mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-19 05:25:52 +00:00
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:
parent
edf4a9d7a3
commit
db10acd3b2
8 changed files with 233 additions and 7 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ü",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue