From 56885309ae699da7db034c9f14656b2baadd43ee Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:39:12 +0530 Subject: [PATCH] feat: loading overlay for webview --- .../kotlin/com/nuvio/app/Platform.android.kt | 3 +- .../kotlin/com/nuvio/app/Platform.kt | 3 +- .../features/player/PlayerScreenRuntimeUi.kt | 170 ++++++++++++------ .../features/player/PlayerScreenSupport.kt | 1 + .../kotlin/com/nuvio/app/Platform.desktop.kt | 1 + .../player/desktop/NativePlayerBridge.kt | 3 + .../player/desktop/NativePlayerController.kt | 16 +- .../desktopMain/native/macos/player_bridge.mm | 57 ++++++ .../kotlin/com/nuvio/app/Platform.ios.kt | 3 +- 9 files changed, 196 insertions(+), 61 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index 5ba9da3c..2c5d6eca 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -8,4 +8,5 @@ class AndroidPlatform : Platform { actual fun getPlatform(): Platform = AndroidPlatform() -internal actual val isIos: Boolean = false \ No newline at end of file +internal actual val isIos: Boolean = false +internal actual val isDesktop: Boolean = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt index 5b1cae86..20d37440 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -6,4 +6,5 @@ interface Platform { expect fun getPlatform(): Platform -internal expect val isIos: Boolean \ No newline at end of file +internal expect val isIos: Boolean +internal expect val isDesktop: Boolean 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 85cc2177..cacc4a2b 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 @@ -8,6 +8,11 @@ import androidx.compose.foundation.layout.Box 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 @@ -23,7 +28,9 @@ import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.isSelectableForPlayback 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.* @@ -277,9 +284,45 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { ), ) { val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - if (playerSurfaceSourceUrl != null) { + 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) { PlatformPlayerSurface( - sourceUrl = playerSurfaceSourceUrl, + sourceUrl = mountedPlayerSurfaceSourceUrl, sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, @@ -349,6 +392,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { showP2pRebufferStats = showP2pRebufferStats, p2pRebufferMessage = p2pRebufferMessage, p2pRebufferProgress = p2pRebufferProgress, + suppressOpeningOverlay = isDesktop && desktopPlayerMountGateOpen && playerSurfaceSourceUrl != null, ) RenderPlayerModals(displayedPositionMs = displayedPositionMs) } @@ -991,62 +1035,66 @@ private fun BoxScope.RenderPlaybackOverlays( showP2pRebufferStats: Boolean, p2pRebufferMessage: String?, p2pRebufferProgress: Float?, + suppressOpeningOverlay: Boolean, ) { runtime.run { PlayerPlaybackOverlays( playerControlsLocked = playerControlsLocked, lockedOverlayVisible = lockedOverlayVisible, playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - onUnlock = { unlockPlayerControls() }, - showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, - backdropArtwork = background ?: poster, - logo = logo, - title = title, - onBackWithProgress = { - flushWatchProgress() - args.onBack() - }, - p2pInitialLoadingMessage = p2pInitialLoadingMessage, - p2pInitialLoadingProgress = p2pInitialLoadingProgress, - showP2pRebufferStats = showP2pRebufferStats, - p2pRebufferMessage = p2pRebufferMessage, - p2pRebufferProgress = p2pRebufferProgress, - currentGestureFeedback = currentGestureFeedback, - renderedGestureFeedback = renderedGestureFeedback, - initialLoadCompleted = initialLoadCompleted, - pausedOverlayVisible = pausedOverlayVisible, - activeSkipInterval = activeSkipInterval, - skipIntervalDismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkipInterval = { interval -> - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - }, - onDismissSkipInterval = { skipIntervalDismissed = true }, - sliderEdgePadding = sliderEdgePadding, - overlayBottomPadding = overlayBottomPadding, - isSeries = isSeries, - nextEpisodeInfo = nextEpisodeInfo, - showNextEpisodeCard = showNextEpisodeCard, - nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, - nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, - nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, - onPlayNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - }, - onDismissNextEpisode = { - nextEpisodeAutoPlayJob?.cancel() - showNextEpisodeCard = false - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - }, - errorMessage = errorMessage, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = { unlockPlayerControls() }, + showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && + !initialLoadCompleted && + errorMessage == null && + !suppressOpeningOverlay, + backdropArtwork = background ?: poster, + logo = logo, + title = title, + onBackWithProgress = { + flushWatchProgress() + args.onBack() + }, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + currentGestureFeedback = currentGestureFeedback, + renderedGestureFeedback = renderedGestureFeedback, + initialLoadCompleted = initialLoadCompleted, + pausedOverlayVisible = pausedOverlayVisible, + activeSkipInterval = activeSkipInterval, + skipIntervalDismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkipInterval = { interval -> + playerController?.seekTo((interval.endTime * 1000).toLong()) + scheduleProgressSyncAfterSeek() + skipIntervalDismissed = true + }, + onDismissSkipInterval = { skipIntervalDismissed = true }, + sliderEdgePadding = sliderEdgePadding, + overlayBottomPadding = overlayBottomPadding, + isSeries = isSeries, + nextEpisodeInfo = nextEpisodeInfo, + showNextEpisodeCard = showNextEpisodeCard, + nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, + nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, + nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, + onPlayNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + playNextEpisode() + }, + onDismissNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + showNextEpisodeCard = false + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + }, + errorMessage = errorMessage, onDismissError = { flushWatchProgress() args.onBack() @@ -1055,6 +1103,24 @@ 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 83c7944a..86057282 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,6 +16,7 @@ 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/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt index 37753bf9..79fd9ee7 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt @@ -7,3 +7,4 @@ class DesktopPlatform : Platform { actual fun getPlatform(): Platform = DesktopPlatform() internal actual val isIos: Boolean = false +internal actual val isDesktop: Boolean = true diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt index 9fb988a0..2ae60772 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -30,6 +30,9 @@ internal object NativePlayerBridge { external fun setResizeMode(handle: Long, mode: Int) external fun durationMs(handle: Long): Long external fun positionMs(handle: Long): Long + external fun bufferedPositionMs(handle: Long): Long + external fun isLoading(handle: Long): Boolean + external fun isEnded(handle: Long): Boolean external fun isPaused(handle: Long): Boolean external fun speed(handle: Long): Float external fun audioTracksJson(handle: Long): String 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 7e4ac1c1..b59fc8dd 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 @@ -64,7 +64,9 @@ internal class NativePlayerController( private fun attachPending() { val pending = pendingSource ?: return SwingUtilities.invokeLater { - if (!host.isDisplayable) return@invokeLater + if (!host.isDisplayable) { + return@invokeLater + } dispose() runCatching { val hostViewPtr = AwtNativeViewResolver.resolveNativeViewPointer(host) @@ -172,16 +174,18 @@ internal class NativePlayerController( val current = handle if (current == 0L) return PlayerPlaybackSnapshot(isLoading = true) return runCatching { + val isLoading = NativePlayerBridge.isLoading(current) + val isEnded = NativePlayerBridge.isEnded(current) PlayerPlaybackSnapshot( - isLoading = false, - isPlaying = !NativePlayerBridge.isPaused(current), - isEnded = false, + isLoading = isLoading, + isPlaying = !NativePlayerBridge.isPaused(current) && !isLoading && !isEnded, + isEnded = isEnded, durationMs = NativePlayerBridge.durationMs(current), positionMs = NativePlayerBridge.positionMs(current), - bufferedPositionMs = NativePlayerBridge.positionMs(current), + bufferedPositionMs = NativePlayerBridge.bufferedPositionMs(current), playbackSpeed = NativePlayerBridge.speed(current), ) - }.getOrDefault(PlayerPlaybackSnapshot(isLoading = false)) + }.getOrDefault(PlayerPlaybackSnapshot(isLoading = true)) } fun dispose() { diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index 1e572717..4afdb763 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -114,6 +114,9 @@ uint64_t mpv_render_context_update(mpv_render_context *ctx); - (void)setResizeMode:(int)mode; - (long long)durationMs; - (long long)positionMs; +- (long long)bufferedPositionMs; +- (BOOL)isLoading; +- (BOOL)isEnded; - (NSString *)audioTracksJson; - (NSString *)subtitleTracksJson; - (void)selectAudioTrackId:(int)trackId; @@ -551,6 +554,27 @@ static NSString *javaScriptStringLiteral(NSString *value) { return (long long)llround([self doubleProperty:"time-pos" fallback:0.0] * 1000.0); } +- (long long)bufferedPositionMs { + double position = [self doubleProperty:"time-pos" fallback:0.0]; + double cached = [self doubleProperty:"demuxer-cache-time" fallback:0.0]; + return (long long)llround(fmax(position + cached, 0.0) * 1000.0); +} + +- (BOOL)isLoading { + BOOL paused = [self isPaused]; + BOOL eofReached = [self isEnded]; + BOOL idle = [self flagProperty:"core-idle" fallback:YES]; + BOOL seeking = [self flagProperty:"seeking" fallback:NO]; + BOOL bufferingCache = [self flagProperty:"paused-for-cache" fallback:NO]; + BOOL fileReady = [self doubleProperty:"duration" fallback:0.0] > 0.0 + || [self int64Property:"track-list/count" fallback:0] > 0; + return !fileReady || (idle && !paused && !eofReached) || seeking || bufferingCache; +} + +- (BOOL)isEnded { + return [self flagProperty:"eof-reached" fallback:NO]; +} + - (NSString *)audioTracksJson { return [self tracksJsonForType:@"audio"]; } @@ -1105,6 +1129,39 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs( return [player positionMs]; } +extern "C" JNIEXPORT jlong JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_bufferedPositionMs( + JNIEnv * /* env */, + jobject /* bridge */, + jlong handle +) { + if (handle == 0) return 0; + MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; + return [player bufferedPositionMs]; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isLoading( + JNIEnv * /* env */, + jobject /* bridge */, + jlong handle +) { + if (handle == 0) return JNI_TRUE; + MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; + return [player isLoading] ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isEnded( + JNIEnv * /* env */, + jobject /* bridge */, + jlong handle +) { + if (handle == 0) return JNI_FALSE; + MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; + return [player isEnded] ? JNI_TRUE : JNI_FALSE; +} + extern "C" JNIEXPORT jboolean JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused( JNIEnv * /* env */, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt index ee348bc5..4f79e7c8 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -8,4 +8,5 @@ class IOSPlatform: Platform { actual fun getPlatform(): Platform = IOSPlatform() -internal actual val isIos: Boolean = true \ No newline at end of file +internal actual val isIos: Boolean = true +internal actual val isDesktop: Boolean = false