mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 00:59:27 +00:00
feat: add opening overlay support
This commit is contained in:
parent
8a2637432a
commit
a0af6ab07e
6 changed files with 628 additions and 69 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]]) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue