diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2122c545..cd3e7f69 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index c53c50ef..746ffbd8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e8a44966..d1ff0e02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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,