diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt new file mode 100644 index 000000000..535fc3410 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt @@ -0,0 +1,280 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadItem +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamAutoPlaySource +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( + previousJob: Job?, + nextEpisodeInfo: NextEpisodeInfo?, + allEpisodes: List, + parentMetaId: String, + parentMetaType: String, + contentType: String?, + settings: PlayerSettingsUiState, + currentStreamBingeGroup: String?, + onDownloadedEpisodeSelected: (DownloadItem, MetaVideo) -> Unit, + onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit, + onManualSelectionRequired: (MetaVideo) -> Unit, + onSearchingChanged: (Boolean) -> Unit, + onSourceNameChanged: (String?) -> Unit, + onCountdownChanged: (Int?) -> Unit, + onNextEpisodeCardVisibleChanged: (Boolean) -> Unit, +): Job? { + val nextVideoId = nextEpisodeInfo?.videoId ?: return null + val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return null + if (nextEpisodeInfo.hasAired != true) return null + + val downloadedNextEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = nextVideo.season, + episodeNumber = nextVideo.episode, + videoId = nextVideo.id, + ) + if (downloadedNextEpisode != null) { + onDownloadedEpisodeSelected(downloadedNextEpisode, nextVideo) + return null + } + + previousJob?.cancel() + onSearchingChanged(true) + onSourceNameChanged(null) + onCountdownChanged(null) + + val type = contentType ?: parentMetaType + val shouldAutoSelectInManualMode = + settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && + ( + settings.streamAutoPlayNextEpisodeEnabled || + settings.streamAutoPlayPreferBingeGroup + ) + + val bingeGroupOnlyManualMode = + shouldAutoSelectInManualMode && + !settings.streamAutoPlayNextEpisodeEnabled && + settings.streamAutoPlayPreferBingeGroup + + val effectiveMode = if (shouldAutoSelectInManualMode) { + StreamAutoPlayMode.FIRST_STREAM + } else { + settings.streamAutoPlayMode + } + val effectiveSource = if (shouldAutoSelectInManualMode) { + StreamAutoPlaySource.ALL_SOURCES + } else { + settings.streamAutoPlaySource + } + val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedAddons + } + val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedPlugins + } + val effectiveRegex = if (shouldAutoSelectInManualMode) { + "" + } else { + settings.streamAutoPlayRegex + } + val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + } + + return launch { + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = nextVideo.id, + season = nextVideo.season, + episode = nextVideo.episode, + ) + + val installedAddonNames = AddonRepository.uiState.value.addons + .enabledAddons() + .map { it.displayTitle } + .toSet() + val debridSettings = DebridSettingsRepository.snapshot() + + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null + val autoSelectSettled = CompletableDeferred() + + fun settleAutoSelect() { + if (!autoSelectSettled.isCompleted) { + autoSelectSettled.complete(Unit) + } + } + + fun selectStream(stream: StreamItem) { + autoSelectTriggered = true + selectedStream = stream + settleAutoSelect() + } + + fun finishWithoutSelection() { + autoSelectTriggered = true + settleAutoSelect() + } + + fun trySelectStream(streams: List): StreamItem? = + StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + bingeGroupOnly = bingeGroupOnlyManualMode, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + + fun tryBingeGroupOnly(streams: List): StreamItem? { + if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + } + + val innerJob = launch { + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + + val allStreams = state.groups.flatMap { it.streams } + + if (autoSelectTriggered) { + // Already resolved. + } else if (timeoutElapsed) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } else if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + selectStream(earlyMatch) + } + } + + if (!autoSelectTriggered && !state.isAnyLoading) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + if (!autoSelectTriggered) { + finishWithoutSelection() + } + return@collectLatest + } + + if (autoSelectTriggered) return@collectLatest + } + } + + val timeoutMs = timeoutSeconds * 1_000L + val isBoundedTimeout = timeoutSeconds in 1..30 + + if (isBoundedTimeout) { + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + innerJob.cancel() + finishWithoutSelection() + } else { + val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null && !autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } else { + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + trySelectStream(allStreams)?.let(::selectStream) + } + } + val completed = withTimeoutOrNull(NEXT_EPISODE_HARD_TIMEOUT_MS) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null && !autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + + onSearchingChanged(false) + val selected = selectedStream + if (selected != null) { + onSourceNameChanged(selected.addonName) + for (i in 3 downTo 1) { + onCountdownChanged(i) + delay(1000) + } + onEpisodeStreamSelected(selected, nextVideo) + onNextEpisodeCardVisibleChanged(false) + onCountdownChanged(null) + onSourceNameChanged(null) + } else { + onManualSelectionRequired(nextVideo) + onNextEpisodeCardVisibleChanged(false) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt new file mode 100644 index 000000000..ea0a925fc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt @@ -0,0 +1,165 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.p2p.P2pLoadingStatus +import com.nuvio.app.features.player.skip.NextEpisodeCard +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.SkipIntroButton +import com.nuvio.app.features.player.skip.SkipInterval + +@Composable +internal fun BoxScope.PlayerPlaybackOverlays( + playerControlsLocked: Boolean, + lockedOverlayVisible: Boolean, + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + horizontalSafePadding: Dp, + onUnlock: () -> Unit, + showOpeningOverlay: Boolean, + backdropArtwork: String?, + logo: String?, + title: String, + onBackWithProgress: () -> Unit, + p2pInitialLoadingMessage: String?, + p2pInitialLoadingProgress: Float?, + showP2pRebufferStats: Boolean, + p2pRebufferMessage: String?, + p2pRebufferProgress: Float?, + currentGestureFeedback: GestureFeedbackState?, + renderedGestureFeedback: GestureFeedbackState?, + initialLoadCompleted: Boolean, + pausedOverlayVisible: Boolean, + activeSkipInterval: SkipInterval?, + skipIntervalDismissed: Boolean, + controlsVisible: Boolean, + onSkipInterval: (SkipInterval) -> Unit, + onDismissSkipInterval: () -> Unit, + sliderEdgePadding: Dp, + overlayBottomPadding: Dp, + isSeries: Boolean, + nextEpisodeInfo: NextEpisodeInfo?, + showNextEpisodeCard: Boolean, + nextEpisodeAutoPlaySearching: Boolean, + nextEpisodeAutoPlaySourceName: String?, + nextEpisodeAutoPlayCountdown: Int?, + onPlayNextEpisode: () -> Unit, + onDismissNextEpisode: () -> Unit, + errorMessage: String?, + onDismissError: () -> Unit, +) { + AnimatedVisibility( + visible = playerControlsLocked && lockedOverlayVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + LockedPlayerOverlay( + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = onUnlock, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = showOpeningOverlay, + enter = fadeIn(), + exit = fadeOut(), + ) { + OpeningOverlay( + artwork = backdropArtwork, + logo = logo, + title = title, + onBack = onBackWithProgress, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + message = p2pInitialLoadingMessage, + progress = p2pInitialLoadingProgress, + ) + } + + P2pLoadingStatus( + visible = showP2pRebufferStats && errorMessage == null, + message = p2pRebufferMessage, + progress = p2pRebufferProgress, + modifier = Modifier + .align(Alignment.Center) + .padding(top = 58.dp), + ) + + AnimatedVisibility( + visible = currentGestureFeedback != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + renderedGestureFeedback?.let { feedback -> + GestureFeedbackPill( + feedback = feedback, + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) + .padding(horizontal = horizontalSafePadding) + .padding(top = 40.dp), + ) + } + } + } + + if (!playerControlsLocked) { + SkipIntroButton( + interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, + dismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkip = { + activeSkipInterval?.let(onSkipInterval) + }, + onDismiss = onDismissSkipInterval, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), + ) + } + + if (isSeries && !playerControlsLocked) { + NextEpisodeCard( + nextEpisode = nextEpisodeInfo, + visible = showNextEpisodeCard, + isAutoPlaySearching = nextEpisodeAutoPlaySearching, + autoPlaySourceName = nextEpisodeAutoPlaySourceName, + autoPlayCountdownSec = nextEpisodeAutoPlayCountdown, + onPlayNext = onPlayNextEpisode, + onDismiss = onDismissNextEpisode, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = sliderEdgePadding, bottom = overlayBottomPadding), + ) + } + + if (errorMessage != null) { + ErrorModal( + message = errorMessage, + onDismiss = onDismissError, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e00f53fc6..368d2e5a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1,152 +1,7 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioToastController -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.debrid.DirectDebridPlayableResult -import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver -import com.nuvio.app.features.debrid.toastMessage -import com.nuvio.app.features.addons.AddonRepository -import com.nuvio.app.features.addons.AddonResource -import com.nuvio.app.features.addons.ManagedAddon -import com.nuvio.app.features.addons.enabledAddons -import com.nuvio.app.features.addons.httpGetTextWithHeaders -import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.MetaScreenSettingsRepository -import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.downloads.DownloadItem -import com.nuvio.app.features.downloads.DownloadsRepository -import com.nuvio.app.features.p2p.P2pConsentDialog -import com.nuvio.app.features.p2p.P2pLoadingStatus -import com.nuvio.app.features.p2p.P2pSettingsRepository -import com.nuvio.app.features.p2p.P2pStreamRequest -import com.nuvio.app.features.p2p.P2pStreamingEngine -import com.nuvio.app.features.p2p.P2pStreamingState -import com.nuvio.app.features.p2p.formatP2pMegabytes -import com.nuvio.app.features.p2p.formatP2pSpeed -import com.nuvio.app.features.player.skip.NextEpisodeCard -import com.nuvio.app.features.player.skip.NextEpisodeInfo -import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules -import com.nuvio.app.features.player.skip.SkipIntroButton -import com.nuvio.app.features.player.skip.SkipIntroRepository -import com.nuvio.app.features.player.skip.SkipInterval -import com.nuvio.app.features.streams.BingeGroupCacheRepository -import com.nuvio.app.features.streams.StreamAutoPlayMode -import com.nuvio.app.features.streams.StreamAutoPlaySelector -import com.nuvio.app.features.streams.StreamAutoPlaySource -import com.nuvio.app.features.streams.StreamItem -import com.nuvio.app.features.streams.StreamLinkCacheRepository -import com.nuvio.app.features.streams.StreamsUiState -import com.nuvio.app.features.tmdb.TmdbService -import com.nuvio.app.features.trakt.TraktScrobbleItem -import com.nuvio.app.features.trakt.TraktScrobbleRepository -import com.nuvio.app.features.watched.WatchedRepository -import com.nuvio.app.features.watchprogress.WatchProgressClock -import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession -import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.isIos -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.getString -import org.jetbrains.compose.resources.stringResource -import kotlin.math.abs -import kotlin.math.roundToLong -import kotlin.math.roundToInt - -private const val PlaybackProgressPersistIntervalMs = 60_000L -private const val PlayerDoubleTapSeekStepMs = 10_000L -private const val PlayerDoubleTapSeekResetDelayMs = 800L -private const val PlayerLockedOverlayDurationMs = 2_000L -private const val PlayerLeftGestureBoundary = 0.4f -private const val PlayerRightGestureBoundary = 0.6f -private const val PlayerVerticalGestureSensitivity = 1f -private const val PlayerSeekProgressSyncDebounceMs = 700L -private const val P2pInitialPreloadTargetBytes = 5_242_880L -/** Hard ceiling for next-episode stream search to prevent hanging forever. */ -private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L -private val PlayerSliderOverlayGap = 12.dp -private val PlayerTimeRowHeight = 36.dp -private val PlayerActionRowHeight = 50.dp - -private fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) = - metrics.sliderBottomOffset + - metrics.sliderTouchHeight + - PlayerTimeRowHeight + - PlayerActionRowHeight + - PlayerSliderOverlayGap - -private enum class PlayerSideGesture { - Brightness, - Volume, -} - -private enum class PlayerSeekDirection { - Backward, - Forward, -} - -private enum class PlayerGestureMode { - HorizontalSeek, - Brightness, - Volume, -} - -private data class PlayerAccumulatedSeekState( - val direction: PlayerSeekDirection, - val baselinePositionMs: Long, - val amountMs: Long, -) - -private data class PendingPlayerP2pSwitch( - val stream: StreamItem, - val episode: MetaVideo?, - val isAutoPlay: Boolean, -) @Composable fun PlayerScreen( @@ -183,2955 +38,40 @@ fun PlayerScreen( initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, ) { - LockPlayerToLandscape() - val playerSettingsUiState by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pSettingsUiState by remember { - P2pSettingsRepository.ensureLoaded() - P2pSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle() - val metaScreenSettingsUiState by remember { - MetaScreenSettingsRepository.ensureLoaded() - MetaScreenSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val watchedUiState by remember { - WatchedRepository.ensureLoaded() - WatchedRepository.uiState - }.collectAsStateWithLifecycle() - val watchProgressUiState by remember { - WatchProgressRepository.ensureLoaded() - WatchProgressRepository.uiState - }.collectAsStateWithLifecycle() - - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - ) { - val horizontalSafePadding = playerHorizontalSafePadding() - val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } - val sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding - val overlayBottomPadding = sliderOverlayBottomPadding(metrics) - val scope = rememberCoroutineScope() - val hapticFeedback = LocalHapticFeedback.current - val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) - val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) - val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) - val downloadedLabel = stringResource(Res.string.compose_player_downloaded) - val airsPrefix = stringResource(Res.string.compose_player_airs_prefix) - val tbaLabel = stringResource(Res.string.compose_player_tba) - val genericUnknownLabel = stringResource(Res.string.generic_unknown) - val parentalGuideLabels = ParentalGuideLabels( - nudity = stringResource(Res.string.parental_nudity), - violence = stringResource(Res.string.parental_violence), - profanity = stringResource(Res.string.parental_profanity), - alcohol = stringResource(Res.string.parental_alcohol), - frightening = stringResource(Res.string.parental_frightening), - severe = stringResource(Res.string.parental_severity_severe), - moderate = stringResource(Res.string.parental_severity_moderate), - mild = stringResource(Res.string.parental_severity_mild), - ) - val gestureController = rememberPlayerGestureController() - var controlsVisible by rememberSaveable { mutableStateOf(true) } - var playerControlsLocked by rememberSaveable { mutableStateOf(false) } - // Active playback state (mutable to support source/episode switching) - var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } - var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } - var activeSourceHeaders by remember(sourceUrl, sourceHeaders) { - mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) - } - var activeSourceResponseHeaders by remember(sourceUrl, sourceResponseHeaders) { - mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) - } - var activeTorrentInfoHash by rememberSaveable { mutableStateOf(torrentInfoHash) } - var activeTorrentFileIdx by rememberSaveable { mutableStateOf(torrentFileIdx) } - var activeTorrentFilename by rememberSaveable { mutableStateOf(torrentFilename) } - var activeTorrentMagnetUri by rememberSaveable { mutableStateOf(torrentMagnetUri) } - var activeTorrentTrackers by remember { mutableStateOf(torrentTrackers) } - var p2pResolvedSourceUrl by remember { mutableStateOf(null) } - val activePlaybackIdentity = activeTorrentInfoHash - ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" } - ?: activeSourceUrl - var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) } - var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) } - var activeProviderName by rememberSaveable { mutableStateOf(providerName) } - var activeProviderAddonId by rememberSaveable { mutableStateOf(providerAddonId) } - var currentStreamBingeGroup by rememberSaveable { mutableStateOf(initialBingeGroup) } - var activeSeasonNumber by rememberSaveable { mutableStateOf(seasonNumber) } - var activeEpisodeNumber by rememberSaveable { mutableStateOf(episodeNumber) } - var activeEpisodeTitle by rememberSaveable { mutableStateOf(episodeTitle) } - var activeEpisodeThumbnail by rememberSaveable { mutableStateOf(episodeThumbnail) } - var activeVideoId by rememberSaveable { mutableStateOf(videoId) } - var activeInitialPositionMs by rememberSaveable { mutableStateOf(initialPositionMs) } - var activeInitialProgressFraction by rememberSaveable { mutableStateOf(initialProgressFraction) } - var shouldPlay by rememberSaveable(activePlaybackIdentity) { mutableStateOf(true) } - var resizeMode by rememberSaveable(playerSettingsUiState.resizeMode) { - mutableStateOf(playerSettingsUiState.resizeMode) - } - var layoutSize by remember { mutableStateOf(IntSize.Zero) } - var playbackSnapshot by remember { mutableStateOf(PlayerPlaybackSnapshot()) } - var playerController by remember { mutableStateOf(null) } - var playerControllerSourceUrl by remember { mutableStateOf(null) } - var errorMessage by remember { mutableStateOf(null) } - val keepScreenAwake = errorMessage == null && - (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading)) - EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) - var isScrubbingTimeline by remember { mutableStateOf(false) } - var scrubbingPositionMs by remember { mutableStateOf(null) } - var pausedOverlayVisible by remember { mutableStateOf(false) } - var gestureFeedback by remember { mutableStateOf(null) } - var liveGestureFeedback by remember { mutableStateOf(null) } - var renderedGestureFeedback by remember { mutableStateOf(null) } - var lockedOverlayVisible by remember { mutableStateOf(false) } - var gestureMessageJob by remember { mutableStateOf(null) } - var accumulatedSeekResetJob by remember { mutableStateOf(null) } - var seekProgressSyncJob by remember { mutableStateOf(null) } - var accumulatedSeekState by remember { mutableStateOf(null) } - var initialLoadCompleted by remember(activePlaybackIdentity) { mutableStateOf(false) } - var speedBoostRestoreSpeed by remember(activePlaybackIdentity) { mutableStateOf(null) } - var isHoldToSpeedGestureActive by remember(activePlaybackIdentity) { mutableStateOf(false) } - var initialSeekApplied by remember( - activePlaybackIdentity, - activeInitialPositionMs, - activeInitialProgressFraction, - ) { - val initialProgressFraction = activeInitialProgressFraction - mutableStateOf( - activeInitialPositionMs <= 0L && - (initialProgressFraction == null || initialProgressFraction <= 0f), - ) - } - var lastProgressPersistEpochMs by remember(activePlaybackIdentity) { mutableStateOf(0L) } - var previousIsPlaying by remember(activePlaybackIdentity) { mutableStateOf(false) } - var hasRequestedScrobbleStartForCurrentItem by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var scrobbleStartRequestGeneration by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(0L) } - var pendingScrobbleStartAfterSeek by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var hasSentCompletionScrobbleForCurrentItem by remember( - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var currentTraktScrobbleItem by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(null) } - val backdropArtwork = background ?: poster - val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs - val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null - val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback - val isP2pPlaybackActive = activeTorrentInfoHash != null - val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming - val p2pPeerInfo = p2pStats?.let { stats -> - stringResource(Res.string.player_torrent_peer_info, stats.seeds, stats.peers) - } - val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) } - val p2pInitialLoadingMessage = when { - !isP2pPlaybackActive || initialLoadCompleted -> null - p2pStreamingState is P2pStreamingState.Connecting -> { - stringResource(Res.string.player_torrent_connecting_peers) - } - p2pStats != null -> { - if (p2pSettingsUiState.hideTorrentStats) { - null - } else { - stringResource( - Res.string.player_torrent_buffered_status, - formatP2pMegabytes(p2pStats.preloadedBytes), - p2pPeerInfo.orEmpty(), - p2pDownloadSpeed.orEmpty(), - ) - } - } - else -> stringResource(Res.string.player_torrent_starting_engine) - } - val p2pInitialLoadingProgress = when { - !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null - else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f) - } - val showP2pRebufferStats = isP2pPlaybackActive && - initialLoadCompleted && - playbackSnapshot.isLoading && - p2pStats != null && - !p2pSettingsUiState.hideTorrentStats - val p2pRebufferMessage = when { - !showP2pRebufferStats -> null - else -> { - val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L) - .coerceAtLeast(0L) - val peerInfo = p2pPeerInfo.orEmpty() - val speed = p2pDownloadSpeed.orEmpty() - "${bufferedSeconds}s buffered · $peerInfo · $speed" - } - } - val p2pRebufferProgress = when { - !showP2pRebufferStats -> null - else -> { - val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f) - .coerceAtLeast(0f) - (bufferedSeconds / 10f).coerceIn(0f, 1f) - } - } - - LaunchedEffect(currentGestureFeedback) { - if (currentGestureFeedback != null) { - renderedGestureFeedback = currentGestureFeedback - } - } - - // Sources & Episodes Panel state - var showSourcesPanel by remember { mutableStateOf(false) } - var showEpisodesPanel by remember { mutableStateOf(false) } - var showSubmitIntroModal by remember { mutableStateOf(false) } - var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") } - var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") } - var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") } - var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) } - val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() - val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() - val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() - var playerMetaVideos by remember(parentMetaType, parentMetaId) { - mutableStateOf(MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList()) - } - val allEpisodes = remember(playerMetaVideos) { playerMetaVideos } - val isSeries = parentMetaType == "series" - - // Skip intro/outro/recap state - var skipIntervals by remember { mutableStateOf>(emptyList()) } - var activeSkipInterval by remember { mutableStateOf(null) } - var skipIntervalDismissed by remember { mutableStateOf(false) } - - // Parental guide overlay state - var parentalWarnings by remember { mutableStateOf>(emptyList()) } - var showParentalGuide by remember { mutableStateOf(false) } - var parentalGuideHasShown by remember { mutableStateOf(false) } - var playbackStartedForParentalGuide by remember { mutableStateOf(false) } - - // Next episode state - var nextEpisodeInfo by remember { mutableStateOf(null) } - var showNextEpisodeCard by remember { mutableStateOf(false) } - var nextEpisodeAutoPlaySearching by remember { mutableStateOf(false) } - var nextEpisodeAutoPlaySourceName by remember { mutableStateOf(null) } - var nextEpisodeAutoPlayCountdown by remember { mutableStateOf(null) } - var nextEpisodeAutoPlayJob by remember { mutableStateOf(null) } - var pendingP2pSwitch by remember { mutableStateOf(null) } - - LaunchedEffect(parentMetaType, parentMetaId) { - playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList() - if (playerMetaVideos.isEmpty()) { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } - - LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) { - val currentMeta = metaUiState.meta ?: return@LaunchedEffect - if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) { - playerMetaVideos = currentMeta.videos - } - } - - // Persist binge group per content so subsequent episode plays - // (from CW, Details, or next-episode) can reuse the same source group. - LaunchedEffect(currentStreamBingeGroup, parentMetaId) { - val bg = currentStreamBingeGroup - if (bg != null && parentMetaId.isNotBlank()) { - BingeGroupCacheRepository.save(parentMetaId, bg) - } - } - - ManagePlayerPictureInPicture( - isPlaying = playbackSnapshot.isPlaying, - playerSize = layoutSize, - ) - - val playbackSession = remember( - contentType, - parentMetaId, - parentMetaType, - activeVideoId, - title, - logo, - poster, - background, - activeSeasonNumber, - activeEpisodeNumber, - activeEpisodeTitle, - activeEpisodeThumbnail, - activeProviderName, - activeProviderAddonId, - activeStreamTitle, - activeStreamSubtitle, - pauseDescription, - activeSourceUrl, - activeSourceAudioUrl, - ) { - WatchProgressPlaybackSession( - contentType = contentType ?: parentMetaType, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - fallbackVideoId = activeVideoId, - ), - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - episodeThumbnail = activeEpisodeThumbnail, - providerName = activeProviderName, - providerAddonId = activeProviderAddonId, - lastStreamTitle = activeStreamTitle, - lastStreamSubtitle = activeStreamSubtitle, - pauseDescription = pauseDescription, - lastSourceUrl = activeSourceUrl, - ) - } - - fun currentPlaybackProgressPercent(snapshot: PlayerPlaybackSnapshot = playbackSnapshot): Float { - val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f - return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f) - .coerceIn(0f, 100f) - } - - suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( - contentType = contentType ?: parentMetaType, - parentMetaId = parentMetaId, - videoId = activeVideoId, + PlayerScreenContent( + PlayerScreenArgs( title = title, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + providerName = providerName, + streamTitle = streamTitle, + streamSubtitle = streamSubtitle, + initialBingeGroup = initialBingeGroup, + pauseDescription = pauseDescription, + onBack = onBack, + onOpenInExternalPlayer = onOpenInExternalPlayer, + modifier = modifier, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + contentType = contentType, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + providerAddonId = providerAddonId, + torrentInfoHash = torrentInfoHash, + torrentFileIdx = torrentFileIdx, + torrentFilename = torrentFilename, + torrentMagnetUri = torrentMagnetUri, + torrentTrackers = torrentTrackers, + initialPositionMs = initialPositionMs, + initialProgressFraction = initialProgressFraction, ) - - fun emitTraktScrobbleStart() { - if (hasRequestedScrobbleStartForCurrentItem) return - hasRequestedScrobbleStartForCurrentItem = true - val requestGeneration = scrobbleStartRequestGeneration + 1L - scrobbleStartRequestGeneration = requestGeneration - - scope.launch { - val item = currentTraktScrobbleItem() - if (item == null) { - hasRequestedScrobbleStartForCurrentItem = false - return@launch - } - if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { - return@launch - } - currentTraktScrobbleItem = item - TraktScrobbleRepository.scrobbleStart( - item = item, - progressPercent = currentPlaybackProgressPercent(), - ) - } - } - - fun emitTraktScrobbleStop(progressPercent: Float? = null) { - val provided = progressPercent - if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return - - val percent = provided ?: currentPlaybackProgressPercent() - val itemSnapshot = currentTraktScrobbleItem - scope.launch(NonCancellable) { - val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch - TraktScrobbleRepository.scrobbleStop( - item = item, - progressPercent = percent, - ) - } - currentTraktScrobbleItem = null - hasRequestedScrobbleStartForCurrentItem = false - scrobbleStartRequestGeneration += 1L - } - - fun emitStopScrobbleForCurrentProgress() { - val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - return - } - - if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { - hasSentCompletionScrobbleForCurrentItem = true - emitTraktScrobbleStop(progressPercent) - } - } - - fun tryShowParentalGuide() { - if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { - playbackStartedForParentalGuide = true - controlsVisible = true - showParentalGuide = true - parentalGuideHasShown = true - } - } - - suspend fun resolveParentalGuideImdbId(): String? { - val candidates = listOf(parentMetaId, activeVideoId) - candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it } - val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null - return TmdbService.tmdbToImdb( - tmdbId = tmdbId, - mediaType = contentType ?: parentMetaType, - ) - } - - fun flushWatchProgress() { - emitStopScrobbleForCurrentProgress() - WatchProgressRepository.flushPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - ) - } - - fun scheduleProgressSyncAfterSeek() { - val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying - seekProgressSyncJob?.cancel() - seekProgressSyncJob = scope.launch { - delay(PlayerSeekProgressSyncDebounceMs) - WatchProgressRepository.upsertPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - ) - - val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay - if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (shouldRestartScrobbleNow) { - pendingScrobbleStartAfterSeek = true - } - } - } - } - - val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) { - { - flushWatchProgress() - onBack() - } - } - - var showAudioModal by remember { mutableStateOf(false) } - var showSubtitleModal by remember { mutableStateOf(false) } - var showVideoSettingsModal by remember { mutableStateOf(false) } - var audioTracks by remember { mutableStateOf>(emptyList()) } - var subtitleTracks by remember { mutableStateOf>(emptyList()) } - var selectedAudioIndex by remember { mutableStateOf(-1) } - var selectedSubtitleIndex by remember { mutableStateOf(-1) } - var selectedAddonSubtitleId by remember { mutableStateOf(null) } - var useCustomSubtitles by remember { mutableStateOf(false) } - var preferredAudioSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) } - var preferredSubtitleSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) } - var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) } - val subtitleStyle = playerSettingsUiState.subtitleStyle - val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() - val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() - val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() - val activeAddonSubtitleType = contentType ?: parentMetaType - val addonSubtitleFetchKey = remember( - addonsUiState.addons, - activeAddonSubtitleType, - activeVideoId, - ) { - buildAddonSubtitleFetchKey( - addons = addonsUiState.addons, - type = activeAddonSubtitleType, - videoId = activeVideoId, - ) - } - var autoFetchedAddonSubtitlesForKey by rememberSaveable(activePlaybackIdentity, activeVideoId) { - mutableStateOf(null) - } - var trackPreferenceRestoreApplied by rememberSaveable(activePlaybackIdentity, parentMetaId) { - mutableStateOf(false) - } - var subtitleDelayMs by rememberSaveable(playbackSession.videoId) { - mutableStateOf( - PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) - ?: 0 - ) - } - var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) { - mutableStateOf(SubtitleAutoSyncUiState()) - } - val visibleAddonSubtitles = remember( - addonSubtitles, - playerSettingsUiState.preferredSubtitleLanguage, - playerSettingsUiState.secondaryPreferredSubtitleLanguage, - subtitleStyle.showOnlyPreferredLanguages, - playerSettingsUiState.addonSubtitleStartupMode, - selectedAddonSubtitleId, - ) { - filterAddonSubtitlesForSettings( - subtitles = addonSubtitles, - settings = playerSettingsUiState, - selectedAddonSubtitleId = selectedAddonSubtitleId, - ) - } - val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) { - addonSubtitles.firstOrNull { subtitle -> - subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId - } - } - - fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) { - if (parentMetaId.isBlank()) return - val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference() - PlayerTrackPreferenceStorage.save(parentMetaId, update(current)) - } - - fun persistAudioPreference(track: AudioTrack?) { - updateTrackPreference { current -> - current.copy( - audioLanguage = track?.language, - audioName = track?.label, - audioTrackId = track?.id, - ) - } - } - - fun persistInternalSubtitlePreference(track: SubtitleTrack?) { - updateTrackPreference { current -> - current.copy( - subtitleType = if (track == null) { - PersistedSubtitleSelectionType.DISABLED - } else { - PersistedSubtitleSelectionType.INTERNAL - }, - subtitleLanguage = track?.language, - subtitleName = track?.label, - subtitleTrackId = track?.id, - addonSubtitleId = null, - addonSubtitleUrl = null, - addonSubtitleAddonName = null, - ) - } - } - - fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) { - updateTrackPreference { current -> - current.copy( - subtitleType = PersistedSubtitleSelectionType.ADDON, - subtitleLanguage = subtitle.language, - subtitleName = subtitle.display, - subtitleTrackId = null, - addonSubtitleId = subtitle.id, - addonSubtitleUrl = subtitle.url, - addonSubtitleAddonName = subtitle.addonName, - ) - } - } - - fun restorePersistedTrackPreferenceIfNeeded() { - if (trackPreferenceRestoreApplied) return - val preference = PlayerTrackPreferenceStorage.load(parentMetaId) - if (preference == null) { - trackPreferenceRestoreApplied = true - return - } - - if ( - audioTracks.isNotEmpty() && - (!preference.audioTrackId.isNullOrBlank() || - !preference.audioLanguage.isNullOrBlank() || - !preference.audioName.isNullOrBlank()) - ) { - val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference) - if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) { - playerController?.selectAudioTrack(restoredAudioIndex) - selectedAudioIndex = restoredAudioIndex - } - preferredAudioSelectionApplied = true - } - - when (preference.subtitleType) { - PersistedSubtitleSelectionType.DISABLED -> { - playerController?.selectSubtitleTrack(-1) - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } - PersistedSubtitleSelectionType.INTERNAL -> { - if (subtitleTracks.isNotEmpty()) { - val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference) - if (restoredSubtitleIndex >= 0) { - if (useCustomSubtitles) { - playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) - } else { - playerController?.selectSubtitleTrack(restoredSubtitleIndex) - } - selectedSubtitleIndex = restoredSubtitleIndex - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } - } - } - PersistedSubtitleSelectionType.ADDON -> { - val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } - if (url != null) { - selectedAddonSubtitleId = preference.addonSubtitleId ?: url - selectedSubtitleIndex = -1 - useCustomSubtitles = true - playerController?.setSubtitleUri(url) - preferredSubtitleSelectionApplied = true - } - } - } - - trackPreferenceRestoreApplied = true - } - - fun refreshTracks() { - val ctrl = playerController ?: return - audioTracks = ctrl.getAudioTracks() - subtitleTracks = ctrl.getSubtitleTracks() - val selectedAudio = audioTracks.firstOrNull { it.isSelected } - if (selectedAudio != null) selectedAudioIndex = selectedAudio.index - val selectedSub = subtitleTracks.firstOrNull { it.isSelected } - if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index - - restorePersistedTrackPreferenceIfNeeded() - - if (!preferredAudioSelectionApplied) { - val preferredAudioTargets = resolvePreferredAudioLanguageTargets( - preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ) - if (preferredAudioTargets.isEmpty()) { - preferredAudioSelectionApplied = true - } else if (audioTracks.isNotEmpty()) { - val preferredAudioIndex = findPreferredTrackIndex( - tracks = audioTracks, - targets = preferredAudioTargets, - language = { track -> track.language }, - ) - if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { - playerController?.selectAudioTrack(preferredAudioIndex) - selectedAudioIndex = preferredAudioIndex - } - preferredAudioSelectionApplied = true - } - } - - if (!preferredSubtitleSelectionApplied) { - val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - playerSettingsUiState.preferredSubtitleLanguage - }, - secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ) - - if (preferredSubtitleTargets.isEmpty()) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } else if (subtitleTracks.isNotEmpty()) { - val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( - tracks = subtitleTracks, - targets = preferredSubtitleTargets, - ) - if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { - playerController?.selectSubtitleTrack(preferredSubtitleIndex) - selectedSubtitleIndex = preferredSubtitleIndex - selectedAddonSubtitleId = null - useCustomSubtitles = false - } else if ( - preferredSubtitleIndex < 0 && - (subtitleStyle.useForcedSubtitles || - normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED) - ) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - } - preferredSubtitleSelectionApplied = true - } - } - - } - - fun showGestureFeedback(feedback: GestureFeedbackState) { - gestureMessageJob?.cancel() - gestureFeedback = feedback - gestureMessageJob = scope.launch { - delay(900) - gestureFeedback = null - } - } - - fun showGestureMessage(message: String) { - showGestureFeedback(GestureFeedbackState(message = message)) - } - - fun clearLiveGestureFeedback() { - liveGestureFeedback = null - } - - fun revealLockedOverlay() { - controlsVisible = false - lockedOverlayVisible = true - } - - fun lockPlayerControls() { - playerControlsLocked = true - controlsVisible = false - lockedOverlayVisible = false - pausedOverlayVisible = false - isScrubbingTimeline = false - scrubbingPositionMs = null - gestureMessageJob?.cancel() - gestureFeedback = null - liveGestureFeedback = null - renderedGestureFeedback = null - showAudioModal = false - showSubtitleModal = false - showVideoSettingsModal = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - } - - fun unlockPlayerControls() { - playerControlsLocked = false - lockedOverlayVisible = false - controlsVisible = true - } - - fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { - val seconds = amountMs / 1000L - if (seconds <= 0L) return - showGestureFeedback( - GestureFeedbackState( - messageRes = if (direction == PlayerSeekDirection.Forward) { - Res.string.compose_player_seek_feedback_forward - } else { - Res.string.compose_player_seek_feedback_backward - }, - messageArgs = listOf(seconds), - icon = if (direction == PlayerSeekDirection.Forward) { - GestureFeedbackIcon.SeekForward - } else { - GestureFeedbackIcon.SeekBackward - }, - ), - ) - } - - fun showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) { - val deltaMs = previewPositionMs - baselinePositionMs - val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward - liveGestureFeedback = GestureFeedbackState( - message = formatPlaybackTime(previewPositionMs), - icon = if (direction == PlayerSeekDirection.Forward) { - GestureFeedbackIcon.SeekForward - } else { - GestureFeedbackIcon.SeekBackward - }, - secondaryMessageRes = if (deltaMs >= 0L) { - Res.string.compose_player_seek_delta_forward - } else { - Res.string.compose_player_seek_delta_backward - }, - secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), - secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { - Color(0xFF6EE7A8) - } else { - Color(0xFFFF9A76) - }, - ) - } - - fun showBrightnessFeedback(level: Float) { - val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() - showGestureFeedback( - GestureFeedbackState( - messageRes = Res.string.compose_player_brightness_level, - messageArgs = listOf("$percentage%"), - icon = GestureFeedbackIcon.Brightness, - ), - ) - } - - fun showVolumeFeedback(level: PlayerAudioLevel) { - val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() - showGestureFeedback( - GestureFeedbackState( - messageRes = if (level.isMuted) { - Res.string.compose_player_muted - } else { - Res.string.compose_player_volume_level - }, - messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"), - icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, - isDanger = level.isMuted, - ), - ) - } - - fun togglePlayback() { - if (playbackSnapshot.isPlaying) { - shouldPlay = false - playerController?.pause() - } else { - if (playbackSnapshot.isEnded) { - playerController?.seekTo(0L) - } - shouldPlay = true - playerController?.play() - } - controlsVisible = true - } - - fun seekBy(offsetMs: Long) { - playerController?.seekBy(offsetMs) - scheduleProgressSyncAfterSeek() - controlsVisible = true - when { - offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) - offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) - } - } - - fun handleDoubleTapSeek(direction: PlayerSeekDirection) { - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - val nextState = if (accumulatedSeekState?.direction == direction) { - accumulatedSeekState!!.copy(amountMs = accumulatedSeekState!!.amountMs + PlayerDoubleTapSeekStepMs) - } else { - PlayerAccumulatedSeekState( - direction = direction, - baselinePositionMs = currentPositionMs, - amountMs = PlayerDoubleTapSeekStepMs, - ) - } - accumulatedSeekState = nextState - - val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L } - val targetPositionMs = when (direction) { - PlayerSeekDirection.Backward -> { - (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L) - } - - PlayerSeekDirection.Forward -> { - val unclamped = nextState.baselinePositionMs + nextState.amountMs - maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped - } - } - playerController?.seekTo(targetPositionMs) - scheduleProgressSyncAfterSeek() - showSeekFeedback(direction, nextState.amountMs) - - accumulatedSeekResetJob?.cancel() - accumulatedSeekResetJob = scope.launch { - delay(PlayerDoubleTapSeekResetDelayMs) - accumulatedSeekState = null - } - } - - fun cycleResizeMode() { - val nextMode = resizeMode.next() - resizeMode = nextMode - PlayerSettingsRepository.setResizeMode(nextMode) - showGestureMessage( - when (nextMode) { - PlayerResizeMode.Fit -> resizeModeFitLabel - PlayerResizeMode.Fill -> resizeModeFillLabel - PlayerResizeMode.Zoom -> resizeModeZoomLabel - }, - ) - controlsVisible = true - } - - fun cyclePlaybackSpeed() { - val speeds = listOf(1f, 1.25f, 1.5f, 2f) - val current = playbackSnapshot.playbackSpeed - val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() - playerController?.setPlaybackSpeed(next) - showGestureMessage(formatPlaybackSpeedLabel(next)) - controlsVisible = true - } - - fun activateHoldToSpeed() { - if (!playerSettingsUiState.holdToSpeedEnabled) return - val controller = playerController ?: return - if (speedBoostRestoreSpeed != null) return - - val targetSpeed = playerSettingsUiState.holdToSpeedValue - val currentSpeed = playbackSnapshot.playbackSpeed - if (abs(currentSpeed - targetSpeed) < 0.01f) return - - isHoldToSpeedGestureActive = true - speedBoostRestoreSpeed = currentSpeed - controller.setPlaybackSpeed(targetSpeed) - liveGestureFeedback = GestureFeedbackState( - message = formatPlaybackSpeedLabel(targetSpeed), - icon = GestureFeedbackIcon.Speed, - ) - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - - fun deactivateHoldToSpeed() { - isHoldToSpeedGestureActive = false - val restoreSpeed = speedBoostRestoreSpeed ?: return - playerController?.setPlaybackSpeed(restoreSpeed) - speedBoostRestoreSpeed = null - liveGestureFeedback = null - } - - val onSurfaceTap = rememberUpdatedState { offset: Offset -> - if (playerControlsLocked) { - revealLockedOverlay() - return@rememberUpdatedState - } - val centerStart = layoutSize.width * PlayerLeftGestureBoundary - val centerEnd = layoutSize.width * PlayerRightGestureBoundary - if (controlsVisible && offset.x in centerStart..centerEnd) { - controlsVisible = false - } else { - controlsVisible = !controlsVisible - } - } - val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> - if (playerControlsLocked) { - revealLockedOverlay() - return@rememberUpdatedState - } - when { - offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { - handleDoubleTapSeek(PlayerSeekDirection.Backward) - } - - offset.x > layoutSize.width * PlayerRightGestureBoundary -> { - handleDoubleTapSeek(PlayerSeekDirection.Forward) - } - - else -> controlsVisible = !controlsVisible - } - } - val activateHoldToSpeedState = rememberUpdatedState(::activateHoldToSpeed) - val deactivateHoldToSpeedState = rememberUpdatedState(::deactivateHoldToSpeed) - val showHorizontalSeekPreviewState = rememberUpdatedState(::showHorizontalSeekPreview) - val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) - val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) - val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) - val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay) - val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive) - val playerControlsLockedState = rememberUpdatedState(playerControlsLocked) - val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) - val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) - val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> - playerController?.seekTo(targetPositionMs) - scheduleProgressSyncAfterSeek() - } - - fun resolveDebridForPlayer( - stream: StreamItem, - season: Int?, - episode: Int?, - onResolved: (StreamItem) -> Unit, - onStale: () -> Unit, - ): Boolean { - if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false - scope.launch { - val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( - stream = stream, - season = season, - episode = episode, - ) - when (resolved) { - is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) - else -> { - resolved.toastMessage()?.let { NuvioToastController.show(it) } - if (resolved == DirectDebridPlayableResult.Stale) { - onStale() - } - } - } - } - return true - } - - fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = - "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" - - fun isP2pStream(stream: StreamItem): Boolean = - stream.needsLocalDebridResolve && stream.p2pInfoHash != null - - fun stopActiveP2pStream() { - if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { - P2pStreamingEngine.stopStream() - } - activeTorrentInfoHash = null - activeTorrentFileIdx = null - activeTorrentFilename = null - activeTorrentMagnetUri = null - activeTorrentTrackers = emptyList() - p2pResolvedSourceUrl = null - } - - fun saveP2pStreamForReuse( - stream: StreamItem, - videoId: String?, - season: Int?, - episode: Int?, - ) { - if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return - val infoHash = stream.p2pInfoHash ?: return - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = videoId, - parentMetaId = parentMetaId, - season = season, - episode = episode, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = "", - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = emptyMap(), - responseHeaders = emptyMap(), - filename = stream.p2pFilename, - videoSize = stream.behaviorHints.videoSize, - infoHash = infoHash, - fileIdx = stream.p2pFileIdx, - magnetUri = stream.torrentMagnetUri, - sources = stream.p2pSourceHints, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - - fun switchToP2pSourceStream(stream: StreamItem) { - val infoHash = stream.p2pInfoHash ?: return - if (!P2pSettingsRepository.isVisible) return - if (!P2pSettingsRepository.uiState.value.p2pEnabled) { - pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false) - return - } - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - flushWatchProgress() - stopActiveP2pStream() - saveP2pStreamForReuse( - stream = stream, - videoId = activeVideoId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.p2pFileIdx - activeTorrentFilename = stream.p2pFilename - activeTorrentMagnetUri = stream.torrentMagnetUri - activeTorrentTrackers = stream.p2pTrackers - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeInitialPositionMs = currentPositionMs - activeInitialProgressFraction = null - showSourcesPanel = false - controlsVisible = true - } - - fun switchToP2pEpisodeStream(stream: StreamItem, episode: MetaVideo, isAutoPlay: Boolean = false) { - val infoHash = stream.p2pInfoHash ?: return - if (!P2pSettingsRepository.isVisible) return - if (!P2pSettingsRepository.uiState.value.p2pEnabled) { - pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay) - return - } - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - val epVideoId = episode.id - val epResumeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = epVideoId, - ) - val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, - ) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - saveP2pStreamForReuse( - stream = stream, - videoId = epVideoId, - season = episode.season, - episode = episode.episode, - ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.p2pFileIdx - activeTorrentFilename = stream.p2pFilename - activeTorrentMagnetUri = stream.torrentMagnetUri - activeTorrentTrackers = stream.p2pTrackers - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = episode.id - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun switchToSource(stream: StreamItem) { - if ( - resolveDebridForPlayer( - stream = stream, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - onResolved = ::switchToSource, - onStale = { - val type = contentType ?: parentMetaType - val vid = activeVideoId - if (vid != null) { - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = true, - ) - } - }, - ) - ) return - if (isP2pStream(stream)) { - switchToP2pSourceStream(stream) - return - } - val url = stream.playableDirectUrl ?: return - if (url == activeSourceUrl) return - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - flushWatchProgress() - stopActiveP2pStream() - if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = activeVideoId!!, - parentMetaId = parentMetaId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = url, - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - activeSourceUrl = url - activeSourceAudioUrl = null - activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) - activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeInitialPositionMs = currentPositionMs - activeInitialProgressFraction = null - showSourcesPanel = false - controlsVisible = true - } - - fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { - if ( - resolveDebridForPlayer( - stream = stream, - season = episode.season, - episode = episode.episode, - onResolved = { resolvedStream -> - switchToEpisodeStream(resolvedStream, episode) - }, - onStale = { - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = true, - ) - }, - ) - ) return - if (isP2pStream(stream)) { - switchToP2pEpisodeStream(stream, episode) - return - } - val url = stream.playableDirectUrl ?: return - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - val epVideoId = episode.id - val epResumeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = epVideoId, - ) - val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, - ) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - if (playerSettingsUiState.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = epVideoId, - parentMetaId = parentMetaId, - season = episode.season, - episode = episode.episode, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = url, - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - activeSourceUrl = url - activeSourceAudioUrl = null - activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) - activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = episode.id - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - - val fallbackVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) - val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - - activeSourceUrl = localFileUri - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeStreamTitle = downloadItem.streamTitle.ifBlank { - episode.title.ifBlank { title } - } - activeStreamSubtitle = downloadItem.streamSubtitle - activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } - activeProviderAddonId = downloadItem.providerAddonId - currentStreamBingeGroup = null - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = resolvedVideoId - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun playNextEpisode() { - val nextVideoId = nextEpisodeInfo?.videoId ?: return - val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return - if (nextEpisodeInfo?.hasAired != true) return - - val downloadedNextEpisode = DownloadsRepository.findPlayableDownload( - parentMetaId = parentMetaId, - seasonNumber = nextVideo.season, - episodeNumber = nextVideo.episode, - videoId = nextVideo.id, - ) - if (downloadedNextEpisode != null) { - switchToDownloadedEpisode(downloadedNextEpisode, nextVideo) - return - } - - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = true - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - - val type = contentType ?: parentMetaType - val settings = playerSettingsUiState - val shouldAutoSelectInManualMode = - settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && - ( - settings.streamAutoPlayNextEpisodeEnabled || - settings.streamAutoPlayPreferBingeGroup - ) - - // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle) - val bingeGroupOnlyManualMode = - shouldAutoSelectInManualMode && - !settings.streamAutoPlayNextEpisodeEnabled && - settings.streamAutoPlayPreferBingeGroup - - // Determine auto-play mode for next episode - val effectiveMode = if (shouldAutoSelectInManualMode) { - StreamAutoPlayMode.FIRST_STREAM - } else { - settings.streamAutoPlayMode - } - val effectiveSource = if (shouldAutoSelectInManualMode) { - StreamAutoPlaySource.ALL_SOURCES - } else { - settings.streamAutoPlaySource - } - val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) { - emptySet() - } else { - settings.streamAutoPlaySelectedAddons - } - val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) { - emptySet() - } else { - settings.streamAutoPlaySelectedPlugins - } - val effectiveRegex = if (shouldAutoSelectInManualMode) { - "" - } else { - settings.streamAutoPlayRegex - } - - // Determine preferred binge group from current stream (not cache) - val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - currentStreamBingeGroup - } else { - null - } - - nextEpisodeAutoPlayJob = scope.launch { - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = nextVideo.id, - season = nextVideo.season, - episode = nextVideo.episode, - ) - - val installedAddonNames = AddonRepository.uiState.value.addons - .enabledAddons() - .map { it.displayTitle } - .toSet() - val debridSettings = DebridSettingsRepository.snapshot() - - val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds - var autoSelectTriggered = false - var timeoutElapsed = false - var selectedStream: StreamItem? = null - val autoSelectSettled = CompletableDeferred() - - fun settleAutoSelect() { - if (!autoSelectSettled.isCompleted) { - autoSelectSettled.complete(Unit) - } - } - - fun selectStream(stream: StreamItem) { - autoSelectTriggered = true - selectedStream = stream - settleAutoSelect() - } - - fun finishWithoutSelection() { - autoSelectTriggered = true - settleAutoSelect() - } - - // Full select: tries binge group first, then falls back to mode-based selection - fun trySelectStream(streams: List): StreamItem? { - return StreamAutoPlaySelector.selectAutoPlayStream( - streams = streams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = preferredBingeGroup, - preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - bingeGroupOnly = bingeGroupOnlyManualMode, - debridEnabled = debridSettings.canResolvePlayableLinks, - activeResolverProviderId = debridSettings.activeResolverProviderId, - ) - } - - // Binge group only early match: returns null if no binge group match - fun tryBingeGroupOnly(streams: List): StreamItem? { - if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null - return StreamAutoPlaySelector.selectAutoPlayStream( - streams = streams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = preferredBingeGroup, - preferBingeGroupInSelection = true, - bingeGroupOnly = true, - debridEnabled = debridSettings.canResolvePlayableLinks, - activeResolverProviderId = debridSettings.activeResolverProviderId, - ) - } - - val innerJob = launch { - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - - val allStreams = state.groups.flatMap { it.streams } - - if (autoSelectTriggered) { - // Already resolved - } else if (timeoutElapsed) { - // Timeout elapsed: full select (binge group + fallback to mode) - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - } else { - // Before timeout: eagerly check binge group only - if (allStreams.isNotEmpty()) { - val earlyMatch = tryBingeGroupOnly(allStreams) - if (earlyMatch != null) { - selectStream(earlyMatch) - } - } - } - - // If all addons finished loading and no match yet, do a final full select - if (!autoSelectTriggered && !state.isAnyLoading) { - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - if (!autoSelectTriggered) { - finishWithoutSelection() - } - return@collectLatest - } - - if (autoSelectTriggered) return@collectLatest - } - } - - // Timeout logic - val timeoutMs = timeoutSeconds * 1_000L - val isBoundedTimeout = timeoutSeconds in 1..30 - - if (isBoundedTimeout) { - // Bounded timeout (1-30s): wait, then trigger full select - delay(timeoutMs) - timeoutElapsed = true - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - } - if (selectedStream != null) { - innerJob.cancel() - } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { - // Streams arrived but no match after full select — don't wait further - innerJob.cancel() - finishWithoutSelection() - } else { - // No addon responded yet — wait with hard ceiling - val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } - innerJob.cancel() - if (completed == null) { - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - finishWithoutSelection() - } - } - } - } else { - // Instant (0) or unlimited: timeoutElapsed immediately so each - // addon response triggers a full select attempt in the collect. - timeoutElapsed = true - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - trySelectStream(allStreams)?.let(::selectStream) - } - } - val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS - val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() } - innerJob.cancel() - if (completed == null) { - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - finishWithoutSelection() - } - } - } - - // Handle result - nextEpisodeAutoPlaySearching = false - if (selectedStream != null) { - nextEpisodeAutoPlaySourceName = selectedStream!!.addonName - // Countdown before playing - for (i in 3 downTo 1) { - nextEpisodeAutoPlayCountdown = i - delay(1000) - } - switchToEpisodeStream(selectedStream!!, nextVideo) - showNextEpisodeCard = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } else { - // No stream found — open the episode streams panel for manual selection - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = nextVideo, - ) - showEpisodesPanel = true - showNextEpisodeCard = false - } - } - } - - fun openSourcesPanel() { - val type = contentType ?: parentMetaType - val vid = activeVideoId ?: return - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - showSourcesPanel = true - showEpisodesPanel = false - controlsVisible = false - } - - fun openEpisodesPanel() { - // Ensure meta is loaded for episodes - if (allEpisodes.isEmpty()) { - scope.launch { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } - showEpisodesPanel = true - showSourcesPanel = false - controlsVisible = false - } - - fun fetchAddonSubtitlesForActiveItem() { - val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return - val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return - SubtitleRepository.fetchAddonSubtitles(type, videoId) - } - - fun setSubtitleDelay(delayMs: Int) { - val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) - subtitleDelayMs = clamped - PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped) - playerController?.setSubtitleDelayMs(clamped) - } - - fun loadSubtitleAutoSyncCues(force: Boolean = false) { - val subtitle = selectedAddonSubtitle ?: return - if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return - subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null) - scope.launch { - val result = runCatching { - val body = httpGetTextWithHeaders( - url = subtitle.url, - headers = sanitizePlaybackHeaders(activeSourceHeaders), - ) - PlayerSubtitleCueParser.parse(body, subtitle.url) - } - result.fold( - onSuccess = { cues -> - subtitleAutoSyncState = subtitleAutoSyncState.copy( - cues = cues, - isLoading = false, - errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null, - ) - }, - onFailure = { error -> - subtitleAutoSyncState = subtitleAutoSyncState.copy( - isLoading = false, - errorMessage = error.message ?: "Unable to load subtitle lines", - ) - }, - ) - } - } - - fun captureSubtitleAutoSyncTime() { - subtitleAutoSyncState = subtitleAutoSyncState.copy( - capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L), - errorMessage = null, - ) - loadSubtitleAutoSyncCues() - } - - fun applySubtitleAutoSyncCue(cue: SubtitleSyncCue) { - val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return - val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS) - .toInt() - .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) - setSubtitleDelay(newDelayMs) - } - - LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { - errorMessage = null - playerController = null - playerControllerSourceUrl = null - playbackSnapshot = PlayerPlaybackSnapshot() - isScrubbingTimeline = false - scrubbingPositionMs = null - liveGestureFeedback = null - renderedGestureFeedback = null - lockedOverlayVisible = false - initialLoadCompleted = false - lastProgressPersistEpochMs = 0L - previousIsPlaying = false - pendingScrobbleStartAfterSeek = false - seekProgressSyncJob?.cancel() - seekProgressSyncJob = null - accumulatedSeekResetJob?.cancel() - accumulatedSeekResetJob = null - accumulatedSeekState = null - speedBoostRestoreSpeed = null - preferredAudioSelectionApplied = false - preferredSubtitleSelectionApplied = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - SubtitleRepository.clear() - WatchProgressRepository.ensureLoaded() - } - - LaunchedEffect( - activeTorrentInfoHash, - activeTorrentFileIdx, - activeTorrentFilename, - activeTorrentMagnetUri, - activeTorrentTrackers, - p2pSettingsUiState.p2pEnabled, - ) { - val infoHash = activeTorrentInfoHash - if (infoHash == null) { - p2pResolvedSourceUrl = null - P2pStreamingEngine.stopStream() - return@LaunchedEffect - } - if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) { - return@LaunchedEffect - } - - p2pResolvedSourceUrl = null - val requestedFileIdx = activeTorrentFileIdx - val requestedFilename = activeTorrentFilename - val requestedMagnetUri = activeTorrentMagnetUri - val requestedTrackers = activeTorrentTrackers - errorMessage = null - playerController = null - playerControllerSourceUrl = null - playbackSnapshot = PlayerPlaybackSnapshot() - initialLoadCompleted = false - - try { - val localUrl = P2pStreamingEngine.startStream( - P2pStreamRequest( - infoHash = infoHash, - fileIdx = requestedFileIdx, - filename = requestedFilename, - magnetUri = requestedMagnetUri, - trackers = requestedTrackers, - ), - ) - if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) { - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - p2pResolvedSourceUrl = localUrl - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - errorMessage = getString( - Res.string.player_error_failed_start_torrent, - error.message ?: genericUnknownLabel, - ) - controlsVisible = !playerControlsLocked - initialLoadCompleted = true - } - } - - LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) { - val state = p2pStreamingState - if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) { - errorMessage = getString(Res.string.player_error_torrent, state.message) - controlsVisible = !playerControlsLocked - } - } - - LaunchedEffect(playbackSession.videoId) { - subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0 - subtitleAutoSyncState = SubtitleAutoSyncUiState() - } - - LaunchedEffect(playerController, subtitleDelayMs) { - playerController?.setSubtitleDelayMs(subtitleDelayMs) - } - - LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) { - subtitleAutoSyncState = SubtitleAutoSyncUiState() - } - - LaunchedEffect(playerController, subtitleStyle) { - playerController?.applySubtitleStyle(subtitleStyle) - } - - LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { - val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect - if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { - return@LaunchedEffect - } - if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect - autoFetchedAddonSubtitlesForKey = fetchKey - fetchAddonSubtitlesForActiveItem() - } - - LaunchedEffect(playbackSnapshot.isLoading, playerController) { - if (!playbackSnapshot.isLoading && playerController != null) { - refreshTracks() - } - } - - LaunchedEffect( - playerController, - playbackSnapshot.isLoading, - preferredAudioSelectionApplied, - preferredSubtitleSelectionApplied, - ) { - if (playerController == null || playbackSnapshot.isLoading) { - return@LaunchedEffect - } - if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { - return@LaunchedEffect - } - - repeat(10) { - refreshTracks() - if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { - return@LaunchedEffect - } - delay(300) - } - } - - LaunchedEffect( - playerController, - playerControllerSourceUrl, - playbackSnapshot.isLoading, - playbackSnapshot.durationMs, - activeInitialPositionMs, - activeInitialProgressFraction, - initialSeekApplied, - ) { - val controller = playerController ?: return@LaunchedEffect - if (playerControllerSourceUrl != activeSourceUrl) { - return@LaunchedEffect - } - if (initialSeekApplied || playbackSnapshot.isLoading) { - return@LaunchedEffect - } - - val progressFraction = activeInitialProgressFraction - ?.takeIf { it > 0f } - ?.coerceIn(0f, 1f) - val targetPositionMs = when { - activeInitialPositionMs > 0L -> activeInitialPositionMs - progressFraction != null && playbackSnapshot.durationMs > 0L -> { - (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong() - } - progressFraction != null -> return@LaunchedEffect - else -> 0L - } - if (targetPositionMs <= 0L) { - initialSeekApplied = true - return@LaunchedEffect - } - - controller.seekTo(targetPositionMs) - initialSeekApplied = true - } - - LaunchedEffect( - controlsVisible, - isScrubbingTimeline, - playbackSnapshot.isPlaying, - playbackSnapshot.isLoading, - showParentalGuide, - errorMessage, - ) { - if ( - !controlsVisible || - isScrubbingTimeline || - !playbackSnapshot.isPlaying || - playbackSnapshot.isLoading || - showParentalGuide || - errorMessage != null - ) { - return@LaunchedEffect - } - delay(3500) - controlsVisible = false - } - - LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { - if (!playerControlsLocked || !lockedOverlayVisible) { - return@LaunchedEffect - } - delay(PlayerLockedOverlayDurationMs) - lockedOverlayVisible = false - } - - LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { - pausedOverlayVisible = false - if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { - return@LaunchedEffect - } - delay(5000) - pausedOverlayVisible = true - } - - LaunchedEffect( - playbackSnapshot.positionMs, - playbackSnapshot.isPlaying, - playbackSnapshot.isLoading, - playbackSnapshot.isEnded, - playbackSnapshot.durationMs, - ) { - if (playbackSnapshot.isEnded) { - flushWatchProgress() - previousIsPlaying = false - pendingScrobbleStartAfterSeek = false - return@LaunchedEffect - } - - if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { - pendingScrobbleStartAfterSeek = false - flushWatchProgress() - } - - if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { - emitTraktScrobbleStart() - } - - if (!playbackSnapshot.isLoading) { - previousIsPlaying = playbackSnapshot.isPlaying - } - - if (!playbackSnapshot.isPlaying) { - return@LaunchedEffect - } - - val now = WatchProgressClock.nowEpochMs() - if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) { - return@LaunchedEffect - } - lastProgressPersistEpochMs = now - WatchProgressRepository.upsertPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - syncRemote = false, - ) - } - - // Fetch parental guide when the playable item changes. - LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) { - parentalWarnings = emptyList() - showParentalGuide = false - parentalGuideHasShown = false - playbackStartedForParentalGuide = false - - val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect - val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect - parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels) - - if (playbackSnapshot.isPlaying) { - tryShowParentalGuide() - } - } - - LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) { - if (playbackSnapshot.isPlaying) { - tryShowParentalGuide() - } - } - - // Fetch skip intervals when episode changes - LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) { - skipIntervals = emptyList() - activeSkipInterval = null - skipIntervalDismissed = false - showNextEpisodeCard = false - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val vid = activeVideoId - - if (season == null || episode == null || vid == null) return@LaunchedEffect - - launch { - val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") } - val intervals = SkipIntroRepository.getSkipIntervals( - imdbId = imdbId, - season = season, - episode = episode, - ) - skipIntervals = intervals - } - } - - // Update active skip interval based on playback position - LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) { - if (skipIntervals.isEmpty()) { - activeSkipInterval = null - return@LaunchedEffect - } - val positionSec = playbackSnapshot.positionMs / 1000.0 - val current = skipIntervals.firstOrNull { interval -> - positionSec >= interval.startTime && positionSec < interval.endTime - } - if (current != activeSkipInterval) { - activeSkipInterval = current - if (current != null) skipIntervalDismissed = false - } - } - - // Resolve next episode info when episodes list or current episode changes - LaunchedEffect(allEpisodes, activeSeasonNumber, activeEpisodeNumber) { - if (!isSeries || allEpisodes.isEmpty()) { - nextEpisodeInfo = null - return@LaunchedEffect - } - val curSeason = activeSeasonNumber ?: return@LaunchedEffect - val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect - val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode( - videos = allEpisodes, - currentSeason = curSeason, - currentEpisode = curEpisode, - ) - nextEpisodeInfo = if (nextVideo != null && nextVideo.season != null && nextVideo.episode != null) { - NextEpisodeInfo( - videoId = nextVideo.id, - season = nextVideo.season!!, - episode = nextVideo.episode!!, - title = nextVideo.title, - thumbnail = nextVideo.thumbnail, - overview = nextVideo.overview, - released = nextVideo.released, - hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), - unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { - "$airsPrefix ${nextVideo.released ?: tbaLabel}" - } else null, - ) - } else null - } - - // Show next episode card at threshold - LaunchedEffect( - playbackSnapshot.positionMs, - playbackSnapshot.durationMs, - nextEpisodeInfo, - skipIntervals, - playerSettingsUiState.nextEpisodeThresholdMode, - playerSettingsUiState.nextEpisodeThresholdPercent, - playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, - ) { - if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) { - showNextEpisodeCard = false - return@LaunchedEffect - } - val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard( - positionMs = playbackSnapshot.positionMs, - durationMs = playbackSnapshot.durationMs, - skipIntervals = skipIntervals, - thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode, - thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent, - thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, - ) - if (shouldShow && !showNextEpisodeCard) { - showNextEpisodeCard = true - // Auto-play if enabled - if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { - playNextEpisode() - } - } else if (!shouldShow) { - showNextEpisodeCard = false - } - } - - // Auto-play on video ended if next episode card isn't already showing - LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) { - if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) { - showNextEpisodeCard = true - if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { - playNextEpisode() - } - } - } - - DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) { - onDispose { - flushWatchProgress() - } - } - - DisposableEffect(Unit) { - onDispose { - P2pStreamingEngine.shutdown() - PlayerStreamsRepository.clearAll() - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .onSizeChanged { layoutSize = it } - .pointerInput(layoutSize) { - detectTapGestures( - onPress = { - tryAwaitRelease() - deactivateHoldToSpeedState.value() - }, - onTap = { offset -> onSurfaceTap.value(offset) }, - onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, - onLongPress = { - if (playerControlsLockedState.value) { - revealLockedOverlayState.value() - } else { - activateHoldToSpeedState.value() - } - }, - ) - } - .pointerInput(gestureController, layoutSize) { - awaitEachGesture { - val down = awaitFirstDown() - if (playerControlsLockedState.value) { - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - change.consume() - } - return@awaitEachGesture - } - val controller = gestureController - val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture - val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture - val region = when { - down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness - down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume - else -> null - } - - val initialBrightness = if (region == PlayerSideGesture.Brightness) { - controller?.currentBrightness() - } else { - null - } - val initialVolume = if (region == PlayerSideGesture.Volume) { - controller?.currentVolume() - } else { - null - } - - var totalDx = 0f - var totalDy = 0f - var gestureMode: PlayerGestureMode? = null - val horizontalSeekBaselineMs = currentPositionMsState.value - var horizontalSeekPreviewMs = horizontalSeekBaselineMs - - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - - val delta = change.position - change.previousPosition - totalDx += delta.x - totalDy += delta.y - - if (gestureMode == null) { - val holdToSpeedActive = isHoldToSpeedGestureActiveState.value - val horizontalDominant = - !holdToSpeedActive && - abs(totalDx) > viewConfiguration.touchSlop && - abs(totalDx) > abs(totalDy) - val verticalDominant = - !holdToSpeedActive && - abs(totalDy) > viewConfiguration.touchSlop && - abs(totalDy) > abs(totalDx) - - gestureMode = when { - horizontalDominant -> { - deactivateHoldToSpeedState.value() - PlayerGestureMode.HorizontalSeek - } - - verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> { - PlayerGestureMode.Brightness - } - - verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> { - PlayerGestureMode.Volume - } - - else -> null - } - - if (gestureMode == null) { - continue - } - } - - when (gestureMode) { - PlayerGestureMode.HorizontalSeek -> { - val sensitivitySeconds = when { - currentDurationMsState.value >= 3_600_000L -> 120f - currentDurationMsState.value >= 1_800_000L -> 90f - else -> 60f - } - val previewOffsetMs = - ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong() - val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs - horizontalSeekPreviewMs = currentDurationMsState.value - .takeIf { it > 0L } - ?.let { durationMs -> - unclampedPreviewMs.coerceIn(0L, durationMs) - } - ?: unclampedPreviewMs.coerceAtLeast(0L) - showHorizontalSeekPreviewState.value( - horizontalSeekPreviewMs, - horizontalSeekBaselineMs, - ) - } - - PlayerGestureMode.Brightness -> { - val gestureDeltaFraction = - (-totalDy / height) * PlayerVerticalGestureSensitivity - controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction) - ?.let(showBrightnessFeedbackState.value) - } - - PlayerGestureMode.Volume -> { - val gestureDeltaFraction = - (-totalDy / height) * PlayerVerticalGestureSensitivity - controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) - ?.let(showVolumeFeedbackState.value) - } - - null -> Unit - } - change.consume() - } - - if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) { - commitHorizontalSeekState.value(horizontalSeekPreviewMs) - clearLiveGestureFeedbackState.value() - } - } - }, - ) { - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - if (playerSurfaceSourceUrl != null) { - PlatformPlayerSurface( - sourceUrl = playerSurfaceSourceUrl, - sourceAudioUrl = activeSourceAudioUrl, - sourceHeaders = activeSourceHeaders, - sourceResponseHeaders = activeSourceResponseHeaders, - modifier = Modifier.fillMaxSize(), - playWhenReady = shouldPlay, - resizeMode = resizeMode, - onControllerReady = { controller -> - playerController = controller - playerControllerSourceUrl = activeSourceUrl - }, - onSnapshot = { snapshot -> - playbackSnapshot = snapshot - if (!snapshot.isLoading) { - initialLoadCompleted = true - } - if (snapshot.isEnded) { - shouldPlay = false - controlsVisible = !playerControlsLocked - } - }, - onError = { message -> - errorMessage = message - if (message != null) { - controlsVisible = !playerControlsLocked - val currentVideoId = activeVideoId - if (currentVideoId != null) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = currentVideoId, - parentMetaId = parentMetaId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - StreamLinkCacheRepository.remove(cacheKey) - } - } - }, - ) - } - - AnimatedVisibility( - visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked, - enter = fadeIn(animationSpec = tween(durationMillis = 220)), - exit = fadeOut(animationSpec = tween(durationMillis = 180)), - ) { - PauseMetadataOverlay( - title = title, - logo = logo, - isEpisode = isEpisode, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - pauseDescription = pauseDescription ?: activeStreamSubtitle, - providerName = activeProviderName, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, - enter = fadeIn(), - exit = fadeOut(), - ) { - PlayerControlsShell( - title = title, - streamTitle = activeStreamTitle, - providerName = activeProviderName, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - resizeMode = resizeMode, - isLocked = playerControlsLocked, - showPlaybackControls = controlsVisible, - onLockToggle = { - if (playerControlsLocked) { - unlockPlayerControls() - } else { - lockPlayerControls() - } - }, - onBack = onBackWithProgress, - onTogglePlayback = ::togglePlayback, - onSeekBack = { seekBy(-10_000L) }, - onSeekForward = { seekBy(10_000L) }, - onResizeModeClick = ::cycleResizeMode, - onSpeedClick = ::cyclePlaybackSpeed, - onSubtitleClick = { - refreshTracks() - showSubtitleModal = true - }, - onAudioClick = { - refreshTracks() - showAudioModal = true - }, - onVideoSettingsClick = if (isIos) { - { - showVideoSettingsModal = true - controlsVisible = true - } - } else { - null - }, - onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, - onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, - onOpenInExternalPlayer = if (onOpenInExternalPlayer != null) { - { - val currentPositionMs = playbackSnapshot.positionMs - val loadedSubtitles = addonSubtitles - .takeIf { it.isNotEmpty() } - ?.map { sub -> - SubtitleInput( - url = sub.url, - name = buildString { - if (!sub.addonName.isNullOrBlank()) { - append("[${sub.addonName}] ") - } - append(sub.display) - }, - lang = sub.language, - ) - } - val request = ExternalPlayerPlaybackRequest( - sourceUrl = activeSourceUrl, - title = title, - streamTitle = activeStreamTitle, - sourceHeaders = activeSourceHeaders, - resumePositionMs = currentPositionMs, - subtitles = loadedSubtitles, - ) - onOpenInExternalPlayer(request) - } - } else null, - onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, - parentalWarnings = parentalWarnings, - showParentalGuide = showParentalGuide, - onParentalGuideAnimationComplete = { showParentalGuide = false }, - onScrubChange = { positionMs -> - isScrubbingTimeline = true - scrubbingPositionMs = positionMs - }, - onScrubFinished = { positionMs -> - isScrubbingTimeline = false - scrubbingPositionMs = null - playerController?.seekTo(positionMs) - scheduleProgressSyncAfterSeek() - }, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = playerControlsLocked && lockedOverlayVisible, - enter = fadeIn(), - exit = fadeOut(), - ) { - LockedPlayerOverlay( - playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - onUnlock = ::unlockPlayerControls, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, - enter = fadeIn(), - exit = fadeOut(), - ) { - OpeningOverlay( - artwork = backdropArtwork, - logo = logo, - title = title, - onBack = onBackWithProgress, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - message = p2pInitialLoadingMessage, - progress = p2pInitialLoadingProgress, - ) - } - - P2pLoadingStatus( - visible = showP2pRebufferStats && errorMessage == null, - message = p2pRebufferMessage, - progress = p2pRebufferProgress, - modifier = Modifier - .align(Alignment.Center) - .padding(top = 58.dp), - ) - - AnimatedVisibility( - visible = currentGestureFeedback != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - renderedGestureFeedback?.let { feedback -> - GestureFeedbackPill( - feedback = feedback, - modifier = Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) - .padding(horizontal = horizontalSafePadding) - .padding(top = 40.dp), - ) - } - } - } - - // Skip intro/recap/outro button - if (!playerControlsLocked) { - SkipIntroButton( - interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, - dismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkip = { - val interval = activeSkipInterval ?: return@SkipIntroButton - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - }, - onDismiss = { skipIntervalDismissed = true }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), - ) - } - - // Next episode card - if (isSeries && !playerControlsLocked) { - NextEpisodeCard( - nextEpisode = nextEpisodeInfo, - visible = showNextEpisodeCard, - isAutoPlaySearching = nextEpisodeAutoPlaySearching, - autoPlaySourceName = nextEpisodeAutoPlaySourceName, - autoPlayCountdownSec = nextEpisodeAutoPlayCountdown, - onPlayNext = { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - }, - onDismiss = { - nextEpisodeAutoPlayJob?.cancel() - showNextEpisodeCard = false - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = sliderEdgePadding, bottom = overlayBottomPadding), - ) - } - - if (errorMessage != null) { - ErrorModal( - message = errorMessage.orEmpty(), - onDismiss = onBackWithProgress, - ) - } - - if (pendingP2pSwitch != null) { - P2pConsentDialog( - onEnableP2p = { - val pending = pendingP2pSwitch ?: return@P2pConsentDialog - pendingP2pSwitch = null - P2pSettingsRepository.setP2pEnabled(true) - val episode = pending.episode - if (episode != null) { - switchToP2pEpisodeStream( - stream = pending.stream, - episode = episode, - isAutoPlay = pending.isAutoPlay, - ) - } else { - switchToP2pSourceStream(pending.stream) - } - }, - onDismiss = { - val pending = pendingP2pSwitch - if (pending?.isAutoPlay == true) { - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } - pendingP2pSwitch = null - }, - ) - } - - AudioTrackModal( - visible = showAudioModal, - audioTracks = audioTracks, - selectedIndex = selectedAudioIndex, - onTrackSelected = { index -> - selectedAudioIndex = index - persistAudioPreference(audioTracks.firstOrNull { it.index == index }) - playerController?.selectAudioTrack(index) - scope.launch { - delay(200) - showAudioModal = false - } - }, - onDismiss = { showAudioModal = false }, - ) - - SubtitleModal( - visible = showSubtitleModal, - activeTab = activeSubtitleTab, - subtitleTracks = subtitleTracks, - selectedSubtitleIndex = selectedSubtitleIndex, - addonSubtitles = visibleAddonSubtitles, - selectedAddonSubtitleId = selectedAddonSubtitleId, - isLoadingAddonSubtitles = isLoadingAddonSubtitles, - subtitleStyle = subtitleStyle, - subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, - subtitleAutoSyncState = subtitleAutoSyncState, - onTabSelected = { activeSubtitleTab = it }, - onBuiltInTrackSelected = { index -> - val wasCustom = useCustomSubtitles - selectedSubtitleIndex = index - selectedAddonSubtitleId = null - useCustomSubtitles = false - persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) - if (wasCustom) { - playerController?.clearExternalSubtitleAndSelect(index) - } else { - playerController?.selectSubtitleTrack(index) - } - }, - onAddonSubtitleSelected = { addon -> - selectedAddonSubtitleId = addon.id - selectedSubtitleIndex = -1 - useCustomSubtitles = true - persistAddonSubtitlePreference(addon) - playerController?.setSubtitleUri(addon.url) - }, - onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem, - onStyleChanged = PlayerSettingsRepository::setSubtitleStyle, - onSubtitleDelayChanged = ::setSubtitleDelay, - onSubtitleDelayReset = { setSubtitleDelay(0) }, - onAutoSyncCapture = ::captureSubtitleAutoSyncTime, - onAutoSyncCueSelected = ::applySubtitleAutoSyncCue, - onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) }, - onDismiss = { showSubtitleModal = false }, - ) - - IosVideoSettingsModal( - visible = showVideoSettingsModal, - settings = playerSettingsUiState, - onSettingsChanged = { - playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) - }, - onDismiss = { showVideoSettingsModal = false }, - ) - - // Sources Panel - PlayerSourcesPanel( - visible = showSourcesPanel, - streamsUiState = sourceStreamsState, - currentStreamUrl = activeSourceUrl, - currentStreamName = activeStreamTitle, - onFilterSelected = { PlayerStreamsRepository.selectSourceFilter(it) }, - onStreamSelected = ::switchToSource, - onReload = { - val type = contentType ?: parentMetaType - val vid = activeVideoId ?: return@PlayerSourcesPanel - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = true, - ) - }, - onDismiss = { - showSourcesPanel = false - controlsVisible = true - }, - ) - - // Episodes Panel - if (isSeries) { - PlayerEpisodesPanel( - visible = showEpisodesPanel, - episodes = allEpisodes, - parentMetaType = parentMetaType, - parentMetaId = parentMetaId, - currentSeason = activeSeasonNumber, - currentEpisode = activeEpisodeNumber, - progressByVideoId = watchProgressUiState.byVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - episodeStreamsState = episodeStreamsPanelState.copy( - streamsUiState = episodeStreamsRepoState, - ), - onSeasonSelected = { /* season tab change handled internally */ }, - onEpisodeSelected = { episode -> - val downloadedEpisode = DownloadsRepository.findPlayableDownload( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - videoId = episode.id, - ) - if (downloadedEpisode != null) { - switchToDownloadedEpisode(downloadedEpisode, episode) - return@PlayerEpisodesPanel - } - - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - ) - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = episode, - ) - }, - onEpisodeStreamFilterSelected = { - PlayerStreamsRepository.selectEpisodeStreamsFilter(it) - }, - onEpisodeStreamSelected = ::switchToEpisodeStream, - onBackToEpisodes = { - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - }, - onReloadEpisodeStreams = { - val episode = episodeStreamsPanelState.selectedEpisode ?: return@PlayerEpisodesPanel - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = true, - ) - }, - onDismiss = { - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - controlsVisible = true - }, - ) - } - - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } - ?: parentMetaId.takeIf { it.startsWith("tt") } - ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } - - if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) { - com.nuvio.app.features.player.skip.SubmitIntroDialog( - imdbId = imdbId, - season = season, - episode = episode, - currentTimeSec = (displayedPositionMs / 1000.0), - segmentType = submitIntroSegmentType, - onSegmentTypeChange = { submitIntroSegmentType = it }, - startTimeStr = submitIntroStartTimeStr, - onStartTimeChange = { submitIntroStartTimeStr = it }, - endTimeStr = submitIntroEndTimeStr, - onEndTimeChange = { submitIntroEndTimeStr = it }, - onDismiss = { showSubmitIntroModal = false }, - onSuccess = { - submitIntroStartTimeStr = "00:00" - submitIntroEndTimeStr = "00:00" - submitIntroSegmentType = "intro" - showSubmitIntroModal = false - } - ) - } - } - } -} - -private fun buildAddonSubtitleFetchKey( - addons: List, - type: String?, - videoId: String?, -): String? { - val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null - val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null - val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon -> - val manifest = addon.manifest ?: return@mapNotNull null - val supportsSubtitles = manifest.resources.any { resource -> - resource.isCompatibleSubtitleResource( - type = normalizedType, - videoId = normalizedVideoId, - ) - } - if (!supportsSubtitles) return@mapNotNull null - "${manifest.id}:${manifest.transportUrl}" - } - - if (compatibleSubtitleAddons.isEmpty()) return null - return buildString { - append(normalizedType) - append('|') - append(normalizedVideoId) - append('|') - append(compatibleSubtitleAddons.sorted().joinToString("|")) - } -} - -private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean { - val isSubtitleResource = name.equals("subtitles", ignoreCase = true) || - name.equals("subtitle", ignoreCase = true) - if (!isSubtitleResource) return false - - val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type - val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) } - if (!typeMatches) return false - - return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } -} - -private fun findPreferredTrackIndex( - tracks: List, - targets: List, - language: (T) -> String?, -): Int { - if (targets.isEmpty()) return -1 - for (target in targets) { - val matchIndex = tracks.indexOfFirst { track -> - languageMatchesPreference( - trackLanguage = language(track), - targetLanguage = target, - ) - } - if (matchIndex >= 0) { - return matchIndex - } - } - return -1 -} - -private fun findPreferredSubtitleTrackIndex( - tracks: List, - targets: List, -): Int { - if (targets.isEmpty()) return -1 - - for ((targetPosition, target) in targets.withIndex()) { - val normalizedTarget = normalizeLanguageCode(target) ?: continue - if (normalizedTarget == SubtitleLanguageOption.FORCED) { - val forcedIndex = tracks.indexOfFirst { it.isForced } - if (forcedIndex >= 0) return forcedIndex - if (targetPosition == 0) return -1 - continue - } - - val matchIndex = tracks.indexOfFirst { track -> - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) - } - if (matchIndex >= 0) return matchIndex - } - - return -1 -} - -private fun filterAddonSubtitlesForSettings( - subtitles: List, - settings: PlayerSettingsUiState, - selectedAddonSubtitleId: String?, -): List { - val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages || - settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY - if (!shouldFilter) return subtitles - - val targets = preferredSubtitleTargetsForSettings(settings) - if (targets.isEmpty()) { - return subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId - } - } - - val filtered = subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || - subtitle.url == selectedAddonSubtitleId || - targets.any { target -> - languageMatchesPreference( - trackLanguage = subtitle.language, - targetLanguage = target, - ) - } - } - return filtered -} - -private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List { - val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - settings.preferredSubtitleLanguage - } - return resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = preferredLanguage, - secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ).filterNot { it == SubtitleLanguageOption.FORCED } -} - -private fun findPersistedAudioTrackIndex( - tracks: List, - preference: PersistedPlayerTrackPreference, -): Int { - preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> - tracks.firstOrNull { it.id == trackId }?.let { return it.index } - } - preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language -> - tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } - } - preference.audioName?.takeIf { it.isNotBlank() }?.let { name -> - tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } - } - return -1 -} - -private fun findPersistedSubtitleTrackIndex( - tracks: List, - preference: PersistedPlayerTrackPreference, -): Int { - preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> - tracks.firstOrNull { it.id == trackId }?.let { return it.index } - } - preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language -> - tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } - } - preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name -> - tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } - } - return -1 + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt new file mode 100644 index 000000000..47921c816 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.player + +import androidx.compose.ui.Modifier + +internal data class PlayerScreenArgs( + val title: String, + val sourceUrl: String, + val sourceAudioUrl: String?, + val sourceHeaders: Map, + val sourceResponseHeaders: Map, + val providerName: String, + val streamTitle: String, + val streamSubtitle: String?, + val initialBingeGroup: String?, + val pauseDescription: String?, + val onBack: () -> Unit, + val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?, + val modifier: Modifier, + val logo: String?, + val poster: String?, + val background: String?, + val seasonNumber: Int?, + val episodeNumber: Int?, + val episodeTitle: String?, + val episodeThumbnail: String?, + val contentType: String?, + val videoId: String?, + val parentMetaId: String, + val parentMetaType: String, + val providerAddonId: String?, + val torrentInfoHash: String?, + val torrentFileIdx: Int?, + val torrentFilename: String?, + val torrentMagnetUri: String?, + val torrentTrackers: List, + val initialPositionMs: Long, + val initialProgressFraction: Float?, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt new file mode 100644 index 000000000..bc1363e57 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt @@ -0,0 +1,146 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_airs_prefix +import nuvio.composeapp.generated.resources.compose_player_downloaded +import nuvio.composeapp.generated.resources.compose_player_resize_fill +import nuvio.composeapp.generated.resources.compose_player_resize_fit +import nuvio.composeapp.generated.resources.compose_player_resize_zoom +import nuvio.composeapp.generated.resources.generic_unknown +import nuvio.composeapp.generated.resources.parental_alcohol +import nuvio.composeapp.generated.resources.parental_frightening +import nuvio.composeapp.generated.resources.parental_nudity +import nuvio.composeapp.generated.resources.parental_profanity +import nuvio.composeapp.generated.resources.parental_severity_mild +import nuvio.composeapp.generated.resources.parental_severity_moderate +import nuvio.composeapp.generated.resources.parental_severity_severe +import nuvio.composeapp.generated.resources.parental_violence +import nuvio.composeapp.generated.resources.compose_player_tba +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PlayerScreenContent(args: PlayerScreenArgs) { + LockPlayerToLandscape() + + val playerSettingsUiState by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pSettingsUiState by remember { + P2pSettingsRepository.ensureLoaded() + P2pSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle() + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val watchProgressUiState by remember { + WatchProgressRepository.ensureLoaded() + WatchProgressRepository.uiState + }.collectAsStateWithLifecycle() + val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() + val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() + val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() + val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() + + val runtime = remember { PlayerScreenRuntime(args) } + runtime.args = args + + BoxWithConstraints( + modifier = args.modifier + .fillMaxSize() + .background(Color.Black), + ) { + val density = LocalDensity.current + val horizontalSafePadding = playerHorizontalSafePadding() + val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } + + runtime.scope = rememberCoroutineScope() + runtime.hapticFeedback = LocalHapticFeedback.current + runtime.gestureController = rememberPlayerGestureController() + runtime.playerSettingsUiState = playerSettingsUiState + runtime.p2pSettingsUiState = p2pSettingsUiState + runtime.p2pStreamingState = p2pStreamingState + runtime.metaScreenSettingsUiState = metaScreenSettingsUiState + runtime.watchedUiState = watchedUiState + runtime.watchProgressUiState = watchProgressUiState + runtime.sourceStreamsState = sourceStreamsState + runtime.episodeStreamsRepoState = episodeStreamsRepoState + runtime.metaUiState = metaUiState + runtime.addonsUiState = addonsUiState + runtime.addonSubtitles = addonSubtitles + runtime.isLoadingAddonSubtitles = isLoadingAddonSubtitles + runtime.horizontalSafePadding = horizontalSafePadding + runtime.metrics = metrics + runtime.sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding + runtime.overlayBottomPadding = sliderOverlayBottomPadding(metrics) + runtime.sideGestureSystemEdgeExclusionPx = with(density) { + PlayerSideGestureSystemEdgeExclusion.toPx() + } + runtime.resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) + runtime.resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) + runtime.resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) + runtime.downloadedLabel = stringResource(Res.string.compose_player_downloaded) + runtime.airsPrefix = stringResource(Res.string.compose_player_airs_prefix) + runtime.tbaLabel = stringResource(Res.string.compose_player_tba) + runtime.genericUnknownLabel = stringResource(Res.string.generic_unknown) + runtime.parentalGuideLabels = ParentalGuideLabels( + nudity = stringResource(Res.string.parental_nudity), + violence = stringResource(Res.string.parental_violence), + profanity = stringResource(Res.string.parental_profanity), + alcohol = stringResource(Res.string.parental_alcohol), + frightening = stringResource(Res.string.parental_frightening), + severe = stringResource(Res.string.parental_severity_severe), + moderate = stringResource(Res.string.parental_severity_moderate), + mild = stringResource(Res.string.parental_severity_mild), + ) + if (runtime.playerMetaVideos.isEmpty()) { + runtime.playerMetaVideos = MetaDetailsRepository.peek( + args.parentMetaType, + args.parentMetaId, + )?.videos ?: emptyList() + } + if (runtime.lastSyncedSettingsResizeMode != playerSettingsUiState.resizeMode) { + runtime.resizeMode = playerSettingsUiState.resizeMode + runtime.lastSyncedSettingsResizeMode = playerSettingsUiState.resizeMode + } + runtime.resetIdentityStateIfNeeded() + + val keepScreenAwake = runtime.errorMessage == null && + (runtime.playbackSnapshot.isPlaying || + (runtime.shouldPlay && runtime.playbackSnapshot.isLoading)) + EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) + ManagePlayerPictureInPicture( + isPlaying = runtime.playbackSnapshot.isPlaying, + playerSize = runtime.layoutSize, + ) + runtime.BindPlayerRuntimeEffects() + runtime.RenderPlayerRuntimeUi() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt new file mode 100644 index 000000000..1e7dc31fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt @@ -0,0 +1,236 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import com.nuvio.app.features.details.MetaDetailsUiState +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.p2p.P2pConsentDialog +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.watchprogress.WatchProgressEntry + +@Composable +internal fun PlayerScreenModalHosts( + pendingP2pSwitch: PendingPlayerP2pSwitch?, + onPendingP2pSwitchChanged: (PendingPlayerP2pSwitch?) -> Unit, + onP2pEpisodeStreamSelected: (StreamItem, MetaVideo, Boolean) -> Unit, + onP2pSourceStreamSelected: (StreamItem) -> Unit, + onNextEpisodeAutoPlaySearchingChanged: (Boolean) -> Unit, + onNextEpisodeAutoPlayCountdownChanged: (Int?) -> Unit, + onNextEpisodeAutoPlaySourceNameChanged: (String?) -> Unit, + showAudioModal: Boolean, + audioTracks: List, + selectedAudioIndex: Int, + onAudioTrackSelected: (Int) -> Unit, + onAudioModalDismissed: () -> Unit, + showSubtitleModal: Boolean, + activeSubtitleTab: SubtitleTab, + subtitleTracks: List, + selectedSubtitleIndex: Int, + addonSubtitles: List, + selectedAddonSubtitleId: String?, + isLoadingAddonSubtitles: Boolean, + subtitleStyle: SubtitleStyleState, + subtitleDelayMs: Int, + selectedAddonSubtitle: AddonSubtitle?, + subtitleAutoSyncState: SubtitleAutoSyncUiState, + onSubtitleTabSelected: (SubtitleTab) -> Unit, + onBuiltInSubtitleTrackSelected: (Int) -> Unit, + onAddonSubtitleSelected: (AddonSubtitle) -> Unit, + onFetchAddonSubtitles: () -> Unit, + onSubtitleStyleChanged: (SubtitleStyleState) -> Unit, + onSubtitleDelayChanged: (Int) -> Unit, + onSubtitleDelayReset: () -> Unit, + onAutoSyncCapture: () -> Unit, + onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, + onAutoSyncReload: () -> Unit, + onSubtitleModalDismissed: () -> Unit, + showVideoSettingsModal: Boolean, + playerSettings: PlayerSettingsUiState, + onVideoSettingsChanged: () -> Unit, + onVideoSettingsModalDismissed: () -> Unit, + showSourcesPanel: Boolean, + sourceStreamsState: StreamsUiState, + activeSourceUrl: String, + activeStreamTitle: String, + onSourceFilterSelected: (String?) -> Unit, + onSourceStreamSelected: (StreamItem) -> Unit, + onReloadSources: () -> Unit, + onSourcesPanelDismissed: () -> Unit, + isSeries: Boolean, + showEpisodesPanel: Boolean, + allEpisodes: List, + parentMetaType: String, + parentMetaId: String, + activeSeasonNumber: Int?, + activeEpisodeNumber: Int?, + watchProgressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + episodeStreamsPanelState: EpisodeStreamsPanelState, + episodeStreamsRepoState: StreamsUiState, + onEpisodeSelectedForDownload: (MetaVideo) -> Boolean, + onEpisodeStreamsRequested: (MetaVideo) -> Unit, + onEpisodeStreamFilterSelected: (String?) -> Unit, + onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit, + onBackToEpisodes: () -> Unit, + onReloadEpisodeStreams: () -> Unit, + onEpisodesPanelDismissed: () -> Unit, + showSubmitIntroModal: Boolean, + activeVideoId: String?, + metaUiState: MetaDetailsUiState, + displayedPositionMs: Long, + submitIntroSegmentType: String, + onSubmitIntroSegmentTypeChanged: (String) -> Unit, + submitIntroStartTimeStr: String, + onSubmitIntroStartTimeChanged: (String) -> Unit, + submitIntroEndTimeStr: String, + onSubmitIntroEndTimeChanged: (String) -> Unit, + onSubmitIntroDismissed: () -> Unit, + onSubmitIntroSuccess: () -> Unit, +) { + if (pendingP2pSwitch != null) { + P2pConsentDialog( + onEnableP2p = { + val pending = pendingP2pSwitch + onPendingP2pSwitchChanged(null) + P2pSettingsRepository.setP2pEnabled(true) + val episode = pending.episode + if (episode != null) { + onP2pEpisodeStreamSelected(pending.stream, episode, pending.isAutoPlay) + } else { + onP2pSourceStreamSelected(pending.stream) + } + }, + onDismiss = { + if (pendingP2pSwitch.isAutoPlay) { + onNextEpisodeAutoPlaySearchingChanged(false) + onNextEpisodeAutoPlayCountdownChanged(null) + onNextEpisodeAutoPlaySourceNameChanged(null) + } + onPendingP2pSwitchChanged(null) + }, + ) + } + + AudioTrackModal( + visible = showAudioModal, + audioTracks = audioTracks, + selectedIndex = selectedAudioIndex, + onTrackSelected = onAudioTrackSelected, + onDismiss = onAudioModalDismissed, + ) + + SubtitleModal( + visible = showSubtitleModal, + activeTab = activeSubtitleTab, + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + addonSubtitles = addonSubtitles, + selectedAddonSubtitleId = selectedAddonSubtitleId, + isLoadingAddonSubtitles = isLoadingAddonSubtitles, + subtitleStyle = subtitleStyle, + subtitleDelayMs = subtitleDelayMs, + selectedAddonSubtitle = selectedAddonSubtitle, + subtitleAutoSyncState = subtitleAutoSyncState, + onTabSelected = onSubtitleTabSelected, + onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected, + onAddonSubtitleSelected = onAddonSubtitleSelected, + onFetchAddonSubtitles = onFetchAddonSubtitles, + onStyleChanged = onSubtitleStyleChanged, + onSubtitleDelayChanged = onSubtitleDelayChanged, + onSubtitleDelayReset = onSubtitleDelayReset, + onAutoSyncCapture = onAutoSyncCapture, + onAutoSyncCueSelected = onAutoSyncCueSelected, + onAutoSyncReload = onAutoSyncReload, + onDismiss = onSubtitleModalDismissed, + ) + + IosVideoSettingsModal( + visible = showVideoSettingsModal, + settings = playerSettings, + onSettingsChanged = onVideoSettingsChanged, + onDismiss = onVideoSettingsModalDismissed, + ) + + PlayerSourcesPanel( + visible = showSourcesPanel, + streamsUiState = sourceStreamsState, + currentStreamUrl = activeSourceUrl, + currentStreamName = activeStreamTitle, + onFilterSelected = onSourceFilterSelected, + onStreamSelected = onSourceStreamSelected, + onReload = onReloadSources, + onDismiss = onSourcesPanelDismissed, + ) + + if (isSeries) { + PlayerEpisodesPanel( + visible = showEpisodesPanel, + episodes = allEpisodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + currentSeason = activeSeasonNumber, + currentEpisode = activeEpisodeNumber, + progressByVideoId = watchProgressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + episodeStreamsState = episodeStreamsPanelState.copy( + streamsUiState = episodeStreamsRepoState, + ), + onSeasonSelected = { }, + onEpisodeSelected = { episode -> + if (!onEpisodeSelectedForDownload(episode)) { + onEpisodeStreamsRequested(episode) + } + }, + onEpisodeStreamFilterSelected = onEpisodeStreamFilterSelected, + onEpisodeStreamSelected = onEpisodeStreamSelected, + onBackToEpisodes = onBackToEpisodes, + onReloadEpisodeStreams = onReloadEpisodeStreams, + onDismiss = onEpisodesPanelDismissed, + ) + } + + val season = activeSeasonNumber + val episode = activeEpisodeNumber + val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } + ?: parentMetaId.takeIf { it.startsWith("tt") } + ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } + + if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) { + com.nuvio.app.features.player.skip.SubmitIntroDialog( + imdbId = imdbId, + season = season, + episode = episode, + currentTimeSec = displayedPositionMs / 1000.0, + segmentType = submitIntroSegmentType, + onSegmentTypeChange = onSubmitIntroSegmentTypeChanged, + startTimeStr = submitIntroStartTimeStr, + onStartTimeChange = onSubmitIntroStartTimeChanged, + endTimeStr = submitIntroEndTimeStr, + onEndTimeChange = onSubmitIntroEndTimeChanged, + onDismiss = onSubmitIntroDismissed, + onSuccess = onSubmitIntroSuccess, + ) + } +} + +internal fun selectDownloadedEpisodeForPlayback( + parentMetaId: String, + episode: MetaVideo, + onDownloadedEpisodeSelected: (com.nuvio.app.features.downloads.DownloadItem, MetaVideo) -> Unit, +): Boolean { + val downloadedEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + videoId = episode.id, + ) + if (downloadedEpisode != null) { + onDownloadedEpisodeSelected(downloadedEpisode, episode) + return true + } + return false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt new file mode 100644 index 000000000..9c539efaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -0,0 +1,471 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamRequest +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules +import com.nuvio.app.features.player.skip.SkipIntroRepository +import com.nuvio.app.features.streams.BingeGroupCacheRepository +import com.nuvio.app.features.streams.StreamLinkCacheRepository +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + +@Composable +internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { + val currentFeedback = liveGestureFeedback ?: gestureFeedback + LaunchedEffect(currentFeedback) { + if (currentFeedback != null) { + renderedGestureFeedback = currentFeedback + } + } + + LaunchedEffect(parentMetaType, parentMetaId) { + playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList() + if (playerMetaVideos.isEmpty()) { + playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() + } + } + + LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) { + val currentMeta = metaUiState.meta ?: return@LaunchedEffect + if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) { + playerMetaVideos = currentMeta.videos + } + } + + LaunchedEffect(currentStreamBingeGroup, parentMetaId) { + val bg = currentStreamBingeGroup + if (bg != null && parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(parentMetaId, bg) + } + } + + LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { + errorMessage = null + playerController = null + playerControllerSourceUrl = null + playbackSnapshot = PlayerPlaybackSnapshot() + isScrubbingTimeline = false + scrubbingPositionMs = null + liveGestureFeedback = null + renderedGestureFeedback = null + lockedOverlayVisible = false + initialLoadCompleted = false + lastProgressPersistEpochMs = 0L + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + seekProgressSyncJob?.cancel() + seekProgressSyncJob = null + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = null + accumulatedSeekState = null + speedBoostRestoreSpeed = null + preferredAudioSelectionApplied = false + preferredSubtitleSelectionApplied = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + SubtitleRepository.clear() + WatchProgressRepository.ensureLoaded() + } + + LaunchedEffect( + activeTorrentInfoHash, + activeTorrentFileIdx, + activeTorrentFilename, + activeTorrentMagnetUri, + activeTorrentTrackers, + p2pSettingsUiState.p2pEnabled, + ) { + val infoHash = activeTorrentInfoHash + if (infoHash == null) { + p2pResolvedSourceUrl = null + P2pStreamingEngine.stopStream() + return@LaunchedEffect + } + if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) { + return@LaunchedEffect + } + + p2pResolvedSourceUrl = null + val requestedFileIdx = activeTorrentFileIdx + val requestedFilename = activeTorrentFilename + val requestedMagnetUri = activeTorrentMagnetUri + val requestedTrackers = activeTorrentTrackers + errorMessage = null + playerController = null + playerControllerSourceUrl = null + playbackSnapshot = PlayerPlaybackSnapshot() + initialLoadCompleted = false + + try { + val localUrl = P2pStreamingEngine.startStream( + P2pStreamRequest( + infoHash = infoHash, + fileIdx = requestedFileIdx, + filename = requestedFilename, + magnetUri = requestedMagnetUri, + trackers = requestedTrackers, + ), + ) + if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) { + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + p2pResolvedSourceUrl = localUrl + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + errorMessage = getString( + Res.string.player_error_failed_start_torrent, + error.message ?: genericUnknownLabel, + ) + controlsVisible = !playerControlsLocked + initialLoadCompleted = true + } + } + + LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) { + val state = p2pStreamingState + if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) { + errorMessage = getString(Res.string.player_error_torrent, state.message) + controlsVisible = !playerControlsLocked + } + } + + LaunchedEffect(playbackSession.videoId) { + subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0 + subtitleAutoSyncState = SubtitleAutoSyncUiState() + } + + LaunchedEffect(playerController, subtitleDelayMs) { + playerController?.setSubtitleDelayMs(subtitleDelayMs) + } + + LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) { + subtitleAutoSyncState = SubtitleAutoSyncUiState() + } + + LaunchedEffect(playerController, subtitleStyle) { + playerController?.applySubtitleStyle(subtitleStyle) + } + + LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { + val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect + if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { + return@LaunchedEffect + } + if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect + autoFetchedAddonSubtitlesForKey = fetchKey + fetchAddonSubtitlesForActiveItem() + } + + LaunchedEffect(playbackSnapshot.isLoading, playerController) { + if (!playbackSnapshot.isLoading && playerController != null) { + refreshTracks() + } + } + + LaunchedEffect( + playerController, + playbackSnapshot.isLoading, + preferredAudioSelectionApplied, + preferredSubtitleSelectionApplied, + ) { + if (playerController == null || playbackSnapshot.isLoading) { + return@LaunchedEffect + } + if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { + return@LaunchedEffect + } + + repeat(10) { + refreshTracks() + if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { + return@LaunchedEffect + } + delay(300) + } + } + + LaunchedEffect( + playerController, + playerControllerSourceUrl, + playbackSnapshot.isLoading, + playbackSnapshot.durationMs, + activeInitialPositionMs, + activeInitialProgressFraction, + initialSeekApplied, + ) { + val controller = playerController ?: return@LaunchedEffect + if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect + if (initialSeekApplied || playbackSnapshot.isLoading) return@LaunchedEffect + + val progressFraction = activeInitialProgressFraction + ?.takeIf { it > 0f } + ?.coerceIn(0f, 1f) + val targetPositionMs = when { + activeInitialPositionMs > 0L -> activeInitialPositionMs + progressFraction != null && playbackSnapshot.durationMs > 0L -> { + (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong() + } + progressFraction != null -> return@LaunchedEffect + else -> 0L + } + if (targetPositionMs <= 0L) { + initialSeekApplied = true + return@LaunchedEffect + } + + controller.seekTo(targetPositionMs) + initialSeekApplied = true + } + + BindPlayerUiVisibilityEffects() + BindPlayerMetadataAndSkipEffects() + + DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) { + onDispose { + flushWatchProgress() + } + } + + DisposableEffect(Unit) { + onDispose { + P2pStreamingEngine.shutdown() + PlayerStreamsRepository.clearAll() + } + } +} + +@Composable +private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { + LaunchedEffect( + controlsVisible, + isScrubbingTimeline, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + showParentalGuide, + errorMessage, + ) { + if ( + !controlsVisible || + isScrubbingTimeline || + !playbackSnapshot.isPlaying || + playbackSnapshot.isLoading || + showParentalGuide || + errorMessage != null + ) { + return@LaunchedEffect + } + delay(3500) + controlsVisible = false + } + + LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { + if (!playerControlsLocked || !lockedOverlayVisible) return@LaunchedEffect + delay(PlayerLockedOverlayDurationMs) + lockedOverlayVisible = false + } + + LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { + pausedOverlayVisible = false + if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { + return@LaunchedEffect + } + delay(5000) + pausedOverlayVisible = true + } + + LaunchedEffect( + playbackSnapshot.positionMs, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + playbackSnapshot.isEnded, + playbackSnapshot.durationMs, + ) { + if (playbackSnapshot.isEnded) { + flushWatchProgress() + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + return@LaunchedEffect + } + + if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { + pendingScrobbleStartAfterSeek = false + flushWatchProgress() + } + + if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { + pendingScrobbleStartAfterSeek = false + emitTraktScrobbleStart() + } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { + emitTraktScrobbleStart() + } + + if (!playbackSnapshot.isLoading) { + previousIsPlaying = playbackSnapshot.isPlaying + } + if (playbackSnapshot.isPlaying) { + persistPlaybackProgressTick() + } + } +} + +@Composable +private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() { + LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) { + parentalWarnings = emptyList() + showParentalGuide = false + parentalGuideHasShown = false + playbackStartedForParentalGuide = false + + val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect + val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect + parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels) + + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + + LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) { + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + + LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) { + skipIntervals = emptyList() + activeSkipInterval = null + skipIntervalDismissed = false + showNextEpisodeCard = false + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlaySearching = false + + val season = activeSeasonNumber + val episode = activeEpisodeNumber + val vid = activeVideoId + if (season == null || episode == null || vid == null) return@LaunchedEffect + + launch { + val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") } + val intervals = SkipIntroRepository.getSkipIntervals( + imdbId = imdbId, + season = season, + episode = episode, + ) + skipIntervals = intervals + } + } + + LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) { + if (skipIntervals.isEmpty()) { + activeSkipInterval = null + return@LaunchedEffect + } + val positionSec = playbackSnapshot.positionMs / 1000.0 + val current = skipIntervals.firstOrNull { interval -> + positionSec >= interval.startTime && positionSec < interval.endTime + } + if (current != activeSkipInterval) { + activeSkipInterval = current + if (current != null) skipIntervalDismissed = false + } + } + + LaunchedEffect(playerMetaVideos, activeSeasonNumber, activeEpisodeNumber) { + if (!isSeries || playerMetaVideos.isEmpty()) { + nextEpisodeInfo = null + return@LaunchedEffect + } + val curSeason = activeSeasonNumber ?: return@LaunchedEffect + val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect + val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode( + videos = playerMetaVideos, + currentSeason = curSeason, + currentEpisode = curEpisode, + ) + val nextSeason = nextVideo?.season + val nextEpisode = nextVideo?.episode + nextEpisodeInfo = if (nextVideo != null && nextSeason != null && nextEpisode != null) { + NextEpisodeInfo( + videoId = nextVideo.id, + season = nextSeason, + episode = nextEpisode, + title = nextVideo.title, + thumbnail = nextVideo.thumbnail, + overview = nextVideo.overview, + released = nextVideo.released, + hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), + unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { + "$airsPrefix ${nextVideo.released ?: tbaLabel}" + } else null, + ) + } else null + } + + LaunchedEffect( + playbackSnapshot.positionMs, + playbackSnapshot.durationMs, + nextEpisodeInfo, + skipIntervals, + playerSettingsUiState.nextEpisodeThresholdMode, + playerSettingsUiState.nextEpisodeThresholdPercent, + playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, + ) { + if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) { + showNextEpisodeCard = false + return@LaunchedEffect + } + val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard( + positionMs = playbackSnapshot.positionMs, + durationMs = playbackSnapshot.durationMs, + skipIntervals = skipIntervals, + thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode, + thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent, + thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, + ) + if (shouldShow && !showNextEpisodeCard) { + showNextEpisodeCard = true + if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { + playNextEpisode() + } + } else if (!shouldShow) { + showNextEpisodeCard = false + } + } + + LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) { + if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) { + showNextEpisodeCard = true + if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { + playNextEpisode() + } + } + } +} + +internal fun PlayerScreenRuntime.removeFailedStreamFromCache() { + val currentVideoId = activeVideoId ?: return + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = currentVideoId, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + StreamLinkCacheRepository.remove(cacheKey) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt new file mode 100644 index 000000000..5d025f5ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt @@ -0,0 +1,310 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import kotlin.math.abs +import kotlin.math.roundToInt + +internal data class PlayerSurfaceGestureCallbacks( + val onSurfaceTap: State<(Offset) -> Unit>, + val onSurfaceDoubleTap: State<(Offset) -> Unit>, + val activateHoldToSpeed: State<() -> Unit>, + val deactivateHoldToSpeed: State<() -> Unit>, + val showHorizontalSeekPreview: State<(Long, Long) -> Unit>, + val showBrightnessFeedback: State<(Float) -> Unit>, + val showVolumeFeedback: State<(PlayerAudioLevel) -> Unit>, + val clearLiveGestureFeedback: State<() -> Unit>, + val revealLockedOverlay: State<() -> Unit>, + val isHoldToSpeedGestureActive: State, + val playerControlsLocked: State, + val currentPositionMs: State, + val currentDurationMs: State, + val commitHorizontalSeek: State<(Long) -> Unit>, +) + +internal fun PlayerScreenRuntime.showGestureFeedback(feedback: GestureFeedbackState) { + gestureMessageJob?.cancel() + gestureFeedback = feedback + gestureMessageJob = scope.launch { + delay(900) + gestureFeedback = null + } +} + +internal fun PlayerScreenRuntime.showGestureMessage(message: String) { + showGestureFeedback(GestureFeedbackState(message = message)) +} + +internal fun PlayerScreenRuntime.clearLiveGestureFeedback() { + liveGestureFeedback = null +} + +internal fun PlayerScreenRuntime.revealLockedOverlay() { + controlsVisible = false + lockedOverlayVisible = true +} + +internal fun PlayerScreenRuntime.lockPlayerControls() { + playerControlsLocked = true + controlsVisible = false + lockedOverlayVisible = false + pausedOverlayVisible = false + isScrubbingTimeline = false + scrubbingPositionMs = null + gestureMessageJob?.cancel() + gestureFeedback = null + liveGestureFeedback = null + renderedGestureFeedback = null + showAudioModal = false + showSubtitleModal = false + showVideoSettingsModal = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() +} + +internal fun PlayerScreenRuntime.unlockPlayerControls() { + playerControlsLocked = false + lockedOverlayVisible = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { + val seconds = amountMs / 1000L + if (seconds <= 0L) return + showGestureFeedback( + GestureFeedbackState( + messageRes = if (direction == PlayerSeekDirection.Forward) { + Res.string.compose_player_seek_feedback_forward + } else { + Res.string.compose_player_seek_feedback_backward + }, + messageArgs = listOf(seconds), + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + ), + ) +} + +internal fun PlayerScreenRuntime.showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) { + val deltaMs = previewPositionMs - baselinePositionMs + val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackTime(previewPositionMs), + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + secondaryMessageRes = if (deltaMs >= 0L) { + Res.string.compose_player_seek_delta_forward + } else { + Res.string.compose_player_seek_delta_backward + }, + secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), + secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { + Color(0xFF6EE7A8) + } else { + Color(0xFFFF9A76) + }, + ) +} + +internal fun PlayerScreenRuntime.showBrightnessFeedback(level: Float) { + val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() + showGestureFeedback( + GestureFeedbackState( + messageRes = Res.string.compose_player_brightness_level, + messageArgs = listOf("$percentage%"), + icon = GestureFeedbackIcon.Brightness, + ), + ) +} + +internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) { + val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() + showGestureFeedback( + GestureFeedbackState( + messageRes = if (level.isMuted) { + Res.string.compose_player_muted + } else { + Res.string.compose_player_volume_level + }, + messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"), + icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, + isDanger = level.isMuted, + ), + ) +} + +internal fun PlayerScreenRuntime.togglePlayback() { + if (playbackSnapshot.isPlaying) { + shouldPlay = false + playerController?.pause() + } else { + if (playbackSnapshot.isEnded) { + playerController?.seekTo(0L) + } + shouldPlay = true + playerController?.play() + } + controlsVisible = true +} + +internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) { + playerController?.seekBy(offsetMs) + scheduleProgressSyncAfterSeek() + controlsVisible = true + when { + offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) + offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) + } +} + +internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) { + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val currentSeekState = accumulatedSeekState + val nextState = if (currentSeekState?.direction == direction) { + currentSeekState.copy(amountMs = currentSeekState.amountMs + PlayerDoubleTapSeekStepMs) + } else { + PlayerAccumulatedSeekState( + direction = direction, + baselinePositionMs = currentPositionMs, + amountMs = PlayerDoubleTapSeekStepMs, + ) + } + accumulatedSeekState = nextState + + val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L } + val targetPositionMs = when (direction) { + PlayerSeekDirection.Backward -> { + (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L) + } + PlayerSeekDirection.Forward -> { + val unclamped = nextState.baselinePositionMs + nextState.amountMs + maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped + } + } + playerController?.seekTo(targetPositionMs) + scheduleProgressSyncAfterSeek() + showSeekFeedback(direction, nextState.amountMs) + + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = scope.launch { + delay(PlayerDoubleTapSeekResetDelayMs) + accumulatedSeekState = null + } +} + +internal fun PlayerScreenRuntime.cycleResizeMode() { + val nextMode = resizeMode.next() + resizeMode = nextMode + lastSyncedSettingsResizeMode = nextMode + PlayerSettingsRepository.setResizeMode(nextMode) + showGestureMessage( + when (nextMode) { + PlayerResizeMode.Fit -> resizeModeFitLabel + PlayerResizeMode.Fill -> resizeModeFillLabel + PlayerResizeMode.Zoom -> resizeModeZoomLabel + }, + ) + controlsVisible = true +} + +internal fun PlayerScreenRuntime.cyclePlaybackSpeed() { + val speeds = listOf(1f, 1.25f, 1.5f, 2f) + val current = playbackSnapshot.playbackSpeed + val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() + playerController?.setPlaybackSpeed(next) + showGestureMessage(formatPlaybackSpeedLabel(next)) + controlsVisible = true +} + +internal fun PlayerScreenRuntime.activateHoldToSpeed() { + if (!playerSettingsUiState.holdToSpeedEnabled) return + val controller = playerController ?: return + if (speedBoostRestoreSpeed != null) return + + val targetSpeed = playerSettingsUiState.holdToSpeedValue + val currentSpeed = playbackSnapshot.playbackSpeed + if (abs(currentSpeed - targetSpeed) < 0.01f) return + + isHoldToSpeedGestureActive = true + speedBoostRestoreSpeed = currentSpeed + controller.setPlaybackSpeed(targetSpeed) + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackSpeedLabel(targetSpeed), + icon = GestureFeedbackIcon.Speed, + ) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) +} + +internal fun PlayerScreenRuntime.deactivateHoldToSpeed() { + isHoldToSpeedGestureActive = false + val restoreSpeed = speedBoostRestoreSpeed ?: return + playerController?.setPlaybackSpeed(restoreSpeed) + speedBoostRestoreSpeed = null + liveGestureFeedback = null +} + +@Composable +internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfaceGestureCallbacks { + val onSurfaceTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } + val centerStart = layoutSize.width * PlayerLeftGestureBoundary + val centerEnd = layoutSize.width * PlayerRightGestureBoundary + if (controlsVisible && offset.x in centerStart..centerEnd) { + controlsVisible = false + } else { + controlsVisible = !controlsVisible + } + } + val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } + when { + offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Backward) + } + offset.x > layoutSize.width * PlayerRightGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Forward) + } + else -> controlsVisible = !controlsVisible + } + } + return PlayerSurfaceGestureCallbacks( + onSurfaceTap = onSurfaceTap, + onSurfaceDoubleTap = onSurfaceDoubleTap, + activateHoldToSpeed = rememberUpdatedState(::activateHoldToSpeed), + deactivateHoldToSpeed = rememberUpdatedState(::deactivateHoldToSpeed), + showHorizontalSeekPreview = rememberUpdatedState(::showHorizontalSeekPreview), + showBrightnessFeedback = rememberUpdatedState(::showBrightnessFeedback), + showVolumeFeedback = rememberUpdatedState(::showVolumeFeedback), + clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback), + revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay), + isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive), + playerControlsLocked = rememberUpdatedState(playerControlsLocked), + currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)), + currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs), + commitHorizontalSeek = rememberUpdatedState { targetPositionMs: Long -> + playerController?.seekTo(targetPositionMs) + scheduleProgressSyncAfterSeek() + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt new file mode 100644 index 000000000..22828532e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -0,0 +1,208 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.watchprogress.WatchProgressClock +import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal val PlayerScreenRuntime.activePlaybackIdentity: String + get() = activeTorrentInfoHash + ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" } + ?: activeSourceUrl + +internal val PlayerScreenRuntime.playbackSession: WatchProgressPlaybackSession + get() = WatchProgressPlaybackSession( + contentType = contentType ?: parentMetaType, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + fallbackVideoId = activeVideoId, + ), + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + episodeThumbnail = activeEpisodeThumbnail, + providerName = activeProviderName, + providerAddonId = activeProviderAddonId, + lastStreamTitle = activeStreamTitle, + lastStreamSubtitle = activeStreamSubtitle, + pauseDescription = pauseDescription, + lastSourceUrl = activeSourceUrl, + ) + +internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { + val identity = activePlaybackIdentity + if (lastResetPlaybackIdentity != identity) { + lastResetPlaybackIdentity = identity + shouldPlay = true + initialLoadCompleted = false + speedBoostRestoreSpeed = null + isHoldToSpeedGestureActive = false + initialSeekApplied = activeInitialPositionMs <= 0L && + (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) + lastProgressPersistEpochMs = 0L + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + autoFetchedAddonSubtitlesForKey = null + trackPreferenceRestoreApplied = false + preferredAudioSelectionApplied = false + preferredSubtitleSelectionApplied = false + } + + val videoIdentity = "$identity:$activeVideoId:$activeSeasonNumber:$activeEpisodeNumber" + if (lastResetVideoIdentity != videoIdentity) { + lastResetVideoIdentity = videoIdentity + hasRequestedScrobbleStartForCurrentItem = false + scrobbleStartRequestGeneration = 0L + pendingScrobbleStartAfterSeek = false + hasSentCompletionScrobbleForCurrentItem = false + currentTraktScrobbleItem = null + } +} + +internal fun PlayerScreenRuntime.currentPlaybackProgressPercent( + snapshot: PlayerPlaybackSnapshot = playbackSnapshot, +): Float { + val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f + return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f) + .coerceIn(0f, 100f) +} + +internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() = + TraktScrobbleRepository.buildItem( + contentType = contentType ?: parentMetaType, + parentMetaId = parentMetaId, + videoId = activeVideoId, + title = title, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ) + +internal fun PlayerScreenRuntime.emitTraktScrobbleStart() { + if (hasRequestedScrobbleStartForCurrentItem) return + hasRequestedScrobbleStartForCurrentItem = true + val requestGeneration = scrobbleStartRequestGeneration + 1L + scrobbleStartRequestGeneration = requestGeneration + + scope.launch { + val item = currentTraktScrobbleItem() + if (item == null) { + hasRequestedScrobbleStartForCurrentItem = false + return@launch + } + if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { + return@launch + } + currentTraktScrobbleItem = item + TraktScrobbleRepository.scrobbleStart( + item = item, + progressPercent = currentPlaybackProgressPercent(), + ) + } +} + +internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) { + val provided = progressPercent + if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return + + val percent = provided ?: currentPlaybackProgressPercent() + val itemSnapshot = currentTraktScrobbleItem + scope.launch(NonCancellable) { + val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch + TraktScrobbleRepository.scrobbleStop( + item = item, + progressPercent = percent, + ) + } + currentTraktScrobbleItem = null + hasRequestedScrobbleStartForCurrentItem = false + scrobbleStartRequestGeneration += 1L +} + +internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + return + } + + if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { + hasSentCompletionScrobbleForCurrentItem = true + emitTraktScrobbleStop(progressPercent) + } +} + +internal fun PlayerScreenRuntime.tryShowParentalGuide() { + if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { + playbackStartedForParentalGuide = true + controlsVisible = true + showParentalGuide = true + parentalGuideHasShown = true + } +} + +internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? { + val candidates = listOf(parentMetaId, activeVideoId) + candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it } + val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null + return TmdbService.tmdbToImdb( + tmdbId = tmdbId, + mediaType = contentType ?: parentMetaType, + ) +} + +internal fun PlayerScreenRuntime.flushWatchProgress() { + emitStopScrobbleForCurrentProgress() + WatchProgressRepository.flushPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + ) +} + +internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { + val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying + seekProgressSyncJob?.cancel() + seekProgressSyncJob = scope.launch { + delay(PlayerSeekProgressSyncDebounceMs) + WatchProgressRepository.upsertPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + ) + + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay + if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { + pendingScrobbleStartAfterSeek = false + emitTraktScrobbleStart() + } else if (shouldRestartScrobbleNow) { + pendingScrobbleStartAfterSeek = true + } + } + } +} + +internal fun PlayerScreenRuntime.persistPlaybackProgressTick() { + val now = WatchProgressClock.nowEpochMs() + if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) return + lastProgressPersistEpochMs = now + WatchProgressRepository.upsertPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + syncRemote = false, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt new file mode 100644 index 000000000..87bf6545e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -0,0 +1,424 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadItem +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamLinkCacheRepository +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.launch + +internal fun PlayerScreenRuntime.resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, +): Boolean { + if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false + scope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = season, + episode = episode, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + onStale() + } + } + } + } + return true +} + +internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = + "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" + +internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = + stream.needsLocalDebridResolve && stream.p2pInfoHash != null + +internal fun PlayerScreenRuntime.stopActiveP2pStream() { + if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { + P2pStreamingEngine.stopStream() + } + activeTorrentInfoHash = null + activeTorrentFileIdx = null + activeTorrentFilename = null + activeTorrentMagnetUri = null + activeTorrentTrackers = emptyList() + p2pResolvedSourceUrl = null +} + +internal fun PlayerScreenRuntime.saveP2pStreamForReuse( + stream: StreamItem, + videoId: String?, + season: Int?, + episode: Int?, +) { + if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return + val infoHash = stream.p2pInfoHash ?: return + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = videoId, + parentMetaId = parentMetaId, + season = season, + episode = episode, + ) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = "", + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + requestHeaders = emptyMap(), + responseHeaders = emptyMap(), + filename = stream.p2pFilename, + videoSize = stream.behaviorHints.videoSize, + infoHash = infoHash, + fileIdx = stream.p2pFileIdx, + magnetUri = stream.torrentMagnetUri, + sources = stream.p2pSourceHints, + bingeGroup = stream.behaviorHints.bingeGroup, + ) +} + +internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { + val infoHash = stream.p2pInfoHash ?: return + if (!P2pSettingsRepository.isVisible) return + if (!P2pSettingsRepository.uiState.value.p2pEnabled) { + pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false) + return + } + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + flushWatchProgress() + stopActiveP2pStream() + saveP2pStreamForReuse( + stream = stream, + videoId = activeVideoId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeTorrentInfoHash = infoHash + activeTorrentFileIdx = stream.p2pFileIdx + activeTorrentFilename = stream.p2pFilename + activeTorrentMagnetUri = stream.torrentMagnetUri + activeTorrentTrackers = stream.p2pTrackers + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = currentPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.switchToP2pEpisodeStream( + stream: StreamItem, + episode: MetaVideo, + isAutoPlay: Boolean = false, +) { + val infoHash = stream.p2pInfoHash ?: return + if (!P2pSettingsRepository.isVisible) return + if (!P2pSettingsRepository.uiState.value.p2pEnabled) { + pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay) + return + } + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + val epVideoId = episode.id + val resume = resolveEpisodeResume(epVideoId, episode) + saveP2pStreamForReuse( + stream = stream, + videoId = epVideoId, + season = episode.season, + episode = episode.episode, + ) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeTorrentInfoHash = infoHash + activeTorrentFileIdx = stream.p2pFileIdx + activeTorrentFilename = stream.p2pFilename + activeTorrentMagnetUri = stream.torrentMagnetUri + activeTorrentTrackers = stream.p2pTrackers + applyEpisodeStreamMetadata(stream, episode, resume) +} + +internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { + if ( + resolveDebridForPlayer( + stream = stream, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + onResolved = { switchToSource(it) }, + onStale = { + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + ) + ) return + if (isP2pStream(stream)) { + switchToP2pSourceStream(stream) + return + } + val url = stream.playableDirectUrl ?: return + if (url == activeSourceUrl) return + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + flushWatchProgress() + stopActiveP2pStream() + val currentVideoId = activeVideoId + if (playerSettingsUiState.streamReuseLastLinkEnabled && currentVideoId != null) { + saveDirectStreamForReuse(stream, url, currentVideoId, activeSeasonNumber, activeEpisodeNumber) + } + activeSourceUrl = url + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = currentPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { + if ( + resolveDebridForPlayer( + stream = stream, + season = episode.season, + episode = episode.episode, + onResolved = { resolvedStream -> switchToEpisodeStream(resolvedStream, episode) }, + onStale = { + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + }, + ) + ) return + if (isP2pStream(stream)) { + switchToP2pEpisodeStream(stream, episode) + return + } + val url = stream.playableDirectUrl ?: return + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + val epVideoId = episode.id + val resume = resolveEpisodeResume(epVideoId, episode) + if (playerSettingsUiState.streamReuseLastLinkEnabled) { + saveDirectStreamForReuse(stream, url, epVideoId, episode.season, episode.episode) + } + activeSourceUrl = url + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + applyEpisodeStreamMetadata(stream, episode, resume) +} + +internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + + val fallbackVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId + val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + ?.takeIf { !it.isCompleted } + val epResumeFraction = epEntry?.progressPercent + ?.takeIf { it > 0f } + ?.let { (it / 100f).coerceIn(0f, 1f) } + val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L + + activeSourceUrl = localFileUri + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeStreamTitle = downloadItem.streamTitle.ifBlank { + episode.title.ifBlank { title } + } + activeStreamSubtitle = downloadItem.streamSubtitle + activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } + activeProviderAddonId = downloadItem.providerAddonId + currentStreamBingeGroup = null + activeSeasonNumber = episode.season + activeEpisodeNumber = episode.episode + activeEpisodeTitle = episode.title + activeEpisodeThumbnail = episode.thumbnail + activeVideoId = resolvedVideoId + activeInitialPositionMs = epResumePositionMs + activeInitialProgressFraction = epResumeFraction + controlsVisible = true +} + +internal fun PlayerScreenRuntime.playNextEpisode() { + scope.launchPlayerNextEpisodeAutoPlay( + previousJob = nextEpisodeAutoPlayJob, + nextEpisodeInfo = nextEpisodeInfo, + allEpisodes = playerMetaVideos, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + contentType = contentType, + settings = playerSettingsUiState, + currentStreamBingeGroup = currentStreamBingeGroup, + onDownloadedEpisodeSelected = { item, episode -> switchToDownloadedEpisode(item, episode) }, + onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) }, + onManualSelectionRequired = { nextVideo -> + episodeStreamsPanelState = EpisodeStreamsPanelState( + showStreams = true, + selectedEpisode = nextVideo, + ) + showEpisodesPanel = true + }, + onSearchingChanged = { nextEpisodeAutoPlaySearching = it }, + onSourceNameChanged = { nextEpisodeAutoPlaySourceName = it }, + onCountdownChanged = { nextEpisodeAutoPlayCountdown = it }, + onNextEpisodeCardVisibleChanged = { showNextEpisodeCard = it }, + )?.let { job -> + nextEpisodeAutoPlayJob = job + } +} + +internal fun PlayerScreenRuntime.openSourcesPanel() { + val vid = activeVideoId ?: return + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + showSourcesPanel = true + showEpisodesPanel = false + controlsVisible = false +} + +internal fun PlayerScreenRuntime.openEpisodesPanel() { + if (playerMetaVideos.isEmpty()) { + scope.launch { + playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() + } + } + showEpisodesPanel = true + showSourcesPanel = false + controlsVisible = false +} + +private data class EpisodeResume(val positionMs: Long, val fraction: Float?) + +private fun PlayerScreenRuntime.resetEpisodePanelAndNextEpisodeState() { + showNextEpisodeCard = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + PlayerStreamsRepository.clearEpisodeStreams() +} + +private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: MetaVideo): EpisodeResume { + val epResumeVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = epVideoId, + ) + val epEntry = WatchProgressRepository.progressForVideo( + epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + )?.takeIf { !it.isCompleted } + val epResumeFraction = epEntry?.progressPercent + ?.takeIf { it > 0f } + ?.let { (it / 100f).coerceIn(0f, 1f) } + val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L + return EpisodeResume(positionMs = epResumePositionMs, fraction = epResumeFraction) +} + +private fun PlayerScreenRuntime.applyEpisodeStreamMetadata( + stream: StreamItem, + episode: MetaVideo, + resume: EpisodeResume, +) { + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeSeasonNumber = episode.season + activeEpisodeNumber = episode.episode + activeEpisodeTitle = episode.title + activeEpisodeThumbnail = episode.thumbnail + activeVideoId = episode.id + activeInitialPositionMs = resume.positionMs + activeInitialProgressFraction = resume.fraction + controlsVisible = true +} + +private fun PlayerScreenRuntime.saveDirectStreamForReuse( + stream: StreamItem, + url: String, + videoId: String, + season: Int?, + episode: Int?, +) { + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = videoId, + parentMetaId = parentMetaId, + season = season, + episode = episode, + ) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = url, + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + filename = stream.behaviorHints.filename, + videoSize = stream.behaviorHints.videoSize, + bingeGroup = stream.behaviorHints.bingeGroup, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt new file mode 100644 index 000000000..cd000586e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -0,0 +1,192 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.addons.AddonsUiState +import com.nuvio.app.features.details.MetaDetailsUiState +import com.nuvio.app.features.details.MetaScreenSettingsUiState +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.p2p.P2pSettingsUiState +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.SkipInterval +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.trakt.TraktScrobbleItem +import com.nuvio.app.features.watched.WatchedUiState +import com.nuvio.app.features.watchprogress.WatchProgressUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +internal class PlayerScreenRuntime( + args: PlayerScreenArgs, +) { + var args by mutableStateOf(args) + + val title: String get() = args.title + val sourceUrl: String get() = args.sourceUrl + val sourceAudioUrl: String? get() = args.sourceAudioUrl + val sourceHeaders: Map get() = args.sourceHeaders + val sourceResponseHeaders: Map get() = args.sourceResponseHeaders + val providerName: String get() = args.providerName + val streamTitle: String get() = args.streamTitle + val streamSubtitle: String? get() = args.streamSubtitle + val initialBingeGroup: String? get() = args.initialBingeGroup + val pauseDescription: String? get() = args.pauseDescription + val logo: String? get() = args.logo + val poster: String? get() = args.poster + val background: String? get() = args.background + val seasonNumber: Int? get() = args.seasonNumber + val episodeNumber: Int? get() = args.episodeNumber + val episodeTitle: String? get() = args.episodeTitle + val episodeThumbnail: String? get() = args.episodeThumbnail + val contentType: String? get() = args.contentType + val videoId: String? get() = args.videoId + val parentMetaId: String get() = args.parentMetaId + val parentMetaType: String get() = args.parentMetaType + val providerAddonId: String? get() = args.providerAddonId + val torrentInfoHash: String? get() = args.torrentInfoHash + val torrentFileIdx: Int? get() = args.torrentFileIdx + val torrentFilename: String? get() = args.torrentFilename + val torrentMagnetUri: String? get() = args.torrentMagnetUri + val torrentTrackers: List get() = args.torrentTrackers + val initialPositionMs: Long get() = args.initialPositionMs + val initialProgressFraction: Float? get() = args.initialProgressFraction + val isSeries: Boolean get() = parentMetaType == "series" + + lateinit var scope: CoroutineScope + lateinit var hapticFeedback: HapticFeedback + + var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState() + var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState() + var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle + var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() + var watchedUiState: WatchedUiState = WatchedUiState() + var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() + var sourceStreamsState: StreamsUiState = StreamsUiState() + var episodeStreamsRepoState: StreamsUiState = StreamsUiState() + var metaUiState: MetaDetailsUiState = MetaDetailsUiState() + var addonsUiState: AddonsUiState = AddonsUiState() + var addonSubtitles: List = emptyList() + var isLoadingAddonSubtitles: Boolean = false + + var horizontalSafePadding: Dp = 0.dp + var metrics: PlayerLayoutMetrics = PlayerLayoutMetrics.fromWidth(0.dp) + var sliderEdgePadding: Dp = 0.dp + var overlayBottomPadding: Dp = 0.dp + var sideGestureSystemEdgeExclusionPx: Float = 0f + var resizeModeFitLabel: String = "" + var resizeModeFillLabel: String = "" + var resizeModeZoomLabel: String = "" + var downloadedLabel: String = "" + var airsPrefix: String = "" + var tbaLabel: String = "" + var genericUnknownLabel: String = "" + var parentalGuideLabels: ParentalGuideLabels = ParentalGuideLabels("", "", "", "", "", "", "", "") + + var gestureController: PlayerGestureController? = null + + var controlsVisible by mutableStateOf(true) + var playerControlsLocked by mutableStateOf(false) + var activeSourceUrl by mutableStateOf(sourceUrl) + var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl) + var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) + var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) + var activeTorrentInfoHash by mutableStateOf(torrentInfoHash) + var activeTorrentFileIdx by mutableStateOf(torrentFileIdx) + var activeTorrentFilename by mutableStateOf(torrentFilename) + var activeTorrentMagnetUri by mutableStateOf(torrentMagnetUri) + var activeTorrentTrackers by mutableStateOf(torrentTrackers) + var p2pResolvedSourceUrl by mutableStateOf(null) + var activeStreamTitle by mutableStateOf(streamTitle) + var activeStreamSubtitle by mutableStateOf(streamSubtitle) + var activeProviderName by mutableStateOf(providerName) + var activeProviderAddonId by mutableStateOf(providerAddonId) + var currentStreamBingeGroup by mutableStateOf(initialBingeGroup) + var activeSeasonNumber by mutableStateOf(seasonNumber) + var activeEpisodeNumber by mutableStateOf(episodeNumber) + var activeEpisodeTitle by mutableStateOf(episodeTitle) + var activeEpisodeThumbnail by mutableStateOf(episodeThumbnail) + var activeVideoId by mutableStateOf(videoId) + var activeInitialPositionMs by mutableStateOf(initialPositionMs) + var activeInitialProgressFraction by mutableStateOf(initialProgressFraction) + var shouldPlay by mutableStateOf(true) + var resizeMode by mutableStateOf(playerSettingsUiState.resizeMode) + var layoutSize by mutableStateOf(IntSize.Zero) + var playbackSnapshot by mutableStateOf(PlayerPlaybackSnapshot()) + var playerController by mutableStateOf(null) + var playerControllerSourceUrl by mutableStateOf(null) + var errorMessage by mutableStateOf(null) + var isScrubbingTimeline by mutableStateOf(false) + var scrubbingPositionMs by mutableStateOf(null) + var pausedOverlayVisible by mutableStateOf(false) + var gestureFeedback by mutableStateOf(null) + var liveGestureFeedback by mutableStateOf(null) + var renderedGestureFeedback by mutableStateOf(null) + var lockedOverlayVisible by mutableStateOf(false) + var gestureMessageJob by mutableStateOf(null) + var accumulatedSeekResetJob by mutableStateOf(null) + var seekProgressSyncJob by mutableStateOf(null) + var accumulatedSeekState by mutableStateOf(null) + var initialLoadCompleted by mutableStateOf(false) + var speedBoostRestoreSpeed by mutableStateOf(null) + var isHoldToSpeedGestureActive by mutableStateOf(false) + var initialSeekApplied by mutableStateOf( + initialPositionMs <= 0L && ((initialProgressFraction ?: 0f) <= 0f), + ) + var lastProgressPersistEpochMs by mutableStateOf(0L) + var previousIsPlaying by mutableStateOf(false) + var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) + var scrobbleStartRequestGeneration by mutableStateOf(0L) + var pendingScrobbleStartAfterSeek by mutableStateOf(false) + var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) + var currentTraktScrobbleItem by mutableStateOf(null) + + var showSourcesPanel by mutableStateOf(false) + var showEpisodesPanel by mutableStateOf(false) + var showSubmitIntroModal by mutableStateOf(false) + var submitIntroSegmentType by mutableStateOf("intro") + var submitIntroStartTimeStr by mutableStateOf("00:00") + var submitIntroEndTimeStr by mutableStateOf("00:00") + var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState()) + var playerMetaVideos by mutableStateOf>(emptyList()) + var skipIntervals by mutableStateOf>(emptyList()) + var activeSkipInterval by mutableStateOf(null) + var skipIntervalDismissed by mutableStateOf(false) + var parentalWarnings by mutableStateOf>(emptyList()) + var showParentalGuide by mutableStateOf(false) + var parentalGuideHasShown by mutableStateOf(false) + var playbackStartedForParentalGuide by mutableStateOf(false) + var nextEpisodeInfo by mutableStateOf(null) + var showNextEpisodeCard by mutableStateOf(false) + var nextEpisodeAutoPlaySearching by mutableStateOf(false) + var nextEpisodeAutoPlaySourceName by mutableStateOf(null) + var nextEpisodeAutoPlayCountdown by mutableStateOf(null) + var nextEpisodeAutoPlayJob by mutableStateOf(null) + var pendingP2pSwitch by mutableStateOf(null) + + var showAudioModal by mutableStateOf(false) + var showSubtitleModal by mutableStateOf(false) + var showVideoSettingsModal by mutableStateOf(false) + var audioTracks by mutableStateOf>(emptyList()) + var subtitleTracks by mutableStateOf>(emptyList()) + var selectedAudioIndex by mutableStateOf(-1) + var selectedSubtitleIndex by mutableStateOf(-1) + var selectedAddonSubtitleId by mutableStateOf(null) + var useCustomSubtitles by mutableStateOf(false) + var preferredAudioSelectionApplied by mutableStateOf(false) + var preferredSubtitleSelectionApplied by mutableStateOf(false) + var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn) + var autoFetchedAddonSubtitlesForKey by mutableStateOf(null) + var trackPreferenceRestoreApplied by mutableStateOf(false) + var subtitleDelayMs by mutableStateOf(0) + var subtitleAutoSyncState by mutableStateOf(SubtitleAutoSyncUiState()) + + var lastSyncedSettingsResizeMode: PlayerResizeMode? = null + var lastResetPlaybackIdentity: String? = null + var lastResetVideoIdentity: String? = null +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt new file mode 100644 index 000000000..fa9c8afcb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt @@ -0,0 +1,63 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import kotlinx.coroutines.launch + +internal fun PlayerScreenRuntime.fetchAddonSubtitlesForActiveItem() { + val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return + val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return + SubtitleRepository.fetchAddonSubtitles(type, videoId) +} + +internal fun PlayerScreenRuntime.setSubtitleDelay(delayMs: Int) { + val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) + subtitleDelayMs = clamped + PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped) + playerController?.setSubtitleDelayMs(clamped) +} + +internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false) { + val subtitle = selectedAddonSubtitle ?: return + if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return + subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null) + scope.launch { + val result = runCatching { + val body = httpGetTextWithHeaders( + url = subtitle.url, + headers = sanitizePlaybackHeaders(activeSourceHeaders), + ) + PlayerSubtitleCueParser.parse(body, subtitle.url) + } + result.fold( + onSuccess = { cues -> + subtitleAutoSyncState = subtitleAutoSyncState.copy( + cues = cues, + isLoading = false, + errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null, + ) + }, + onFailure = { error -> + subtitleAutoSyncState = subtitleAutoSyncState.copy( + isLoading = false, + errorMessage = error.message ?: "Unable to load subtitle lines", + ) + }, + ) + } +} + +internal fun PlayerScreenRuntime.captureSubtitleAutoSyncTime() { + subtitleAutoSyncState = subtitleAutoSyncState.copy( + capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L), + errorMessage = null, + ) + loadSubtitleAutoSyncCues() +} + +internal fun PlayerScreenRuntime.applySubtitleAutoSyncCue(cue: SubtitleSyncCue) { + val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return + val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS) + .toInt() + .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) + setSubtitleDelay(newDelayMs) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt new file mode 100644 index 000000000..118baba9c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -0,0 +1,217 @@ +package com.nuvio.app.features.player + +internal val PlayerScreenRuntime.subtitleStyle: SubtitleStyleState + get() = playerSettingsUiState.subtitleStyle + +internal val PlayerScreenRuntime.activeAddonSubtitleType: String + get() = contentType ?: parentMetaType + +internal val PlayerScreenRuntime.addonSubtitleFetchKey: String? + get() = buildAddonSubtitleFetchKey( + addons = addonsUiState.addons, + type = activeAddonSubtitleType, + videoId = activeVideoId, + ) + +internal val PlayerScreenRuntime.visibleAddonSubtitles: List + get() = filterAddonSubtitlesForSettings( + subtitles = addonSubtitles, + settings = playerSettingsUiState, + selectedAddonSubtitleId = selectedAddonSubtitleId, + ) + +internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle? + get() = addonSubtitles.firstOrNull { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + +internal fun PlayerScreenRuntime.updateTrackPreference( + update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference, +) { + if (parentMetaId.isBlank()) return + val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference() + PlayerTrackPreferenceStorage.save(parentMetaId, update(current)) +} + +internal fun PlayerScreenRuntime.persistAudioPreference(track: AudioTrack?) { + updateTrackPreference { current -> + current.copy( + audioLanguage = track?.language, + audioName = track?.label, + audioTrackId = track?.id, + ) + } +} + +internal fun PlayerScreenRuntime.persistInternalSubtitlePreference(track: SubtitleTrack?) { + updateTrackPreference { current -> + current.copy( + subtitleType = if (track == null) { + PersistedSubtitleSelectionType.DISABLED + } else { + PersistedSubtitleSelectionType.INTERNAL + }, + subtitleLanguage = track?.language, + subtitleName = track?.label, + subtitleTrackId = track?.id, + addonSubtitleId = null, + addonSubtitleUrl = null, + addonSubtitleAddonName = null, + ) + } +} + +internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonSubtitle) { + updateTrackPreference { current -> + current.copy( + subtitleType = PersistedSubtitleSelectionType.ADDON, + subtitleLanguage = subtitle.language, + subtitleName = subtitle.display, + subtitleTrackId = null, + addonSubtitleId = subtitle.id, + addonSubtitleUrl = subtitle.url, + addonSubtitleAddonName = subtitle.addonName, + ) + } +} + +internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { + if (trackPreferenceRestoreApplied) return + val preference = PlayerTrackPreferenceStorage.load(parentMetaId) + if (preference == null) { + trackPreferenceRestoreApplied = true + return + } + + if ( + audioTracks.isNotEmpty() && + (!preference.audioTrackId.isNullOrBlank() || + !preference.audioLanguage.isNullOrBlank() || + !preference.audioName.isNullOrBlank()) + ) { + val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference) + if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) { + playerController?.selectAudioTrack(restoredAudioIndex) + selectedAudioIndex = restoredAudioIndex + } + preferredAudioSelectionApplied = true + } + + when (preference.subtitleType) { + PersistedSubtitleSelectionType.DISABLED -> { + playerController?.selectSubtitleTrack(-1) + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } + PersistedSubtitleSelectionType.INTERNAL -> { + if (subtitleTracks.isNotEmpty()) { + val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference) + if (restoredSubtitleIndex >= 0) { + if (useCustomSubtitles) { + playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) + } else { + playerController?.selectSubtitleTrack(restoredSubtitleIndex) + } + selectedSubtitleIndex = restoredSubtitleIndex + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } + } + } + PersistedSubtitleSelectionType.ADDON -> { + val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } + if (url != null) { + selectedAddonSubtitleId = preference.addonSubtitleId ?: url + selectedSubtitleIndex = -1 + useCustomSubtitles = true + playerController?.setSubtitleUri(url) + preferredSubtitleSelectionApplied = true + } + } + } + + trackPreferenceRestoreApplied = true +} + +internal fun PlayerScreenRuntime.refreshTracks() { + val ctrl = playerController ?: return + audioTracks = ctrl.getAudioTracks() + subtitleTracks = ctrl.getSubtitleTracks() + val selectedAudio = audioTracks.firstOrNull { it.isSelected } + if (selectedAudio != null) selectedAudioIndex = selectedAudio.index + val selectedSub = subtitleTracks.firstOrNull { it.isSelected } + if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index + + restorePersistedTrackPreferenceIfNeeded() + + if (!preferredAudioSelectionApplied) { + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + if (preferredAudioTargets.isEmpty()) { + preferredAudioSelectionApplied = true + } else if (audioTracks.isNotEmpty()) { + val preferredAudioIndex = findPreferredTrackIndex( + tracks = audioTracks, + targets = preferredAudioTargets, + language = { track -> track.language }, + ) + if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { + playerController?.selectAudioTrack(preferredAudioIndex) + selectedAudioIndex = preferredAudioIndex + } + preferredAudioSelectionApplied = true + } + } + + if (!preferredSubtitleSelectionApplied) { + val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { + SubtitleLanguageOption.FORCED + } else { + playerSettingsUiState.preferredSubtitleLanguage + }, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + + if (preferredSubtitleTargets.isEmpty()) { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } else if (subtitleTracks.isNotEmpty()) { + val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( + tracks = subtitleTracks, + targets = preferredSubtitleTargets, + ) + if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { + playerController?.selectSubtitleTrack(preferredSubtitleIndex) + selectedSubtitleIndex = preferredSubtitleIndex + selectedAddonSubtitleId = null + useCustomSubtitles = false + } else if ( + preferredSubtitleIndex < 0 && + (subtitleStyle.useForcedSubtitles || + normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == + SubtitleLanguageOption.FORCED) + ) { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + } + preferredSubtitleSelectionApplied = true + } + } +} 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 new file mode 100644 index 000000000..6319676f4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -0,0 +1,523 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.p2p.formatP2pMegabytes +import com.nuvio.app.features.p2p.formatP2pSpeed +import com.nuvio.app.isIos +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* + +@Composable +internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { + val runtime = this + val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback + val isP2pPlaybackActive = activeTorrentInfoHash != null + val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming + val p2pPeerInfo = p2pStats?.let { stats -> + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info, + stats.seeds, + stats.peers, + ) + } + val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) } + val p2pInitialLoadingMessage = when { + !isP2pPlaybackActive || initialLoadCompleted -> null + p2pStreamingState is P2pStreamingState.Connecting -> { + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers, + ) + } + p2pStats != null -> { + if (p2pSettingsUiState.hideTorrentStats) { + null + } else { + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status, + formatP2pMegabytes(p2pStats.preloadedBytes), + p2pPeerInfo.orEmpty(), + p2pDownloadSpeed.orEmpty(), + ) + } + } + else -> org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine, + ) + } + val p2pInitialLoadingProgress = when { + !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null + else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f) + } + val showP2pRebufferStats = isP2pPlaybackActive && + initialLoadCompleted && + playbackSnapshot.isLoading && + p2pStats != null && + !p2pSettingsUiState.hideTorrentStats + val p2pRebufferMessage = when { + !showP2pRebufferStats -> null + else -> { + val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L) + .coerceAtLeast(0L) + "${bufferedSeconds}s buffered · ${p2pPeerInfo.orEmpty()} · ${p2pDownloadSpeed.orEmpty()}" + } + } + val p2pRebufferProgress = when { + !showP2pRebufferStats -> null + else -> { + val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f) + .coerceAtLeast(0f) + (bufferedSeconds / 10f).coerceIn(0f, 1f) + } + } + val gestureCallbacks = rememberSurfaceGestureCallbacks() + + Box( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { layoutSize = it } + .playerSurfaceTapGestures( + layoutSize = layoutSize, + playerControlsLockedState = gestureCallbacks.playerControlsLocked, + onSurfaceTap = gestureCallbacks.onSurfaceTap, + onSurfaceDoubleTap = gestureCallbacks.onSurfaceDoubleTap, + activateHoldToSpeedState = gestureCallbacks.activateHoldToSpeed, + deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed, + revealLockedOverlayState = gestureCallbacks.revealLockedOverlay, + ) + .playerSurfaceDragGestures( + gestureController = gestureController, + layoutSize = layoutSize, + sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx, + playerControlsLockedState = gestureCallbacks.playerControlsLocked, + isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive, + currentPositionMsState = gestureCallbacks.currentPositionMs, + currentDurationMsState = gestureCallbacks.currentDurationMs, + deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed, + showHorizontalSeekPreviewState = gestureCallbacks.showHorizontalSeekPreview, + showBrightnessFeedbackState = gestureCallbacks.showBrightnessFeedback, + showVolumeFeedbackState = gestureCallbacks.showVolumeFeedback, + clearLiveGestureFeedbackState = gestureCallbacks.clearLiveGestureFeedback, + revealLockedOverlayState = gestureCallbacks.revealLockedOverlay, + commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, + ), + ) { + val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl + if (playerSurfaceSourceUrl != null) { + PlatformPlayerSurface( + sourceUrl = playerSurfaceSourceUrl, + sourceAudioUrl = activeSourceAudioUrl, + sourceHeaders = activeSourceHeaders, + sourceResponseHeaders = activeSourceResponseHeaders, + modifier = Modifier.fillMaxSize(), + playWhenReady = shouldPlay, + resizeMode = resizeMode, + onControllerReady = { controller -> + playerController = controller + playerControllerSourceUrl = activeSourceUrl + }, + onSnapshot = { snapshot -> + playbackSnapshot = snapshot + if (!snapshot.isLoading) initialLoadCompleted = true + if (snapshot.isEnded) { + shouldPlay = false + controlsVisible = !playerControlsLocked + } + }, + onError = { message -> + errorMessage = message + if (message != null) { + controlsVisible = !playerControlsLocked + removeFailedStreamFromCache() + } + }, + ) + } + + AnimatedVisibility( + visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked, + enter = fadeIn(animationSpec = tween(durationMillis = 220)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)), + ) { + PauseMetadataOverlay( + title = title, + logo = logo, + isEpisode = isEpisode, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + pauseDescription = pauseDescription ?: activeStreamSubtitle, + providerName = activeProviderName, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + ) + } + + RenderPlayerControls(displayedPositionMs = displayedPositionMs, isEpisode = isEpisode) + RenderPlaybackOverlays( + runtime = runtime, + displayedPositionMs = displayedPositionMs, + currentGestureFeedback = currentGestureFeedback, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + ) + RenderPlayerModals(displayedPositionMs = displayedPositionMs) + } +} + +@Composable +private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { + AnimatedVisibility( + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + enter = fadeIn(), + exit = fadeOut(), + ) { + PlayerControlsShell( + title = title, + streamTitle = activeStreamTitle, + providerName = activeProviderName, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + resizeMode = resizeMode, + isLocked = playerControlsLocked, + showPlaybackControls = controlsVisible, + onLockToggle = { + if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls() + }, + onBack = { + flushWatchProgress() + args.onBack() + }, + onTogglePlayback = { togglePlayback() }, + onSeekBack = { seekBy(-10_000L) }, + onSeekForward = { seekBy(10_000L) }, + onResizeModeClick = { cycleResizeMode() }, + onSpeedClick = { cyclePlaybackSpeed() }, + onSubtitleClick = { + refreshTracks() + showSubtitleModal = true + }, + onAudioClick = { + refreshTracks() + showAudioModal = true + }, + onVideoSettingsClick = if (isIos) { + { + showVideoSettingsModal = true + controlsVisible = true + } + } else { + null + }, + onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, + onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, + onOpenInExternalPlayer = args.onOpenInExternalPlayer?.let { openExternal -> + { + val loadedSubtitles = addonSubtitles + .takeIf { it.isNotEmpty() } + ?.map { sub -> + SubtitleInput( + url = sub.url, + name = buildString { + if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ") + append(sub.display) + }, + lang = sub.language, + ) + } + openExternal( + ExternalPlayerPlaybackRequest( + sourceUrl = activeSourceUrl, + title = title, + streamTitle = activeStreamTitle, + sourceHeaders = activeSourceHeaders, + resumePositionMs = playbackSnapshot.positionMs, + subtitles = loadedSubtitles, + ), + ) + } + }, + onSubmitIntroClick = if ( + isSeries && + playerSettingsUiState.introSubmitEnabled && + playerSettingsUiState.introDbApiKey.isNotBlank() + ) { + { showSubmitIntroModal = true } + } else { + null + }, + parentalWarnings = parentalWarnings, + showParentalGuide = showParentalGuide, + onParentalGuideAnimationComplete = { showParentalGuide = false }, + onScrubChange = { positionMs -> + isScrubbingTimeline = true + scrubbingPositionMs = positionMs + }, + onScrubFinished = { positionMs -> + isScrubbingTimeline = false + scrubbingPositionMs = null + playerController?.seekTo(positionMs) + scheduleProgressSyncAfterSeek() + }, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun BoxScope.RenderPlaybackOverlays( + runtime: PlayerScreenRuntime, + displayedPositionMs: Long, + currentGestureFeedback: GestureFeedbackState?, + p2pInitialLoadingMessage: String?, + p2pInitialLoadingProgress: Float?, + showP2pRebufferStats: Boolean, + p2pRebufferMessage: String?, + p2pRebufferProgress: Float?, +) { + 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, + onDismissError = { + flushWatchProgress() + args.onBack() + }, + ) + } +} + +@Composable +private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { + PlayerScreenModalHosts( + pendingP2pSwitch = pendingP2pSwitch, + onPendingP2pSwitchChanged = { pendingP2pSwitch = it }, + onP2pEpisodeStreamSelected = { stream, episode, isAutoPlay -> + switchToP2pEpisodeStream(stream, episode, isAutoPlay) + }, + onP2pSourceStreamSelected = { stream -> switchToP2pSourceStream(stream) }, + onNextEpisodeAutoPlaySearchingChanged = { nextEpisodeAutoPlaySearching = it }, + onNextEpisodeAutoPlayCountdownChanged = { nextEpisodeAutoPlayCountdown = it }, + onNextEpisodeAutoPlaySourceNameChanged = { nextEpisodeAutoPlaySourceName = it }, + showAudioModal = showAudioModal, + audioTracks = audioTracks, + selectedAudioIndex = selectedAudioIndex, + onAudioTrackSelected = { index -> + selectedAudioIndex = index + persistAudioPreference(audioTracks.firstOrNull { it.index == index }) + playerController?.selectAudioTrack(index) + scope.launch { + kotlinx.coroutines.delay(200) + showAudioModal = false + } + }, + onAudioModalDismissed = { showAudioModal = false }, + showSubtitleModal = showSubtitleModal, + activeSubtitleTab = activeSubtitleTab, + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + addonSubtitles = visibleAddonSubtitles, + selectedAddonSubtitleId = selectedAddonSubtitleId, + isLoadingAddonSubtitles = isLoadingAddonSubtitles, + subtitleStyle = subtitleStyle, + subtitleDelayMs = subtitleDelayMs, + selectedAddonSubtitle = selectedAddonSubtitle, + subtitleAutoSyncState = subtitleAutoSyncState, + onSubtitleTabSelected = { activeSubtitleTab = it }, + onBuiltInSubtitleTrackSelected = { index -> + val wasCustom = useCustomSubtitles + selectedSubtitleIndex = index + selectedAddonSubtitleId = null + useCustomSubtitles = false + persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) + if (wasCustom) { + playerController?.clearExternalSubtitleAndSelect(index) + } else { + playerController?.selectSubtitleTrack(index) + } + }, + onAddonSubtitleSelected = { addon -> + selectedAddonSubtitleId = addon.id + selectedSubtitleIndex = -1 + useCustomSubtitles = true + persistAddonSubtitlePreference(addon) + playerController?.setSubtitleUri(addon.url) + }, + onFetchAddonSubtitles = { fetchAddonSubtitlesForActiveItem() }, + onSubtitleStyleChanged = PlayerSettingsRepository::setSubtitleStyle, + onSubtitleDelayChanged = { delayMs -> setSubtitleDelay(delayMs) }, + onSubtitleDelayReset = { setSubtitleDelay(0) }, + onAutoSyncCapture = { captureSubtitleAutoSyncTime() }, + onAutoSyncCueSelected = { cue -> applySubtitleAutoSyncCue(cue) }, + onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) }, + onSubtitleModalDismissed = { showSubtitleModal = false }, + showVideoSettingsModal = showVideoSettingsModal, + playerSettings = playerSettingsUiState, + onVideoSettingsChanged = { + playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) + }, + onVideoSettingsModalDismissed = { showVideoSettingsModal = false }, + showSourcesPanel = showSourcesPanel, + sourceStreamsState = sourceStreamsState, + activeSourceUrl = activeSourceUrl, + activeStreamTitle = activeStreamTitle, + onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter, + onSourceStreamSelected = { stream -> switchToSource(stream) }, + onReloadSources = { + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + onSourcesPanelDismissed = { + showSourcesPanel = false + controlsVisible = true + }, + isSeries = isSeries, + showEpisodesPanel = showEpisodesPanel, + allEpisodes = playerMetaVideos, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + activeSeasonNumber = activeSeasonNumber, + activeEpisodeNumber = activeEpisodeNumber, + watchProgressByVideoId = watchProgressUiState.byVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + episodeStreamsPanelState = episodeStreamsPanelState, + episodeStreamsRepoState = episodeStreamsRepoState, + onEpisodeSelectedForDownload = { episode -> + selectDownloadedEpisodeForPlayback( + parentMetaId = parentMetaId, + episode = episode, + onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) }, + ) + }, + onEpisodeStreamsRequested = { episode -> + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + ) + episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode) + }, + onEpisodeStreamFilterSelected = PlayerStreamsRepository::selectEpisodeStreamsFilter, + onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) }, + onBackToEpisodes = { + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + }, + onReloadEpisodeStreams = { + val episode = episodeStreamsPanelState.selectedEpisode + if (episode != null) { + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + } + }, + onEpisodesPanelDismissed = { + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + controlsVisible = true + }, + showSubmitIntroModal = showSubmitIntroModal, + activeVideoId = activeVideoId, + metaUiState = metaUiState, + displayedPositionMs = displayedPositionMs, + submitIntroSegmentType = submitIntroSegmentType, + onSubmitIntroSegmentTypeChanged = { submitIntroSegmentType = it }, + submitIntroStartTimeStr = submitIntroStartTimeStr, + onSubmitIntroStartTimeChanged = { submitIntroStartTimeStr = it }, + submitIntroEndTimeStr = submitIntroEndTimeStr, + onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it }, + onSubmitIntroDismissed = { showSubmitIntroModal = false }, + onSubmitIntroSuccess = { + submitIntroStartTimeStr = "00:00" + submitIntroEndTimeStr = "00:00" + submitIntroSegmentType = "intro" + showSubmitIntroModal = false + }, + ) +} 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 new file mode 100644 index 000000000..83c7944ad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt @@ -0,0 +1,59 @@ +package com.nuvio.app.features.player + +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.streams.StreamItem + +internal const val PlaybackProgressPersistIntervalMs = 60_000L +internal const val PlayerDoubleTapSeekStepMs = 10_000L +internal const val PlayerDoubleTapSeekResetDelayMs = 800L +internal const val PlayerLockedOverlayDurationMs = 2_000L +internal const val PlayerLeftGestureBoundary = 0.4f +internal const val PlayerRightGestureBoundary = 0.6f +internal const val PlayerVerticalGestureSensitivity = 0.65f +internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f +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 NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L + +internal val PlayerSideGestureSystemEdgeExclusion = 72.dp +internal val PlayerSliderOverlayGap = 12.dp +internal val PlayerTimeRowHeight = 36.dp +internal val PlayerActionRowHeight = 50.dp + +internal fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) = + metrics.sliderBottomOffset + + metrics.sliderTouchHeight + + PlayerTimeRowHeight + + PlayerActionRowHeight + + PlayerSliderOverlayGap + +internal enum class PlayerSideGesture { + Brightness, + Volume, +} + +internal enum class PlayerSeekDirection { + Backward, + Forward, +} + +internal enum class PlayerGestureMode { + HorizontalSeek, + Brightness, + Volume, +} + +internal data class PlayerAccumulatedSeekState( + val direction: PlayerSeekDirection, + val baselinePositionMs: Long, + val amountMs: Long, +) + +internal data class PendingPlayerP2pSwitch( + val stream: StreamItem, + val episode: MetaVideo?, + val isAutoPlay: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt new file mode 100644 index 000000000..de46194de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.math.roundToLong + +internal fun Modifier.playerSurfaceTapGestures( + layoutSize: IntSize, + playerControlsLockedState: State, + onSurfaceTap: State<(Offset) -> Unit>, + onSurfaceDoubleTap: State<(Offset) -> Unit>, + activateHoldToSpeedState: State<() -> Unit>, + deactivateHoldToSpeedState: State<() -> Unit>, + revealLockedOverlayState: State<() -> Unit>, +): Modifier = + pointerInput(layoutSize) { + detectTapGestures( + onPress = { + tryAwaitRelease() + deactivateHoldToSpeedState.value() + }, + onTap = { offset -> onSurfaceTap.value(offset) }, + onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, + onLongPress = { + if (playerControlsLockedState.value) { + revealLockedOverlayState.value() + } else { + activateHoldToSpeedState.value() + } + }, + ) + } + +internal fun Modifier.playerSurfaceDragGestures( + gestureController: PlayerGestureController?, + layoutSize: IntSize, + sideGestureSystemEdgeExclusionPx: Float, + playerControlsLockedState: State, + isHoldToSpeedGestureActiveState: State, + currentPositionMsState: State, + currentDurationMsState: State, + deactivateHoldToSpeedState: State<() -> Unit>, + showHorizontalSeekPreviewState: State<(Long, Long) -> Unit>, + showBrightnessFeedbackState: State<(Float) -> Unit>, + showVolumeFeedbackState: State<(PlayerAudioLevel) -> Unit>, + clearLiveGestureFeedbackState: State<() -> Unit>, + revealLockedOverlayState: State<() -> Unit>, + commitHorizontalSeekState: State<(Long) -> Unit>, +): Modifier = + pointerInput(gestureController, layoutSize, sideGestureSystemEdgeExclusionPx) { + awaitEachGesture { + val down = awaitFirstDown() + if (playerControlsLockedState.value) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + change.consume() + } + return@awaitEachGesture + } + val controller = gestureController + val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture + val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture + val sideGestureEdgeExclusionPx = sideGestureSystemEdgeExclusionPx + .coerceAtMost(height * 0.25f) + val isInSideGestureSystemEdge = + down.position.y <= sideGestureEdgeExclusionPx || + down.position.y >= height - sideGestureEdgeExclusionPx + val region = when { + isInSideGestureSystemEdge -> null + down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness + down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume + else -> null + } + + val initialBrightness = if (region == PlayerSideGesture.Brightness) { + controller?.currentBrightness() + } else { + null + } + val initialVolume = if (region == PlayerSideGesture.Volume) { + controller?.currentVolume() + } else { + null + } + + var totalDx = 0f + var totalDy = 0f + var gestureMode: PlayerGestureMode? = null + var verticalGestureActivationDy = 0f + val horizontalSeekBaselineMs = currentPositionMsState.value + var horizontalSeekPreviewMs = horizontalSeekBaselineMs + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + + val delta = change.position - change.previousPosition + totalDx += delta.x + totalDy += delta.y + + if (gestureMode == null) { + val holdToSpeedActive = isHoldToSpeedGestureActiveState.value + val verticalGestureActivationSlop = maxOf( + viewConfiguration.touchSlop * PlayerVerticalGestureTouchSlopMultiplier, + height * PlayerVerticalGestureMinHeightFraction, + ) + val horizontalDominant = + !holdToSpeedActive && + abs(totalDx) > viewConfiguration.touchSlop && + abs(totalDx) > abs(totalDy) + val verticalDominant = + !holdToSpeedActive && + abs(totalDy) > verticalGestureActivationSlop && + abs(totalDy) > abs(totalDx) * PlayerVerticalGestureDominanceRatio + + gestureMode = when { + horizontalDominant -> { + deactivateHoldToSpeedState.value() + PlayerGestureMode.HorizontalSeek + } + + verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> { + verticalGestureActivationDy = totalDy + PlayerGestureMode.Brightness + } + + verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> { + verticalGestureActivationDy = totalDy + PlayerGestureMode.Volume + } + + else -> null + } + + if (gestureMode == null) { + continue + } + } + + when (gestureMode) { + PlayerGestureMode.HorizontalSeek -> { + val sensitivitySeconds = when { + currentDurationMsState.value >= 3_600_000L -> 120f + currentDurationMsState.value >= 1_800_000L -> 90f + else -> 60f + } + val previewOffsetMs = + ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong() + val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs + horizontalSeekPreviewMs = currentDurationMsState.value + .takeIf { it > 0L } + ?.let { durationMs -> + unclampedPreviewMs.coerceIn(0L, durationMs) + } + ?: unclampedPreviewMs.coerceAtLeast(0L) + showHorizontalSeekPreviewState.value( + horizontalSeekPreviewMs, + horizontalSeekBaselineMs, + ) + } + + PlayerGestureMode.Brightness -> { + val activeTotalDy = totalDy - verticalGestureActivationDy + val gestureDeltaFraction = + (-activeTotalDy / height) * PlayerVerticalGestureSensitivity + controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction) + ?.let(showBrightnessFeedbackState.value) + } + + PlayerGestureMode.Volume -> { + val activeTotalDy = totalDy - verticalGestureActivationDy + val gestureDeltaFraction = + (-activeTotalDy / height) * PlayerVerticalGestureSensitivity + controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) + ?.let(showVolumeFeedbackState.value) + } + } + change.consume() + } + + if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) { + commitHorizontalSeekState.value(horizontalSeekPreviewMs) + clearLiveGestureFeedbackState.value() + } + } + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt new file mode 100644 index 000000000..5bd895def --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.addons.AddonResource +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons + +internal fun buildAddonSubtitleFetchKey( + addons: List, + type: String?, + videoId: String?, +): String? { + val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null + val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null + val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + val supportsSubtitles = manifest.resources.any { resource -> + resource.isCompatibleSubtitleResource( + type = normalizedType, + videoId = normalizedVideoId, + ) + } + if (!supportsSubtitles) return@mapNotNull null + "${manifest.id}:${manifest.transportUrl}" + } + + if (compatibleSubtitleAddons.isEmpty()) return null + return buildString { + append(normalizedType) + append('|') + append(normalizedVideoId) + append('|') + append(compatibleSubtitleAddons.sorted().joinToString("|")) + } +} + +internal fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean { + val isSubtitleResource = name.equals("subtitles", ignoreCase = true) || + name.equals("subtitle", ignoreCase = true) + if (!isSubtitleResource) return false + + val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type + val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) } + if (!typeMatches) return false + + return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } +} + +internal fun findPreferredTrackIndex( + tracks: List, + targets: List, + language: (T) -> String?, +): Int { + if (targets.isEmpty()) return -1 + for (target in targets) { + val matchIndex = tracks.indexOfFirst { track -> + languageMatchesPreference( + trackLanguage = language(track), + targetLanguage = target, + ) + } + if (matchIndex >= 0) { + return matchIndex + } + } + return -1 +} + +internal fun findPreferredSubtitleTrackIndex( + tracks: List, + targets: List, +): Int { + if (targets.isEmpty()) return -1 + + for ((targetPosition, target) in targets.withIndex()) { + val normalizedTarget = normalizeLanguageCode(target) ?: continue + if (normalizedTarget == SubtitleLanguageOption.FORCED) { + val forcedIndex = tracks.indexOfFirst { it.isForced } + if (forcedIndex >= 0) return forcedIndex + if (targetPosition == 0) return -1 + continue + } + + val matchIndex = tracks.indexOfFirst { track -> + languageMatchesPreference( + trackLanguage = track.language, + targetLanguage = normalizedTarget, + ) + } + if (matchIndex >= 0) return matchIndex + } + + return -1 +} + +internal fun filterAddonSubtitlesForSettings( + subtitles: List, + settings: PlayerSettingsUiState, + selectedAddonSubtitleId: String?, +): List { + val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages || + settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY + if (!shouldFilter) return subtitles + + val targets = preferredSubtitleTargetsForSettings(settings) + if (targets.isEmpty()) { + return subtitles.filter { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + } + + val filtered = subtitles.filter { subtitle -> + subtitle.id == selectedAddonSubtitleId || + subtitle.url == selectedAddonSubtitleId || + targets.any { target -> + languageMatchesPreference( + trackLanguage = subtitle.language, + targetLanguage = target, + ) + } + } + return filtered +} + +internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List { + val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) { + SubtitleLanguageOption.FORCED + } else { + settings.preferredSubtitleLanguage + } + return resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = preferredLanguage, + secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ).filterNot { it == SubtitleLanguageOption.FORCED } +} + +internal fun findPersistedAudioTrackIndex( + tracks: List, + preference: PersistedPlayerTrackPreference, +): Int { + preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> + tracks.firstOrNull { it.id == trackId }?.let { return it.index } + } + preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language -> + tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } + } + preference.audioName?.takeIf { it.isNotBlank() }?.let { name -> + tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } + } + return -1 +} + +internal fun findPersistedSubtitleTrackIndex( + tracks: List, + preference: PersistedPlayerTrackPreference, +): Int { + preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> + tracks.firstOrNull { it.id == trackId }?.let { return it.index } + } + preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language -> + tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } + } + preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name -> + tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } + } + return -1 +} diff --git a/vendor/TorrServer b/vendor/TorrServer new file mode 160000 index 000000000..b79dbaf99 --- /dev/null +++ b/vendor/TorrServer @@ -0,0 +1 @@ +Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a