diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 0c2bcb1e..62dee66e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -116,6 +116,12 @@ data class PlayerControlsState( val isLocked: Boolean = false, val lockedOverlayVisible: Boolean = false, val controlsVisible: Boolean = true, + val showOpeningOverlay: Boolean = false, + val openingArtwork: String? = null, + val openingLogo: String? = null, + val openingTitle: String = "", + val openingMessage: String? = null, + val openingProgress: Float? = null, val showSubmitIntro: Boolean = false, val showVideoSettings: Boolean = false, val showSources: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 6180bd5a..ea89cb74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -9,10 +9,6 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -30,7 +26,6 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.isDesktop import com.nuvio.app.isIos -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.abs import nuvio.composeapp.generated.resources.* @@ -103,6 +98,10 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { (bufferedSeconds / 10f).coerceIn(0f, 1f) } } + val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl + val openingOverlayWanted = playerSettingsUiState.showLoadingOverlay && + !initialLoadCompleted && + errorMessage == null val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { stringResource( Res.string.compose_player_episode_title_format, @@ -250,7 +249,52 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { subtitleAutoSyncIsLoading = subtitleAutoSyncState.isLoading, subtitleAutoSyncErrorMessage = subtitleAutoSyncState.errorMessage.orEmpty(), closeModalsToken = playerControlsCloseModalsToken, + showOpeningOverlay = openingOverlayWanted, + openingArtwork = background ?: poster, + openingLogo = logo, + openingTitle = title, + openingMessage = p2pInitialLoadingMessage, + openingProgress = p2pInitialLoadingProgress, ) + val desktopControlsReadinessSignature = listOf( + playerSurfaceSourceUrl != null, + title.isNotBlank(), + episodeText.isNotBlank(), + activeStreamTitle.isNotBlank(), + activeProviderName.isNotBlank(), + openingOverlayWanted, + playbackSnapshot.isLoading, + initialLoadCompleted, + errorMessage != null, + background != null || poster != null, + logo != null, + p2pInitialLoadingMessage != null, + p2pInitialLoadingProgress != null, + ).joinToString("|") + LaunchedEffect(isDesktop, desktopControlsReadinessSignature) { + if (!isDesktop) return@LaunchedEffect + println( + "[NuvioDesktopPlayerHandoff] compose controls " + + "sourceReady=${playerSurfaceSourceUrl != null} " + + "titleReady=${title.isNotBlank()} titleLen=${title.length} " + + "episodeReady=${episodeText.isNotBlank()} " + + "streamReady=${activeStreamTitle.isNotBlank()} streamLen=${activeStreamTitle.length} " + + "providerReady=${activeProviderName.isNotBlank()} providerLen=${activeProviderName.length} " + + "openingWanted=$openingOverlayWanted nativeLoading=${playbackSnapshot.isLoading} " + + "initialLoadCompleted=$initialLoadCompleted error=${errorMessage != null} " + + "artwork=${background != null || poster != null} logo=${logo != null} " + + "p2pMessage=${p2pInitialLoadingMessage != null} p2pProgress=${p2pInitialLoadingProgress != null}", + ) + } + LaunchedEffect(isDesktop, playerSurfaceSourceUrl) { + if (isDesktop) { + println( + "[NuvioDesktopPlayerHandoff] surface source " + + "ready=${playerSurfaceSourceUrl != null} " + + "mode=${if (isP2pPlaybackActive) "p2p" else "direct"}", + ) + } + } val gestureCallbacks = rememberSurfaceGestureCallbacks() Box( @@ -283,46 +327,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, ), ) { - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - val desktopOpeningGateEnabled = isDesktop && - playerSettingsUiState.showLoadingOverlay && - !initialLoadCompleted && - errorMessage == null && - playerSurfaceSourceUrl != null - var desktopPlayerMountGateOpen by remember(playerSurfaceSourceUrl, desktopOpeningGateEnabled) { - mutableStateOf(!desktopOpeningGateEnabled) - } - - LaunchedEffect(playerSurfaceSourceUrl, desktopOpeningGateEnabled) { - if (playerSurfaceSourceUrl == null) { - desktopPlayerMountGateOpen = true - return@LaunchedEffect - } - if (!desktopOpeningGateEnabled) { - if (isDesktop) { - logDesktopPlayerGate( - "mount immediate", - playerSurfaceSourceUrl, - "overlay=${playerSettingsUiState.showLoadingOverlay}, initialLoaded=$initialLoadCompleted, error=${errorMessage != null}", - ) - } - desktopPlayerMountGateOpen = true - return@LaunchedEffect - } - - desktopPlayerMountGateOpen = false - logDesktopPlayerGate("hold native mount", playerSurfaceSourceUrl, "${DesktopPlayerOpeningGateDelayMs}ms") - delay(DesktopPlayerOpeningGateDelayMs) - desktopPlayerMountGateOpen = true - logDesktopPlayerGate("open native mount", playerSurfaceSourceUrl) - } - - val shouldMountPlayerSurface = playerSurfaceSourceUrl != null && - (!desktopOpeningGateEnabled || desktopPlayerMountGateOpen) - val mountedPlayerSurfaceSourceUrl = playerSurfaceSourceUrl.takeIf { shouldMountPlayerSurface } - if (mountedPlayerSurfaceSourceUrl != null) { + if (playerSurfaceSourceUrl != null) { PlatformPlayerSurface( - sourceUrl = mountedPlayerSurfaceSourceUrl, + sourceUrl = playerSurfaceSourceUrl, sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, @@ -342,10 +349,34 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { true }, onControllerReady = { controller -> + if (isDesktop) { + println( + "[NuvioDesktopPlayerHandoff] controller ready " + + "titleReady=${title.isNotBlank()} streamReady=${activeStreamTitle.isNotBlank()}", + ) + } playerController = controller playerControllerSourceUrl = activeSourceUrl }, onSnapshot = { snapshot -> + val previousSnapshot = playbackSnapshot + if ( + isDesktop && + ( + previousSnapshot.isLoading != snapshot.isLoading || + previousSnapshot.isPlaying != snapshot.isPlaying || + (previousSnapshot.durationMs <= 0L && snapshot.durationMs > 0L) || + previousSnapshot.isEnded != snapshot.isEnded + ) + ) { + println( + "[NuvioDesktopPlayerHandoff] snapshot " + + "loading=${snapshot.isLoading} wasLoading=${previousSnapshot.isLoading} " + + "playing=${snapshot.isPlaying} durationMs=${snapshot.durationMs} " + + "positionMs=${snapshot.positionMs} ended=${snapshot.isEnded} " + + "initialLoadCompletedBefore=$initialLoadCompleted", + ) + } playbackSnapshot = snapshot if (!snapshot.isLoading) initialLoadCompleted = true if (snapshot.isEnded) { @@ -393,7 +424,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, - suppressOpeningOverlay = isDesktop && desktopPlayerMountGateOpen && playerSurfaceSourceUrl != null, + suppressOpeningOverlay = isDesktop && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -1157,24 +1188,6 @@ private fun BoxScope.RenderPlaybackOverlays( } } -private fun logDesktopPlayerGate(event: String, sourceUrl: String, detail: String? = null) { - val suffix = detail?.let { " ($it)" }.orEmpty() - println("[NuvioDesktopPlayerGate] $event source=${sourceUrl.redactedPlaybackSourceForLog()}$suffix") -} - -private fun String.redactedPlaybackSourceForLog(): String { - val schemeEnd = indexOf("://") - if (schemeEnd < 0) return "opaque" - val scheme = substring(0, schemeEnd) - if (scheme == "file") return "file://" - val host = substring(schemeEnd + 3) - .substringBefore('/') - .substringBefore('?') - .takeIf { it.isNotBlank() } - ?: "" - return "$scheme://$host/" -} - @Composable private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { PlayerScreenModalHosts( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt index 86057282..83c7944a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt @@ -16,7 +16,6 @@ internal const val PlayerVerticalGestureMinHeightFraction = 0.06f internal const val PlayerVerticalGestureDominanceRatio = 1.2f internal const val PlayerSeekProgressSyncDebounceMs = 700L internal const val P2pInitialPreloadTargetBytes = 5_242_880L -internal const val DesktopPlayerOpeningGateDelayMs = 1_800L internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L internal val PlayerSideGestureSystemEdgeExclusion = 72.dp diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index ec944889..30f7d4c7 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -39,6 +39,7 @@ internal class NativePlayerController( private var controlsState = PlayerControlsState() private var lastSentControlsStructureKey: PlayerControlsState? = null private var lastSourceControlsDiagnostics: String? = null + private var lastPlayerControlsDiagnostics: String? = null private var onAction: (PlayerControlsAction) -> Boolean = { false } private var onEvent: (String, Double) -> Boolean = { _, _ -> false } private var onScrubChange: (Long) -> Boolean = { false } @@ -56,6 +57,11 @@ internal class NativePlayerController( initialPositionMs: Long, onError: (String?) -> Unit, ) { + println( + "[NuvioDesktopControls] attach requested " + + "source=${sourceUrl.redactedPlaybackSourceForLog()} " + + "headers=${sourceHeaders.size} playWhenReady=$playWhenReady initialPositionMs=$initialPositionMs", + ) val pending = PendingSource( sourceUrl = sourceUrl, headerLines = sourceHeaders.toHeaderLines(), @@ -74,10 +80,19 @@ internal class NativePlayerController( val pending = pendingSource ?: return SwingUtilities.invokeLater { if (!host.isDisplayable) { + println( + "[NuvioDesktopControls] attach pending " + + "hostDisplayable=false source=${pending.sourceUrl.redactedPlaybackSourceForLog()}", + ) return@invokeLater } dispose() runCatching { + println( + "[NuvioDesktopControls] create native player " + + "source=${pending.sourceUrl.redactedPlaybackSourceForLog()} " + + "headers=${pending.headerLines.size} initialPositionMs=${pending.initialPositionMs}", + ) val hostViewPtr = AwtNativeViewResolver.resolveNativeViewPointer(host) handle = NativePlayerBridge.create( hostViewPtr = hostViewPtr, @@ -89,8 +104,16 @@ internal class NativePlayerController( eventSink = eventSink, ) if (handle == 0L) error("Native player did not return a handle.") + println( + "[NuvioDesktopControls] create native player success " + + "handleReady=true source=${pending.sourceUrl.redactedPlaybackSourceForLog()}", + ) updateControls(controlsState) }.onFailure { error -> + println( + "[NuvioDesktopControls] create native player failed " + + "source=${pending.sourceUrl.redactedPlaybackSourceForLog()} error=${error.message}", + ) pending.onError(error.message) } } @@ -118,10 +141,23 @@ internal class NativePlayerController( println("[NuvioDesktopControls] state handleReady=${currentHandle != 0L} willSend=$willSend $sourceDiagnostics") lastSourceControlsDiagnostics = sourceDiagnostics } + val playerDiagnostics = state.playerControlsDiagnostics() + if (playerDiagnostics != lastPlayerControlsDiagnostics) { + println( + "[NuvioDesktopControls] controls handleReady=${currentHandle != 0L} " + + "willSend=$willSend changed=${structureKey != lastSentControlsStructureKey} $playerDiagnostics", + ) + lastPlayerControlsDiagnostics = playerDiagnostics + } val current = currentHandle.takeIf { it != 0L } ?: return if (structureKey == lastSentControlsStructureKey) return lastSentControlsStructureKey = structureKey - NativePlayerBridge.updateControls(current, state.toControlsJson()) + val controlsJson = state.toControlsJson() + println( + "[NuvioDesktopControls] controls send " + + "jsonBytes=${controlsJson.length} $playerDiagnostics", + ) + NativePlayerBridge.updateControls(current, controlsJson) } fun setResizeMode(mode: PlayerResizeMode) { @@ -649,6 +685,18 @@ private fun PlayerControlsState.toControlsJson(): String = append(',') appendJsonField("controlsVisible", controlsVisible) append(',') + appendJsonField("showOpeningOverlay", showOpeningOverlay) + append(',') + appendJsonField("openingArtwork", openingArtwork.orEmpty()) + append(',') + appendJsonField("openingLogo", openingLogo.orEmpty()) + append(',') + appendJsonField("openingTitle", openingTitle) + append(',') + appendJsonField("openingMessage", openingMessage.orEmpty()) + append(',') + appendJsonField("openingProgress", openingProgress) + append(',') appendJsonField("showSubmitIntro", showSubmitIntro) append(',') appendJsonField("showVideoSettings", showVideoSettings) @@ -746,6 +794,33 @@ private fun PlayerControlsState.sourceControlsDiagnostics(): String { "current=$currentItems disabled=$disabledItems first=$firstItem)" } +private fun PlayerControlsState.playerControlsDiagnostics(): String { + val openingProgressLabel = openingProgress?.let { value -> + (value.coerceIn(0f, 1f) * 100).toInt().toString() + "%" + } ?: "none" + return "metadata(title=${title.isNotBlank()}:${title.length} " + + "episode=${episodeText.isNotBlank()}:${episodeText.length} " + + "stream=${streamTitle.isNotBlank()}:${streamTitle.length} " + + "provider=${providerName.isNotBlank()}:${providerName.length}) " + + "opening(wanted=$showOpeningOverlay artwork=${!openingArtwork.isNullOrBlank()} " + + "logo=${!openingLogo.isNullOrBlank()} title=${openingTitle.isNotBlank()}:${openingTitle.length} " + + "message=${!openingMessage.isNullOrBlank()} progress=$openingProgressLabel) " + + "playback(loading=$isLoading playing=$isPlaying controlsVisible=$controlsVisible locked=$isLocked)" +} + +private fun String.redactedPlaybackSourceForLog(): String { + val schemeEnd = indexOf("://") + if (schemeEnd < 0) return "opaque" + val scheme = substring(0, schemeEnd) + if (scheme == "file") return "file://" + val host = substring(schemeEnd + 3) + .substringBefore('/') + .substringBefore('?') + .takeIf { it.isNotBlank() } + ?: "" + return "$scheme://$host/" +} + private fun StringBuilder.appendJsonField(name: String, value: String) { append('"').append(name).append("\":") append(value.toJsonString()) @@ -759,6 +834,15 @@ private fun StringBuilder.appendJsonField(name: String, value: Long) { append('"').append(name).append("\":").append(value) } +private fun StringBuilder.appendJsonField(name: String, value: Float?) { + append('"').append(name).append("\":") + if (value == null || value.isNaN() || value.isInfinite()) { + append("null") + } else { + append(value.coerceIn(0f, 1f)) + } +} + private fun StringBuilder.appendJsonField(name: String, value: Int) { append('"').append(name).append("\":").append(value) } diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index a1b24167..ed65509e 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -773,6 +773,8 @@ static NSString *limitDiagnosticText(NSString *text) { std::atomic_bool _eventDrainScheduled; BOOL _didLogSoftwareDecodeWarning; BOOL _didFocusControlsWebView; + BOOL _controlsWebReady; + NSString *_pendingControlsJson; double _initialStartSeconds; } @@ -1432,11 +1434,33 @@ static NSString *limitDiagnosticText(NSString *text) { NSLog(@"[NuvioDesktopControls][native] updateControls ignored nil controls JSON"); return; } + _pendingControlsJson = [controlsJson copy]; + NSLog( + @"[NuvioDesktopControls][native] updateControls queued ready=%@ jsonBytes=%lu", + _controlsWebReady ? @"true" : @"false", + (unsigned long)[_pendingControlsJson lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + ); + [self flushPendingControlsJsonIfReady:@"updateControls"]; +} + +- (void)flushPendingControlsJsonIfReady:(NSString *)reason { + if (!_webView || !_pendingControlsJson) { + return; + } + if (!_controlsWebReady) { + NSLog( + @"[NuvioDesktopControls][native] controls flush deferred reason=%@ ready=false jsonBytes=%lu", + reason ?: @"unknown", + (unsigned long)[_pendingControlsJson lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + ); + return; + } + NSString *controlsJson = [_pendingControlsJson copy]; NSString *jsonString = javaScriptStringLiteral(controlsJson); NSString *script = [NSString stringWithFormat: - @"if (window.playerControls) window.playerControls(JSON.parse(%@))", + @"(function(){if(!window.playerControls)return 'missing';window.playerControls(JSON.parse(%@));return 'applied';})()", jsonString]; - [_webView evaluateJavaScript:script completionHandler:^(id _Nullable, NSError * _Nullable error) { + [_webView evaluateJavaScript:script completionHandler:^(id _Nullable jsResult, NSError * _Nullable error) { if (error) { NSDictionary *userInfo = error.userInfo ?: @{}; id message = userInfo[@"WKJavaScriptExceptionMessage"] ?: error.localizedDescription; @@ -1448,6 +1472,21 @@ static NSString *limitDiagnosticText(NSString *text) { line, column ); + return; + } + NSString *resultText = [jsResult isKindOfClass:[NSString class]] ? (NSString *)jsResult : @"unknown"; + NSLog( + @"[NuvioDesktopControls][native] controls flush reason=%@ result=%@ jsonBytes=%lu", + reason ?: @"unknown", + resultText, + (unsigned long)[controlsJson lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + ); + if ([resultText isEqualToString:@"missing"]) { + _controlsWebReady = NO; + return; + } + if (_pendingControlsJson && [_pendingControlsJson isEqualToString:controlsJson]) { + _pendingControlsJson = nil; } }]; } @@ -1458,6 +1497,8 @@ static NSString *limitDiagnosticText(NSString *text) { _timer = nil; [_resizeSettleTimer invalidate]; _resizeSettleTimer = nil; + _controlsWebReady = NO; + _pendingControlsJson = nil; if (_mpv) { mpv_set_wakeup_callback(_mpv, nullptr, nullptr); } @@ -1906,6 +1947,16 @@ static NSString *limitDiagnosticText(NSString *text) { id rawValue = message[@"value"]; NSNumber *value = [rawValue isKindOfClass:[NSNumber class]] ? rawValue : nil; + if ([type isEqualToString:@"controlsReady"]) { + _controlsWebReady = YES; + NSLog( + @"[NuvioDesktopControls][native] controlsReady pending=%@", + _pendingControlsJson ? @"true" : @"false" + ); + [self flushPendingControlsJsonIfReady:@"controlsReady"]; + [self syncControls]; + return; + } if ([type isEqualToString:@"diagnostic"]) { id rawText = message[@"message"]; if ([rawText isKindOfClass:[NSString class]]) { diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index fe13692b..522ade4e 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -161,6 +161,15 @@ pointer-events: none; } + .player.opening-active .top-gradient, + .player.opening-active .bottom-gradient, + .player.opening-active .content { + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: none !important; + } + .player.locked.locked-visible .bottom-gradient, .player.locked.locked-visible .progress { opacity: 1; @@ -383,6 +392,215 @@ animation: center-spin 760ms linear infinite; } + .opening-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + overflow: hidden; + background: rgba(0, 0, 0, .85); + opacity: 0; + pointer-events: none; + transition: opacity 180ms ease; + } + + .opening-overlay.visible { + opacity: 1; + pointer-events: auto; + } + + .opening-back { + position: absolute; + top: calc(20px + env(safe-area-inset-top)); + right: calc(var(--hp) + 20px); + z-index: 3; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border-radius: 999px; + background: rgba(0, 0, 0, .3); + } + + .opening-back:hover { + background: rgba(255, 255, 255, .14); + } + + .opening-back:active, + .opening-back.is-pressed { + transform: scale(.94); + } + + .opening-back svg { + width: 24px; + height: 24px; + } + + .opening-artwork { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + } + + .opening-overlay.has-artwork .opening-artwork { + opacity: 1; + } + + .opening-scrim { + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, .3), + rgba(0, 0, 0, .6), + rgba(0, 0, 0, .8), + rgba(0, 0, 0, .9) + ); + opacity: 0; + } + + .opening-overlay.has-artwork .opening-scrim { + opacity: 1; + } + + .opening-content { + position: relative; + z-index: 2; + width: min(100%, 720px); + min-height: 260px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + opacity: 0; + } + + .opening-overlay.visible .opening-content { + animation: opening-content-in 700ms linear 400ms forwards; + } + + .opening-logo-slot { + position: relative; + width: 300px; + max-width: min(78vw, 300px); + height: 180px; + } + + .opening-logo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + animation: opening-logo-pulse 2000ms linear infinite alternate; + } + + .opening-overlay.has-progress .opening-logo { + animation: none; + } + + .opening-logo-base { + opacity: 1; + } + + .opening-overlay.has-progress .opening-logo-base { + opacity: .25; + } + + .opening-logo-fill-clip { + position: absolute; + inset: 0 auto 0 0; + width: 0%; + overflow: hidden; + } + + .opening-logo-fill { + width: 300px; + max-width: min(78vw, 300px); + height: 180px; + object-fit: contain; + } + + .opening-title { + max-width: min(84vw, 680px); + margin: 0; + color: #fff; + font-size: 42px; + line-height: 1.12; + font-weight: 800; + text-align: center; + animation: opening-logo-pulse 2000ms linear infinite alternate; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .opening-spinner { + width: 54px; + height: 54px; + border-radius: 999px; + border: 3px solid rgba(229, 9, 20, .22); + border-top-color: #e50914; + animation: center-spin 760ms linear infinite; + } + + .opening-status { + width: 100%; + min-height: 40px; + margin-top: 16px; + display: grid; + place-items: center; + color: rgba(255, 255, 255, .72); + font-size: 13px; + line-height: 1.35; + font-weight: 600; + text-align: center; + } + + .opening-status span { + max-width: min(84vw, 620px); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .opening-progress-track { + width: 240px; + height: 4px; + margin-top: 10px; + overflow: hidden; + border-radius: 2px; + background: rgba(255, 255, 255, .2); + } + + .opening-progress-bar { + width: 0%; + height: 100%; + border-radius: inherit; + background: rgba(255, 255, 255, .85); + transition: width 900ms cubic-bezier(.4, 0, .2, 1); + } + + @keyframes opening-content-in { + to { + opacity: 1; + } + } + + @keyframes opening-logo-pulse { + to { + transform: scale(1.04); + } + } + @keyframes center-spin { to { transform: rotate(360deg); @@ -1198,6 +1416,11 @@ .center-controls, .center-status, .progress, + .opening-overlay, + .opening-content, + .opening-logo, + .opening-title, + .opening-progress-bar, .modal-layer, .track-panel, .header-button, @@ -1289,6 +1512,29 @@
+