Route Screen.Detail through shared CMP Detail screen on Android mobile

Adds season/episode picker and sources list to the shared Detail
composable, switches FluxaAppState's selectedDetail to carry a
DetailRequestUiModel directly (instead of a full CatalogItemUiModel)
so the standalone Screen.Detail route can seed it without going
through a catalog item first, and threads a detailRequest +
onDetailNavigationEvent pair through FluxaAppHost.

AppRoutesHost now builds a DetailRequestUiModel from Screen.Detail and
hands the shared Detail screen's navigation decisions (play a
specific stream vs. go pick sources, based on cs3: id prefixes) back
to Screen.Player / Screen.Sources, reconstructing the full Stream
object via AndroidDetailDataSource.resolveStream() so headers/
subtitles aren't lost. TV still uses the legacy DetailRoute for now.
This commit is contained in:
KhooLy 2026-07-14 13:06:39 +03:00
parent edf4a9d7a3
commit db10acd3b2
8 changed files with 233 additions and 7 deletions

View file

@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class AndroidDetailDataSource(
private val detailViewModel: DetailViewModel,
val detailViewModel: DetailViewModel,
private val activeProfile: () -> UserProfile?
) : DetailDataSource {
@ -106,6 +106,9 @@ class AndroidDetailDataSource(
val episodes = detailViewModel.uiState.value.seasonEpisodes.filter { (it.season ?: season) == season }
detailViewModel.downloadEpisodes(episodes)
}
fun resolveStream(playableUrl: String): com.fluxa.app.data.remote.Stream? =
detailViewModel.uiState.value.streams.firstOrNull { it.playableUrl == playableUrl }
}
private fun Video.toUiModel(watchedIds: Set<String>): DetailEpisodeUiModel = DetailEpisodeUiModel(

View file

@ -144,6 +144,26 @@ internal fun AppRoutesHost(
null
}
val mobileDetailScreen = (currentScreen as? Screen.Detail).takeIf { deviceType == DeviceType.Mobile }
val mobileDetailRequest = mobileDetailScreen?.let { screen ->
com.fluxa.app.shared.feature.detail.DetailRequestUiModel(
id = screen.id,
type = screen.type,
source = com.fluxa.app.shared.feature.catalog.CatalogSourceUiModel(
addonTransportUrl = screen.sourceAddonTransportUrl,
catalogType = screen.sourceAddonCatalogType
),
initialProgress = screen.initialProgress,
lastVideoId = screen.lastVideoId,
lastStreamIndex = screen.lastStreamIndex,
autoPlay = screen.autoPlay,
targetSeason = screen.targetSeason,
targetEpisode = screen.targetEpisode,
lastStreamUrl = screen.lastStreamUrl,
lastStreamTitle = screen.lastStreamTitle
)
}
var pendingAvatarPicked by remember { mutableStateOf<((String?) -> Unit)?>(null) }
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
val callback = pendingAvatarPicked
@ -160,6 +180,54 @@ internal fun AppRoutesHost(
platformServices = androidFluxaPlatformServices!!,
language = activeProfile?.language,
destination = mobileSharedDestination,
detailRequest = mobileDetailRequest,
onDetailNavigationEvent = { event ->
val androidDetailSource = androidFluxaPlatformServices.detailDataSource
val detail = androidDetailSource.detailViewModel.uiState.value.detail
val meta = detail?.let {
com.fluxa.app.data.remote.Meta(
id = it.id, name = it.name, type = it.type, poster = it.poster, background = it.background,
logo = it.logo, description = it.description, imdbRating = it.imdbRating, releaseInfo = it.releaseInfo,
released = it.released, originalLanguage = it.originalLanguage, originalName = it.originalName,
videos = it.videos, trailers = it.trailers
)
} ?: mobileDetailScreen?.let { com.fluxa.app.data.remote.Meta(it.id, "", it.type, "", "") }
if (meta != null) {
when (event) {
is com.fluxa.app.shared.feature.detail.DetailNavigationEvent.PlayStream -> {
val resolvedStream = androidDetailSource.resolveStream(event.stream.playableUrl)
val streams = androidDetailSource.detailViewModel.uiState.value.filteredStreams
val index = resolvedStream?.let { streams.indexOf(it) }?.coerceAtLeast(0) ?: 0
navigator.navigateTo(
Screen.Player(
meta = meta,
videoId = event.episodeId,
initialProgress = mobileDetailScreen?.initialProgress ?: 0L,
streamIndex = index,
initialStreams = streams,
lastStreamUrl = event.stream.playableUrl,
lastStreamTitle = event.stream.title
)
)
}
is com.fluxa.app.shared.feature.detail.DetailNavigationEvent.SelectSources -> {
val episode = androidDetailSource.detailViewModel.uiState.value.seasonEpisodes
.firstOrNull { it.id == event.episodeId }
navigator.navigateTo(
Screen.Sources(
meta = meta,
video = episode,
videoId = event.episodeId,
initialProgress = mobileDetailScreen?.initialProgress ?: 0L,
lastStreamIndex = mobileDetailScreen?.lastStreamIndex,
lastStreamUrl = mobileDetailScreen?.lastStreamUrl,
lastStreamTitle = mobileDetailScreen?.lastStreamTitle
)
)
}
}
}
},
showNavigationBar = false,
onPlayRequested = onSharedPlayRequested,
onOpenUrlRequested = { url ->
@ -300,7 +368,7 @@ internal fun AppRoutesHost(
)
}
if (mobileSharedDestination != null) {
if (mobileSharedDestination != null || mobileDetailRequest != null) {
return
}

View file

@ -145,6 +145,7 @@
"auto.watched": "WATCHED",
"auto.no_addons_found": "No addons found!",
"auto.no_sources_found": "No sources found!",
"auto.download": "Download",
"auto.search_movies_shows_cast": "Search movies, shows, cast",
"auto.type": "Type",
"auto.all_24e2815b": "All",

View file

@ -145,6 +145,7 @@
"auto.watched": "İZLENDİ",
"auto.no_addons_found": "Hiç eklenti bulunamadı!",
"auto.no_sources_found": "Hiç kaynak bulunamadı!",
"auto.download": "İndir",
"auto.search_movies_shows_cast": "Film, dizi, oyuncu ara",
"auto.type": "Tür",
"auto.all_24e2815b": "Tümü",

View file

@ -52,6 +52,7 @@ import com.fluxa.app.shared.feature.calendar.CalendarAction
import com.fluxa.app.shared.feature.calendar.CalendarScreen
import com.fluxa.app.shared.feature.calendar.CalendarUiState
import com.fluxa.app.shared.feature.detail.DetailAction
import com.fluxa.app.shared.feature.detail.DetailRequestUiModel
import com.fluxa.app.shared.feature.detail.DetailScreen
import com.fluxa.app.shared.feature.detail.DetailUiState
import com.fluxa.app.shared.feature.discover.DiscoverAction
@ -106,7 +107,7 @@ data class FluxaAppUiState(
val language: String? = null,
val destination: FluxaDestination = FluxaDestination.Home,
val catalogHome: CatalogHomeUiState = CatalogHomeUiState(),
val selectedDetail: CatalogItemUiModel? = null,
val selectedDetail: DetailRequestUiModel? = null,
val editingProfile: ProfileEditTarget? = null
)

View file

@ -62,6 +62,8 @@ fun FluxaAppHost(
language: String? = null,
onCatalogAction: (CatalogAction) -> Unit = {},
destination: FluxaDestination? = null,
detailRequest: DetailRequestUiModel? = null,
onDetailNavigationEvent: (com.fluxa.app.shared.feature.detail.DetailNavigationEvent) -> Unit = {},
showNavigationBar: Boolean = true,
onPlayRequested: () -> Unit = {},
onOpenUrlRequested: (String) -> Unit = {},
@ -102,6 +104,8 @@ fun FluxaAppHost(
language = language,
onCatalogAction = onCatalogAction,
destination = destination,
detailRequest = detailRequest,
onDetailNavigationEvent = onDetailNavigationEvent,
showNavigationBar = showNavigationBar,
onPlayRequested = onPlayRequested,
onOpenUrlRequested = onOpenUrlRequested,
@ -145,6 +149,8 @@ fun FluxaAppHost(
language: String? = null,
onCatalogAction: (CatalogAction) -> Unit = {},
destination: FluxaDestination? = null,
detailRequest: DetailRequestUiModel? = null,
onDetailNavigationEvent: (com.fluxa.app.shared.feature.detail.DetailNavigationEvent) -> Unit = {},
showNavigationBar: Boolean = true,
onPlayRequested: () -> Unit = {},
onOpenUrlRequested: (String) -> Unit = {},
@ -217,10 +223,10 @@ fun FluxaAppHost(
mutableStateOf(initial)
}
val selectedDetail = appState.uiState.selectedDetail
val detailStore = selectedDetail?.let { item ->
val detailStore = selectedDetail?.let { request ->
detailDataSource?.let { source ->
remember(item.id, item.type, source) {
DetailStore(DetailRequestUiModel(item.id, item.type, item.source), source, scope)
remember(request.id, request.type, source) {
DetailStore(request, source, scope)
}
}
}
@ -235,12 +241,18 @@ fun FluxaAppHost(
LaunchedEffect(destination) {
destination?.let(appState::selectDestination)
}
LaunchedEffect(detailRequest) {
detailRequest?.let(appState::selectDetail)
}
LaunchedEffect(catalogHomeStore) {
catalogHomeStore.dispatch(CatalogAction.Refresh)
}
LaunchedEffect(detailStore) {
detailStore?.load()
}
LaunchedEffect(detailStore) {
detailStore?.navigation?.collect { event -> onDetailNavigationEvent(event) }
}
LaunchedEffect(appState.uiState.destination, discoverStore) {
if (appState.uiState.destination == FluxaDestination.Discover) {
discoverStore?.dispatch(DiscoverAction.FiltersChanged(discoverState?.filters ?: DiscoverFiltersUiModel()))

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import com.fluxa.app.shared.feature.catalog.CatalogHomeUiState
import com.fluxa.app.shared.feature.catalog.CatalogItemUiModel
import com.fluxa.app.shared.feature.detail.DetailRequestUiModel
import com.fluxa.app.shared.feature.profile.ProfileEditTarget
@Stable
@ -20,7 +21,11 @@ class FluxaAppState internal constructor(initialState: FluxaAppUiState) {
}
fun selectDetail(item: CatalogItemUiModel) {
uiState = uiState.copy(selectedDetail = item)
uiState = uiState.copy(selectedDetail = DetailRequestUiModel(item.id, item.type, item.source))
}
fun selectDetail(request: DetailRequestUiModel) {
uiState = uiState.copy(selectedDetail = request)
}
fun beginProfileEdit(target: ProfileEditTarget?) {

View file

@ -1,14 +1,17 @@
package com.fluxa.app.shared.feature.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
@ -105,6 +108,11 @@ private fun DetailContent(
Text(AppStrings.t(language, if (content.isInWatchlist) "auto.in_list" else "auto.my_list"))
}
}
if (content.type == "series" && content.availableSeasons.isNotEmpty()) {
SeasonSelector(content = content, language = language, onAction = onAction)
EpisodeList(content = content, language = language, onAction = onAction)
}
SourcesSection(content = content, language = language, onAction = onAction)
if (content.relatedItems.isNotEmpty()) {
Text(
text = AppStrings.t(language, "auto.similar_titles"),
@ -117,6 +125,133 @@ private fun DetailContent(
}
}
@Composable
private fun SeasonSelector(content: DetailUiModel, language: String?, onAction: (DetailAction) -> Unit) {
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = AppStrings.t(language, "auto.seasons"),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 20.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(content.availableSeasons, key = { it }) { season ->
val selected = season == content.selectedSeason
Text(
text = "${AppStrings.t(language, "auto.season")} $season",
color = if (selected) FluxaColors.background else Color.White,
modifier = Modifier
.background(if (selected) Color.White else FluxaColors.surface)
.clickable { onAction(DetailAction.SeasonSelected(season)) }
.padding(horizontal = 14.dp, vertical = 8.dp)
)
}
}
}
}
@Composable
private fun EpisodeList(content: DetailUiModel, language: String?, onAction: (DetailAction) -> Unit) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(content.seasonEpisodes, key = { it.id }) { episode ->
val selected = episode.id == content.selectedEpisodeId
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (selected) FluxaColors.surface else Color.Transparent)
.clickable(enabled = !episode.isUpcoming) { onAction(DetailAction.EpisodeSelected(episode.id)) }
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = listOfNotNull(episode.number?.let { "$it." }, episode.title)
.joinToString(" "),
color = Color.White
)
episode.description?.takeIf { it.isNotBlank() }?.let {
Text(text = it, color = Color.White.copy(alpha = 0.6f), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Text(
text = AppStrings.t(language, "auto.download"),
color = Color.White.copy(alpha = 0.7f),
modifier = Modifier.clickable(enabled = !episode.isUpcoming) {
onAction(DetailAction.DownloadEpisode(episode.id))
}
)
}
}
}
}
@Composable
private fun SourcesSection(content: DetailUiModel, language: String?, onAction: (DetailAction) -> Unit) {
if (!content.hasStreamProviders && content.streams.isEmpty() && !content.isLoadingStreams) return
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = AppStrings.t(language, "auto.sources"),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 20.dp)
)
if (content.availableAddons.size > 1) {
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(content.availableAddons, key = { it }) { addon ->
val selected = addon == content.selectedAddon
Text(
text = addon,
color = if (selected) FluxaColors.background else Color.White,
modifier = Modifier
.background(if (selected) Color.White else FluxaColors.surface)
.clickable {
onAction(DetailAction.AddonFilterSelected(if (selected) null else addon))
}
.padding(horizontal = 14.dp, vertical = 8.dp)
)
}
}
}
if (content.isLoadingStreams) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp)
)
} else {
Column(
modifier = Modifier.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
content.streams.forEach { stream ->
Text(
text = listOf(stream.addonName, stream.title).filter { it.isNotBlank() }.joinToString(" · "),
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.background(FluxaColors.surface)
.clickable {
onAction(DetailAction.StreamSelected(stream, content.selectedEpisodeId))
}
.padding(12.dp)
)
}
}
}
}
}
@Composable
private fun RelatedItems(items: List<CatalogItemUiModel>, onAction: (DetailAction) -> Unit) {
LazyRow(