feat: prefer downloaded files over stream selection

This commit is contained in:
tapframe 2026-04-11 21:51:06 +05:30
parent 7c2cad51b9
commit 616eec757b
3 changed files with 255 additions and 66 deletions

View file

@ -88,6 +88,7 @@ import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.downloads.DownloadsScreen
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaDetailsScreen
@ -495,56 +496,137 @@ private fun MainAppContent(
}
}
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
parentMetaId: String,
parentMetaType: String,
title: String,
logo: String?,
poster: String?,
background: String?,
seasonNumber: Int?,
episodeNumber: Int?,
episodeTitle: String?,
episodeThumbnail: String?,
pauseDescription: String?,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
manualSelection: Boolean,
startFromBeginning: Boolean,
) {
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
if (!manualSelection) {
val downloadedItem = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
videoId = videoId,
)
val localSourceUrl = downloadedItem?.localFileUri
if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put(
PlayerLaunch(
title = title,
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamTitle = downloadedItem.streamTitle.ifBlank { title },
streamSubtitle = downloadedItem.streamSubtitle,
pauseDescription = pauseDescription,
providerName = downloadedItem.providerName.ifBlank { "Downloaded" },
providerAddonId = downloadedItem.providerAddonId,
contentType = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction,
),
)
navController.navigate(PlayerRoute(launchId = launchId))
return
}
}
val streamContextId = pauseDescription
?.takeIf { it.isNotBlank() }
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
navController.navigate(
StreamRoute(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamContextId = streamContextId,
resumePositionMs = if (startFromBeginning) 0L else resumePositionMs,
resumeProgressFraction = targetResumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
),
)
}
val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
val streamContextId = pauseDescription
?.takeIf { it.isNotBlank() }
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
navController.navigate(
StreamRoute(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamContextId = streamContextId,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
)
launchPlaybackWithDownloadPreference(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = false,
startFromBeginning = false,
)
}
val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit =
{ type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs ->
val streamContextId = pauseDescription
?.takeIf { it.isNotBlank() }
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
navController.navigate(
StreamRoute(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
streamContextId = streamContextId,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = true,
)
launchPlaybackWithDownloadPreference(
type = type,
videoId = videoId,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = resumePositionMs,
resumeProgressFraction = null,
manualSelection = true,
startFromBeginning = false,
)
}
@ -579,29 +661,24 @@ private fun MainAppContent(
}
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
val streamContextId = item.pauseDescription
?.takeIf { it.isNotBlank() }
?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) }
navController.navigate(
StreamRoute(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
streamContextId = streamContextId,
resumePositionMs = if (startFromBeginning) 0L else item.resumePositionMs,
resumeProgressFraction = if (startFromBeginning) null else item.resumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
),
launchPlaybackWithDownloadPreference(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
)
}

View file

@ -34,6 +34,46 @@ object DownloadsRepository {
_uiState.value = DownloadsUiState()
}
fun findPlayableDownloadByVideoId(videoId: String?): DownloadItem? {
ensureLoaded()
val normalizedVideoId = videoId?.trim().orEmpty()
if (normalizedVideoId.isBlank()) return null
return _uiState.value.items.firstOrNull { item ->
item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank()
}
}
fun findPlayableDownload(
parentMetaId: String,
seasonNumber: Int? = null,
episodeNumber: Int? = null,
videoId: String? = null,
): DownloadItem? {
ensureLoaded()
val items = _uiState.value.items
val normalizedParentMetaId = parentMetaId.trim()
findPlayableDownloadByVideoId(videoId)?.let { return it }
return if (seasonNumber != null && episodeNumber != null) {
items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == seasonNumber &&
item.episodeNumber == episodeNumber &&
item.isPlayable &&
!item.localFileUri.isNullOrBlank()
}
} else {
items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == null &&
item.episodeNumber == null &&
item.isPlayable &&
!item.localFileUri.isNullOrBlank()
}
}
}
fun enqueueFromStream(
contentType: String,
videoId: String,

View file

@ -40,6 +40,8 @@ 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.MetaVideo
import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.player.skip.NextEpisodeCard
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
@ -793,11 +795,70 @@ fun PlayerScreen(
controlsVisible = true
}
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
val localFileUri = downloadItem.localFileUri ?: return
showNextEpisodeCard = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = false
nextEpisodeAutoPlaySourceName = null
nextEpisodeAutoPlayCountdown = null
PlayerStreamsRepository.clearEpisodeStreams()
flushWatchProgress()
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 { "Downloaded" }
activeProviderAddonId = downloadItem.providerAddonId
currentStreamBingeGroup = null
activeSeasonNumber = episode.season
activeEpisodeNumber = episode.episode
activeEpisodeTitle = episode.title
activeEpisodeThumbnail = episode.thumbnail
activeVideoId = resolvedVideoId
activeInitialPositionMs = epResumePositionMs
activeInitialProgressFraction = epResumeFraction
controlsVisible = true
}
fun playNextEpisode() {
val nextVideoId = nextEpisodeInfo?.videoId ?: return
val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return
if (nextEpisodeInfo?.hasAired != true) return
val downloadedNextEpisode = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = nextVideo.season,
episodeNumber = nextVideo.episode,
videoId = nextVideo.id,
)
if (downloadedNextEpisode != null) {
switchToDownloadedEpisode(downloadedNextEpisode, nextVideo)
return
}
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = true
nextEpisodeAutoPlaySourceName = null
@ -1596,6 +1657,17 @@ fun PlayerScreen(
),
onSeasonSelected = { /* season tab change handled internally */ },
onEpisodeSelected = { episode ->
val downloadedEpisode = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
videoId = episode.id,
)
if (downloadedEpisode != null) {
switchToDownloadedEpisode(downloadedEpisode, episode)
return@PlayerEpisodesPanel
}
val type = contentType ?: parentMetaType
PlayerStreamsRepository.loadEpisodeStreams(
type = type,