mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-27 01:22:18 +00:00
ref(player) : into smaller chunks
This commit is contained in:
parent
3de5fc32a3
commit
939bca4848
18 changed files with 3730 additions and 3094 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1
vendor/TorrServer
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a
|
||||
Loading…
Reference in a new issue