mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-07 01:08:51 +00:00
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:
parent
f27bb23837
commit
311968075d
4 changed files with 138 additions and 102 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue