feat: add opening overlay support

This commit is contained in:
tapframe 2026-06-08 22:15:11 +05:30
parent 8a2637432a
commit a0af6ab07e
6 changed files with 628 additions and 69 deletions

View file

@ -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,

View file

@ -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://<local>"
val host = substring(schemeEnd + 3)
.substringBefore('/')
.substringBefore('?')
.takeIf { it.isNotBlank() }
?: "<host>"
return "$scheme://$host/<redacted>"
}
@Composable
private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
PlayerScreenModalHosts(

View file

@ -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

View file

@ -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://<local>"
val host = substring(schemeEnd + 3)
.substringBefore('/')
.substringBefore('?')
.takeIf { it.isNotBlank() }
?: "<host>"
return "$scheme://$host/<redacted>"
}
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)
}

View file

@ -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]]) {

View file

@ -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 @@
<div class="player" id="playerRoot" tabindex="-1">
<div class="top-gradient"></div>
<div class="bottom-gradient"></div>
<section class="opening-overlay" id="openingOverlay" aria-label="Opening player" aria-hidden="true">
<img class="opening-artwork" id="openingArtwork" alt="">
<div class="opening-scrim"></div>
<button class="opening-back" id="openingBackButton" data-command="back" aria-label="Close player">
<svg><use href="#icon-close"></use></svg>
</button>
<div class="opening-content">
<div class="opening-logo-slot" id="openingLogoSlot" hidden>
<img class="opening-logo opening-logo-base" id="openingLogoBase" alt="">
<div class="opening-logo-fill-clip" id="openingLogoFillClip">
<img class="opening-logo-fill" id="openingLogoFill" alt="">
</div>
</div>
<h1 class="opening-title" id="openingTitle" hidden></h1>
<div class="opening-spinner" id="openingSpinner" aria-hidden="true"></div>
<div class="opening-status" id="openingStatus" hidden>
<span id="openingMessage"></span>
</div>
<div class="opening-progress-track" id="openingProgressTrack" hidden>
<div class="opening-progress-bar" id="openingProgressBar"></div>
</div>
</div>
</section>
<div class="content">
<header class="header">
<section class="metadata" aria-label="Playback metadata">
@ -1546,6 +1792,19 @@
const lockButton = document.getElementById("lockButton");
const videoSettingsButton = document.getElementById("videoSettingsButton");
const backButton = document.getElementById("backButton");
const openingOverlay = document.getElementById("openingOverlay");
const openingArtwork = document.getElementById("openingArtwork");
const openingBackButton = document.getElementById("openingBackButton");
const openingLogoSlot = document.getElementById("openingLogoSlot");
const openingLogoBase = document.getElementById("openingLogoBase");
const openingLogoFillClip = document.getElementById("openingLogoFillClip");
const openingLogoFill = document.getElementById("openingLogoFill");
const openingTitle = document.getElementById("openingTitle");
const openingSpinner = document.getElementById("openingSpinner");
const openingStatus = document.getElementById("openingStatus");
const openingMessage = document.getElementById("openingMessage");
const openingProgressTrack = document.getElementById("openingProgressTrack");
const openingProgressBar = document.getElementById("openingProgressBar");
const sourcesButton = document.getElementById("sourcesButton");
const episodesButton = document.getElementById("episodesButton");
const externalButton = document.getElementById("externalButton");
@ -1702,10 +1961,16 @@
onLabel: "On",
offLabel: "Off",
isPlaying: false,
isLoading: false,
isLoading: true,
isLocked: false,
lockedOverlayVisible: false,
controlsVisible: true,
showOpeningOverlay: false,
openingArtwork: "",
openingLogo: "",
openingTitle: "",
openingMessage: "",
openingProgress: null,
showSubmitIntro: false,
showVideoSettings: false,
showSources: false,
@ -1768,6 +2033,13 @@
status: "",
};
let lastSourceControlsDiagnostic = "";
let lastMetadataDiagnostic = "";
let lastOpeningDiagnostic = "";
let lastNativeUpdateDiagnostic = "";
let lastControlsUpdateDiagnostic = "";
let playerNativeUpdateCount = 0;
let playerControlsUpdateCount = 0;
let hasReceivedPlayerControls = false;
const prefersReducedMotion = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const modalTransitionMs = prefersReducedMotion ? 1 : 240;
@ -1835,6 +2107,50 @@
element.hidden = !visible;
};
const setImageSource = (element, source) => {
const url = String(source || "").trim();
if (!url) {
element.removeAttribute("src");
return "";
}
if (element.getAttribute("src") !== url) {
element.setAttribute("src", url);
}
return url;
};
const normalizedOpeningProgress = () => {
const progress = Number(state.openingProgress);
return Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : null;
};
const booleanFlag = value => Boolean(value) ? "1" : "0";
const textReadiness = text => {
const value = String(text || "");
return `${booleanFlag(value.trim())}:${value.length}`;
};
const progressReadiness = progress =>
progress === null ? "none" : `${Math.round(progress * 100)}%`;
const controlsReadinessDiagnostic = () => {
const progress = normalizedOpeningProgress();
return [
`title=${textReadiness(state.title)}`,
`episode=${textReadiness(state.episodeText)}`,
`stream=${textReadiness(state.streamTitle)}`,
`provider=${textReadiness(state.providerName)}`,
`openingWanted=${booleanFlag(state.showOpeningOverlay)}`,
`nativeLoading=${booleanFlag(state.isLoading)}`,
`artwork=${booleanFlag(state.openingArtwork)}`,
`logo=${booleanFlag(state.openingLogo)}`,
`openingTitle=${textReadiness(state.openingTitle)}`,
`message=${textReadiness(state.openingMessage)}`,
`progress=${progressReadiness(progress)}`,
].join(" ");
};
const rangePositionMs = () => {
const durationMs = Math.max(0, Number(state.durationMs) || 0);
return durationMs > 0 ? Math.round(durationMs * Number(seek.value) / 1000) : 0;
@ -2495,12 +2811,76 @@
].join(":"))
.join("|");
const renderOpeningOverlay = () => {
const progress = normalizedOpeningProgress();
const artworkUrl = setImageSource(openingArtwork, state.openingArtwork);
const logoUrl = setImageSource(openingLogoBase, state.openingLogo);
setImageSource(openingLogoFill, state.openingLogo);
const hasProgress = progress !== null;
const openingBootstrap = !hasReceivedPlayerControls;
const wantsOpening = Boolean(openingBootstrap || state.showOpeningOverlay);
const showOpening = Boolean(wantsOpening && state.isLoading);
const titleText = String(state.openingTitle || state.title || "").trim();
const messageText = String(state.openingMessage || "").trim();
const showHorizontalProgress = hasProgress && !logoUrl;
const openingDiagnostic = [
`visible=${booleanFlag(showOpening)}`,
`wanted=${booleanFlag(wantsOpening)}`,
`bootstrap=${booleanFlag(openingBootstrap)}`,
`nativeLoading=${booleanFlag(state.isLoading)}`,
`artwork=${booleanFlag(artworkUrl)}`,
`logo=${booleanFlag(logoUrl)}`,
`title=${textReadiness(titleText)}`,
`message=${textReadiness(messageText)}`,
`progress=${progressReadiness(progress)}`,
].join(" ");
if (openingDiagnostic !== lastOpeningDiagnostic) {
lastOpeningDiagnostic = openingDiagnostic;
postDiagnostic(`opening ${openingDiagnostic}`);
}
root.classList.toggle("opening-active", showOpening);
openingOverlay.classList.toggle("visible", showOpening);
openingOverlay.classList.toggle("has-artwork", Boolean(artworkUrl));
openingOverlay.classList.toggle("has-progress", hasProgress);
openingOverlay.setAttribute("aria-hidden", showOpening ? "false" : "true");
openingBackButton.setAttribute("aria-label", state.closeLabel || "Close player");
openingLogoSlot.hidden = !logoUrl;
openingLogoFillClip.style.width = `${(progress || 0) * 100}%`;
openingTitle.textContent = titleText;
openingTitle.hidden = Boolean(logoUrl || !titleText);
openingSpinner.hidden = Boolean(logoUrl || titleText);
openingMessage.textContent = messageText;
openingStatus.hidden = !(messageText || showHorizontalProgress);
openingProgressTrack.hidden = !showHorizontalProgress;
openingProgressBar.style.width = `${(progress || 0) * 100}%`;
return showOpening;
};
const renderChrome = () => {
const durationMs = Math.max(0, Number(state.durationMs) || 0);
const positionMs = isScrubbing ? scrubPositionMs : Math.max(0, Number(state.positionMs) || 0);
root.classList.toggle("locked", Boolean(state.isLocked));
root.classList.toggle("locked-visible", Boolean(state.isLocked && state.lockedOverlayVisible));
root.classList.toggle("chrome-hidden", Boolean(!state.controlsVisible && !(state.isLocked && state.lockedOverlayVisible)));
const showOpening = renderOpeningOverlay();
const metadataDiagnostic = [
`title=${textReadiness(state.title)}`,
`episode=${textReadiness(state.episodeText)}`,
`stream=${textReadiness(state.streamTitle)}`,
`provider=${textReadiness(state.providerName)}`,
`controlsVisible=${booleanFlag(state.controlsVisible)}`,
`locked=${booleanFlag(state.isLocked)}`,
].join(" ");
if (metadataDiagnostic !== lastMetadataDiagnostic) {
lastMetadataDiagnostic = metadataDiagnostic;
postDiagnostic(`metadata ${metadataDiagnostic}`);
}
title.textContent = state.title || "";
setText(episode, state.episodeText);
@ -2516,7 +2896,7 @@
lockedLabel.textContent = state.tapToUnlockLabel || "Tap to unlock";
bufferingLabel.textContent = state.bufferingLabel || "Buffering...";
const showBuffering = Boolean(state.isLoading && !state.isLocked && !activeModal);
const showBuffering = Boolean(state.isLoading && !state.isLocked && !activeModal && !showOpening);
bufferingStatus.classList.toggle("visible", showBuffering);
bufferingStatus.setAttribute("aria-hidden", showBuffering ? "false" : "true");
@ -2630,6 +3010,10 @@
});
});
openingOverlay.addEventListener("click", event => {
event.stopPropagation();
});
modalElements.forEach(modal => {
modal.addEventListener("click", event => {
event.stopPropagation();
@ -2848,12 +3232,25 @@
});
window.playerUpdate = update => {
playerNativeUpdateCount += 1;
const durationMs = Math.round((Number(update.duration) || 0) * 1000);
const positionMs = Math.round((Number(update.position) || 0) * 1000);
const audioTracks = normalizeTracks(update.audioTracks);
const subtitleTracks = normalizeTracks(update.subtitleTracks);
const audioTracksChanged = trackListSignature(audioTracks) !== trackListSignature(state.audioTracks);
const subtitleTracksChanged = trackListSignature(subtitleTracks) !== trackListSignature(state.subtitleTracks);
const nativeUpdateDiagnostic = [
`loading=${booleanFlag(update.loading || update.isLoading)}`,
`paused=${booleanFlag(update.paused)}`,
`durationReady=${booleanFlag(durationMs > 0)}`,
`audioTracks=${audioTracks.length}`,
`subtitleTracks=${subtitleTracks.length}`,
`openingWanted=${booleanFlag(state.showOpeningOverlay)}`,
].join(" ");
if (nativeUpdateDiagnostic !== lastNativeUpdateDiagnostic) {
lastNativeUpdateDiagnostic = nativeUpdateDiagnostic;
postDiagnostic(`nativeUpdate #${playerNativeUpdateCount} ${nativeUpdateDiagnostic}`);
}
state = {
...state,
durationMs,
@ -2871,8 +3268,15 @@
};
window.playerControls = nextState => {
playerControlsUpdateCount += 1;
const previousCloseToken = Number(state.closeModalsToken) || 0;
state = { ...state, ...nextState };
hasReceivedPlayerControls = true;
const controlsUpdateDiagnostic = controlsReadinessDiagnostic();
if (controlsUpdateDiagnostic !== lastControlsUpdateDiagnostic) {
lastControlsUpdateDiagnostic = controlsUpdateDiagnostic;
postDiagnostic(`controlsUpdate #${playerControlsUpdateCount} ${controlsUpdateDiagnostic}`);
}
const sourceDiagnostic = sourceControlsDiagnostic(state);
if (sourceDiagnostic !== lastSourceControlsDiagnostic) {
lastSourceControlsDiagnostic = sourceDiagnostic;
@ -2928,6 +3332,8 @@
setProgress(0, 0);
focusShortcutRoot();
render();
postDiagnostic("controlsReady window.playerControls=1");
send("controlsReady", 0);
</script>
</body>
</html>