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.
This commit is contained in:
KhooLy 2026-07-14 13:51:34 +03:00
parent f27bb23837
commit 311968075d
4 changed files with 138 additions and 102 deletions

View file

@ -129,12 +129,16 @@ private fun Video.toUiModel(watchedIds: Set<String>): 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<com.fluxa.app.data.remote.Stream>.toUiModels(): List<DetailStreamUiModel> =
mapNotNull { stream ->
val url = stream.playableUrl ?: return@mapNotNull null
DetailStreamUiModel(
addonName = stream.addonName.orEmpty(),
title = stream.title ?: stream.name.orEmpty(),
playableUrl = url
)
stream.toDetailStreamUiModel()
}

View file

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

View file

@ -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<DetailUiState>
suspend fun loadDetail(request: DetailRequestUiModel)

View file

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