mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-31 08:39:24 +00:00
feat: loading overlay for webview
This commit is contained in:
parent
cca1d5f994
commit
56885309ae
9 changed files with 196 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ class DesktopPlatform : Platform {
|
|||
actual fun getPlatform(): Platform = DesktopPlatform()
|
||||
|
||||
internal actual val isIos: Boolean = false
|
||||
internal actual val isDesktop: Boolean = true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue