feat: loading overlay for webview

This commit is contained in:
tapframe 2026-06-08 15:39:12 +05:30
parent cca1d5f994
commit 56885309ae
9 changed files with 196 additions and 61 deletions

View file

@ -8,4 +8,5 @@ class AndroidPlatform : Platform {
actual fun getPlatform(): Platform = AndroidPlatform()
internal actual val isIos: Boolean = false
internal actual val isIos: Boolean = false
internal actual val isDesktop: Boolean = false

View file

@ -6,4 +6,5 @@ interface Platform {
expect fun getPlatform(): Platform
internal expect val isIos: Boolean
internal expect val isIos: Boolean
internal expect val isDesktop: Boolean

View file

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

View file

@ -7,3 +7,4 @@ class DesktopPlatform : Platform {
actual fun getPlatform(): Platform = DesktopPlatform()
internal actual val isIos: Boolean = false
internal actual val isDesktop: Boolean = true

View file

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

View file

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

View file

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

View file

@ -8,4 +8,5 @@ class IOSPlatform: Platform {
actual fun getPlatform(): Platform = IOSPlatform()
internal actual val isIos: Boolean = true
internal actual val isIos: Boolean = true
internal actual val isDesktop: Boolean = false