From 311968075d8a065124e963bcae7b04857eacd4ac Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:51:34 +0300 Subject: [PATCH] Unify TV and mobile Detail navigation decisions onto DetailNavigationLogic Extracts the "which stream/episode plays, is it cs3, how much resume progress" decision into a pure DetailNavigationLogic object in commonMain. DetailStore (used by mobile's shared CMP Detail screen) now delegates to it instead of carrying its own copy. DetailRoute.kt (Android TV's legacy Detail route - mobile no longer reaches this file after the earlier Detail route migration) now calls the same DetailNavigationLogic functions instead of its own hand- rolled duplicate of the same branching, so both platforms make this decision through one code path. TvDetailScreenContent's rendering is untouched - it still reads DetailViewModel directly, since it needs richer domain data (MetaDetail/Video/Stream) than the lightweight shared Detail UI models carry. Preserves the movie-vs-series distinction from the original TV logic: Play button on a movie always targets a null video id (no accidental resume-progress carryover), matching the legacy behavior exactly. --- .../app/ui/catalog/AndroidDetailDataSource.kt | 16 +- .../com/fluxa/app/ui/routes/DetailRoute.kt | 152 +++++++++--------- .../shared/feature/detail/DetailContracts.kt | 36 +++++ .../app/shared/feature/detail/DetailStore.kt | 36 ++--- 4 files changed, 138 insertions(+), 102 deletions(-) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt index fbb73d1..ff30062 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt @@ -129,12 +129,16 @@ private fun Video.toUiModel(watchedIds: Set): DetailEpisodeUiModel = Det isWatched = id in watchedIds ) +internal fun com.fluxa.app.data.remote.Stream.toDetailStreamUiModel(): DetailStreamUiModel? { + val url = playableUrl ?: return null + return DetailStreamUiModel( + addonName = addonName.orEmpty(), + title = title ?: name.orEmpty(), + playableUrl = url + ) +} + private fun List.toUiModels(): List = mapNotNull { stream -> - val url = stream.playableUrl ?: return@mapNotNull null - DetailStreamUiModel( - addonName = stream.addonName.orEmpty(), - title = stream.title ?: stream.name.orEmpty(), - playableUrl = url - ) + stream.toDetailStreamUiModel() } diff --git a/app/src/main/java/com/fluxa/app/ui/routes/DetailRoute.kt b/app/src/main/java/com/fluxa/app/ui/routes/DetailRoute.kt index 7f03072..c8e3bb9 100644 --- a/app/src/main/java/com/fluxa/app/ui/routes/DetailRoute.kt +++ b/app/src/main/java/com/fluxa/app/ui/routes/DetailRoute.kt @@ -5,6 +5,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.fluxa.app.data.local.UserProfile import com.fluxa.app.data.remote.Meta +import com.fluxa.app.data.remote.Stream +import com.fluxa.app.data.remote.Video +import com.fluxa.app.shared.feature.detail.DetailNavigationEvent +import com.fluxa.app.shared.feature.detail.DetailNavigationLogic import com.fluxa.app.ui.AppNavigator import com.fluxa.app.ui.Screen import com.fluxa.app.ui.asNavigationMeta @@ -12,6 +16,7 @@ import com.fluxa.app.ui.toMeta import androidx.hilt.navigation.compose.hiltViewModel import com.fluxa.app.ui.catalog.DetailScreen import com.fluxa.app.ui.catalog.DetailViewModel +import com.fluxa.app.ui.catalog.toDetailStreamUiModel @Composable internal fun DetailRoute( @@ -24,6 +29,42 @@ internal fun DetailRoute( val detailViewModel: DetailViewModel = hiltViewModel( key = "DetailViewModel_${screen.id}" ) + + fun effectiveResumeVideoId(): String? = screen.lastVideoId ?: detailViewModel.uiState.value.savedPlayback?.lastVideoId + fun effectiveResumeProgress(): Long = screen.initialProgress ?: detailViewModel.uiState.value.savedPlayback?.timeOffset ?: 0L + + fun navigate(event: DetailNavigationEvent, meta: Meta, streamsForIndex: List, episode: Video?) { + when (event) { + is DetailNavigationEvent.PlayStream -> { + val index = streamsForIndex.indexOfFirst { it.playableUrl == event.stream.playableUrl }.coerceAtLeast(0) + navigator.navigateTo( + Screen.Player( + meta, + videoId = event.episodeId, + initialProgress = event.resumeProgress, + streamIndex = index, + initialStreams = streamsForIndex, + lastStreamUrl = event.stream.playableUrl, + lastStreamTitle = event.stream.title + ) + ) + } + is DetailNavigationEvent.SelectSources -> { + navigator.navigateTo( + Screen.Sources( + meta = meta, + video = episode, + videoId = event.episodeId, + initialProgress = event.resumeProgress, + lastStreamIndex = screen.lastStreamIndex, + lastStreamUrl = screen.lastStreamUrl, + lastStreamTitle = screen.lastStreamTitle + ) + ) + } + } + } + DetailScreen( screen.type, screen.id, @@ -37,89 +78,46 @@ internal fun DetailRoute( onBack, { stream, episode -> val snap = detailViewModel.uiState.value - val savedPlayback = snap.savedPlayback - val effectiveLastVideoId = screen.lastVideoId ?: savedPlayback?.lastVideoId - val effectiveInitialProgress = screen.initialProgress ?: savedPlayback?.timeOffset - val index = snap.streams.indexOf(stream).coerceAtLeast(0) - val targetVideoId = episode?.id ?: effectiveLastVideoId - val resumeProgress = if (targetVideoId != null && targetVideoId == effectiveLastVideoId) { - effectiveInitialProgress ?: 0L - } else { - 0L - } - navigator.navigateTo( - Screen.Player( - snap.detail?.toMeta() ?: Meta(screen.id, "", screen.type, "", ""), - videoId = targetVideoId, - initialProgress = resumeProgress, - streamIndex = index, - initialStreams = snap.filteredStreams, - lastStreamUrl = stream.playableUrl, - lastStreamTitle = stream.title + val meta = snap.detail?.toMeta() ?: Meta(screen.id, "", screen.type, "", "") + val streamUiModel = stream.toDetailStreamUiModel() + if (streamUiModel != null) { + val event = DetailNavigationLogic.forStream( + contentResumeVideoId = effectiveResumeVideoId(), + contentResumeProgress = effectiveResumeProgress(), + stream = streamUiModel, + episodeId = episode?.id ) - ) + navigate(event, meta, snap.filteredStreams, episode) + } }, { selectedEpisode -> - detailViewModel.uiState.value.detail?.let { meta -> - val savedPlayback = detailViewModel.uiState.value.savedPlayback - val effectiveLastVideoId = screen.lastVideoId ?: savedPlayback?.lastVideoId - val effectiveInitialProgress = screen.initialProgress ?: savedPlayback?.timeOffset - val targetVideoId = if (meta.type == "series") selectedEpisode?.id ?: effectiveLastVideoId else null - val resumeProgress = if (selectedEpisode?.id != null && selectedEpisode.id == effectiveLastVideoId) { - effectiveInitialProgress ?: 0L - } else { - 0L - } - if (meta.id.startsWith("cs3:") || targetVideoId?.startsWith("cs3:") == true) { - navigator.navigateTo( - Screen.Player( - meta = meta.asNavigationMeta(), - videoId = targetVideoId, - initialProgress = resumeProgress - ) - ) - } else { - navigator.navigateTo( - Screen.Sources( - meta = meta.asNavigationMeta(), - video = selectedEpisode, - videoId = targetVideoId, - initialProgress = resumeProgress, - lastStreamIndex = screen.lastStreamIndex, - lastStreamUrl = screen.lastStreamUrl, - lastStreamTitle = screen.lastStreamTitle - ) - ) - } + val snap = detailViewModel.uiState.value + snap.detail?.let { meta -> + val isSeries = meta.type == "series" + val episodeId = if (isSeries) selectedEpisode?.id else null + val firstStream = snap.streams.firstOrNull()?.toDetailStreamUiModel() + val event = DetailNavigationLogic.forPlay( + contentId = meta.id, + contentResumeVideoId = if (isSeries) effectiveResumeVideoId() else null, + contentResumeProgress = effectiveResumeProgress(), + episodeId = episodeId, + firstStreamIfCs3 = firstStream + ) + navigate(event, meta.asNavigationMeta(), snap.filteredStreams, selectedEpisode) } }, { episode -> - detailViewModel.uiState.value.detail?.let { - val savedPlayback = detailViewModel.uiState.value.savedPlayback - val effectiveLastVideoId = screen.lastVideoId ?: savedPlayback?.lastVideoId - val effectiveInitialProgress = screen.initialProgress ?: savedPlayback?.timeOffset - val resumeProgress = if (episode.id == effectiveLastVideoId) effectiveInitialProgress ?: 0L else 0L - if (it.id.startsWith("cs3:") || episode.id.startsWith("cs3:")) { - navigator.navigateTo( - Screen.Player( - meta = it.asNavigationMeta(), - videoId = episode.id, - initialProgress = resumeProgress - ) - ) - } else { - navigator.navigateTo( - Screen.Sources( - meta = it.asNavigationMeta(), - video = episode, - videoId = episode.id, - initialProgress = resumeProgress, - lastStreamIndex = screen.lastStreamIndex, - lastStreamUrl = screen.lastStreamUrl, - lastStreamTitle = screen.lastStreamTitle - ) - ) - } + val snap = detailViewModel.uiState.value + snap.detail?.let { meta -> + val firstStream = snap.streams.firstOrNull()?.toDetailStreamUiModel() + val event = DetailNavigationLogic.forPlay( + contentId = meta.id, + contentResumeVideoId = effectiveResumeVideoId(), + contentResumeProgress = effectiveResumeProgress(), + episodeId = episode.id, + firstStreamIfCs3 = firstStream + ) + navigate(event, meta.asNavigationMeta(), snap.filteredStreams, episode) } }, { episode -> diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt index 8539a50..ba4623c 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt @@ -85,6 +85,42 @@ sealed interface DetailNavigationEvent { data class SelectSources(val episodeId: String?, val resumeProgress: Long = 0L) : DetailNavigationEvent } +object DetailNavigationLogic { + fun resumeProgressFor(resumeVideoId: String?, resumeProgress: Long, targetVideoId: String?): Long = + if (targetVideoId != null && targetVideoId == resumeVideoId) resumeProgress else 0L + + fun forStream( + contentResumeVideoId: String?, + contentResumeProgress: Long, + stream: DetailStreamUiModel, + episodeId: String? + ): DetailNavigationEvent.PlayStream { + val targetVideoId = episodeId ?: contentResumeVideoId + return DetailNavigationEvent.PlayStream( + stream = stream, + episodeId = episodeId, + resumeProgress = resumeProgressFor(contentResumeVideoId, contentResumeProgress, targetVideoId) + ) + } + + fun forPlay( + contentId: String?, + contentResumeVideoId: String?, + contentResumeProgress: Long, + episodeId: String?, + firstStreamIfCs3: DetailStreamUiModel? + ): DetailNavigationEvent { + val targetVideoId = episodeId ?: contentResumeVideoId + val progress = resumeProgressFor(contentResumeVideoId, contentResumeProgress, targetVideoId) + val isCs3 = contentId?.startsWith("cs3:") == true || targetVideoId?.startsWith("cs3:") == true + return if (isCs3 && firstStreamIfCs3 != null) { + DetailNavigationEvent.PlayStream(firstStreamIfCs3, episodeId, progress) + } else { + DetailNavigationEvent.SelectSources(episodeId, progress) + } + } +} + interface DetailDataSource { fun observeDetail(id: String, type: String): Flow suspend fun loadDetail(request: DetailRequestUiModel) diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailStore.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailStore.kt index 4645a92..391a11f 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailStore.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailStore.kt @@ -32,31 +32,29 @@ class DetailStore( is DetailAction.DownloadSeason -> dataSource.downloadSeason(action.season) is DetailAction.StreamSelected -> { val content = state.value.content - val targetVideoId = action.episodeId ?: content?.resumeVideoId - val progress = resumeProgressFor(content, targetVideoId) - _navigation.emit(DetailNavigationEvent.PlayStream(action.stream, action.episodeId, progress)) + _navigation.emit( + DetailNavigationLogic.forStream( + contentResumeVideoId = content?.resumeVideoId, + contentResumeProgress = content?.resumeProgress ?: 0L, + stream = action.stream, + episodeId = action.episodeId + ) + ) } DetailAction.Play -> { val content = state.value.content val episodeId = content?.selectedEpisodeId - val targetVideoId = episodeId ?: content?.resumeVideoId - val progress = resumeProgressFor(content, targetVideoId) - val isCs3 = content?.id?.startsWith("cs3:") == true || episodeId?.startsWith("cs3:") == true - if (isCs3) { - val stream = content?.streams?.firstOrNull() - if (stream != null) { - _navigation.emit(DetailNavigationEvent.PlayStream(stream, episodeId, progress)) - } else { - _navigation.emit(DetailNavigationEvent.SelectSources(episodeId, progress)) - } - } else { - _navigation.emit(DetailNavigationEvent.SelectSources(episodeId, progress)) - } + _navigation.emit( + DetailNavigationLogic.forPlay( + contentId = content?.id, + contentResumeVideoId = content?.resumeVideoId, + contentResumeProgress = content?.resumeProgress ?: 0L, + episodeId = episodeId, + firstStreamIfCs3 = content?.streams?.firstOrNull() + ) + ) } is DetailAction.RelatedItemSelected -> Unit } } - - private fun resumeProgressFor(content: DetailUiModel?, targetVideoId: String?): Long = - if (targetVideoId != null && targetVideoId == content?.resumeVideoId) content.resumeProgress else 0L }