ref(player) : into smaller chunks

This commit is contained in:
tapframe 2026-06-06 23:04:22 +05:30
parent 3de5fc32a3
commit 939bca4848
18 changed files with 3730 additions and 3094 deletions

View file

@ -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<MetaVideo>,
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<Unit>()
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>): 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>): 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)
}
}
}

View file

@ -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,
)
}
}

View file

@ -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<String, String>,
val sourceResponseHeaders: Map<String, String>,
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<String>,
val initialPositionMs: Long,
val initialProgressFraction: Float?,
)

View file

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

View file

@ -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<AudioTrack>,
selectedAudioIndex: Int,
onAudioTrackSelected: (Int) -> Unit,
onAudioModalDismissed: () -> Unit,
showSubtitleModal: Boolean,
activeSubtitleTab: SubtitleTab,
subtitleTracks: List<SubtitleTrack>,
selectedSubtitleIndex: Int,
addonSubtitles: List<AddonSubtitle>,
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<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
activeSeasonNumber: Int?,
activeEpisodeNumber: Int?,
watchProgressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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
}

View file

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

View file

@ -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<Boolean>,
val playerControlsLocked: State<Boolean>,
val currentPositionMs: State<Long>,
val currentDurationMs: State<Long>,
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()
},
)
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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<String, String> get() = args.sourceHeaders
val sourceResponseHeaders: Map<String, String> 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<String> 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<AddonSubtitle> = 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<String?>(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<PlayerEngineController?>(null)
var playerControllerSourceUrl by mutableStateOf<String?>(null)
var errorMessage by mutableStateOf<String?>(null)
var isScrubbingTimeline by mutableStateOf(false)
var scrubbingPositionMs by mutableStateOf<Long?>(null)
var pausedOverlayVisible by mutableStateOf(false)
var gestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var liveGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var renderedGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var lockedOverlayVisible by mutableStateOf(false)
var gestureMessageJob by mutableStateOf<Job?>(null)
var accumulatedSeekResetJob by mutableStateOf<Job?>(null)
var seekProgressSyncJob by mutableStateOf<Job?>(null)
var accumulatedSeekState by mutableStateOf<PlayerAccumulatedSeekState?>(null)
var initialLoadCompleted by mutableStateOf(false)
var speedBoostRestoreSpeed by mutableStateOf<Float?>(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<TraktScrobbleItem?>(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<List<MetaVideo>>(emptyList())
var skipIntervals by mutableStateOf<List<SkipInterval>>(emptyList())
var activeSkipInterval by mutableStateOf<SkipInterval?>(null)
var skipIntervalDismissed by mutableStateOf(false)
var parentalWarnings by mutableStateOf<List<ParentalWarning>>(emptyList())
var showParentalGuide by mutableStateOf(false)
var parentalGuideHasShown by mutableStateOf(false)
var playbackStartedForParentalGuide by mutableStateOf(false)
var nextEpisodeInfo by mutableStateOf<NextEpisodeInfo?>(null)
var showNextEpisodeCard by mutableStateOf(false)
var nextEpisodeAutoPlaySearching by mutableStateOf(false)
var nextEpisodeAutoPlaySourceName by mutableStateOf<String?>(null)
var nextEpisodeAutoPlayCountdown by mutableStateOf<Int?>(null)
var nextEpisodeAutoPlayJob by mutableStateOf<Job?>(null)
var pendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
var showAudioModal by mutableStateOf(false)
var showSubtitleModal by mutableStateOf(false)
var showVideoSettingsModal by mutableStateOf(false)
var audioTracks by mutableStateOf<List<AudioTrack>>(emptyList())
var subtitleTracks by mutableStateOf<List<SubtitleTrack>>(emptyList())
var selectedAudioIndex by mutableStateOf(-1)
var selectedSubtitleIndex by mutableStateOf(-1)
var selectedAddonSubtitleId by mutableStateOf<String?>(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<String?>(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
}

View file

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

View file

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

View file

@ -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
},
)
}

View file

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

View file

@ -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<Boolean>,
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<Boolean>,
isHoldToSpeedGestureActiveState: State<Boolean>,
currentPositionMsState: State<Long>,
currentDurationMsState: State<Long>,
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()
}
}
}

View file

@ -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<ManagedAddon>,
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 <T> findPreferredTrackIndex(
tracks: List<T>,
targets: List<String>,
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<SubtitleTrack>,
targets: List<String>,
): 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<AddonSubtitle>,
settings: PlayerSettingsUiState,
selectedAddonSubtitleId: String?,
): List<AddonSubtitle> {
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<String> {
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<AudioTrack>,
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<SubtitleTrack>,
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
}

1
vendor/TorrServer vendored Submodule

@ -0,0 +1 @@
Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a