From db10acd3b2fc10fc8541c6a146e4f87006c0df1c Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:06:39 +0300 Subject: [PATCH] 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. --- .../app/ui/catalog/AndroidDetailDataSource.kt | 5 +- .../com/fluxa/app/ui/routes/AppRoutesHost.kt | 70 ++++++++- core/src/androidMain/assets/i18n/en-US.json | 1 + core/src/androidMain/assets/i18n/tr-TR.json | 1 + .../kotlin/com/fluxa/app/shared/FluxaApp.kt | 3 +- .../com/fluxa/app/shared/FluxaAppHost.kt | 18 ++- .../com/fluxa/app/shared/FluxaAppState.kt | 7 +- .../app/shared/feature/detail/DetailScreen.kt | 135 ++++++++++++++++++ 8 files changed, 233 insertions(+), 7 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 fc4665f..ae47f8e 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 @@ -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): DetailEpisodeUiModel = DetailEpisodeUiModel( diff --git a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt index c651c87..e07ae29 100644 --- a/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt +++ b/app/src/main/java/com/fluxa/app/ui/routes/AppRoutesHost.kt @@ -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 } diff --git a/core/src/androidMain/assets/i18n/en-US.json b/core/src/androidMain/assets/i18n/en-US.json index 25e3a88..48e2b44 100644 --- a/core/src/androidMain/assets/i18n/en-US.json +++ b/core/src/androidMain/assets/i18n/en-US.json @@ -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", diff --git a/core/src/androidMain/assets/i18n/tr-TR.json b/core/src/androidMain/assets/i18n/tr-TR.json index e13da2c..9cf2acd 100644 --- a/core/src/androidMain/assets/i18n/tr-TR.json +++ b/core/src/androidMain/assets/i18n/tr-TR.json @@ -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ü", diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt index 148df0c..c6a8730 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaApp.kt @@ -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 ) diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt index fade6f6..fb285fd 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppHost.kt @@ -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())) diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt index 9af0c75..599c2d8 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/FluxaAppState.kt @@ -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?) { diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt index d0e71aa..4fe398c 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt @@ -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, onAction: (DetailAction) -> Unit) { LazyRow(