mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 08:27:54 +00:00
feat(entity-browse): add TmdbEntityBrowseScreen and related functionality
This commit is contained in:
parent
9bcbe21d46
commit
fe04d6cf72
7 changed files with 830 additions and 3 deletions
|
|
@ -81,6 +81,8 @@ import com.nuvio.app.features.details.MetaDetailsRepository
|
|||
import com.nuvio.app.features.details.MetaDetailsScreen
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
import com.nuvio.app.features.details.PersonDetailScreen
|
||||
import com.nuvio.app.features.details.TmdbEntityBrowseScreen
|
||||
import com.nuvio.app.features.tmdb.TmdbEntityKind
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.HomeScreen
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
|
|
@ -143,6 +145,14 @@ data class PersonDetailRoute(
|
|||
val preferCrew: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EntityBrowseRoute(
|
||||
val entityKind: String,
|
||||
val entityId: Int,
|
||||
val entityName: String,
|
||||
val sourceType: String = "tv",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
object HomescreenSettingsRoute
|
||||
|
||||
|
|
@ -632,6 +642,19 @@ private fun MainAppContent(
|
|||
)
|
||||
}
|
||||
},
|
||||
onCompanyClick = { company, entityKind ->
|
||||
val tmdbId = company.tmdbId
|
||||
if (tmdbId != null && tmdbId > 0) {
|
||||
navController.navigate(
|
||||
EntityBrowseRoute(
|
||||
entityKind = entityKind,
|
||||
entityId = tmdbId,
|
||||
entityName = company.name,
|
||||
sourceType = route.type,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -666,6 +689,38 @@ private fun MainAppContent(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
composable<EntityBrowseRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<EntityBrowseRoute>()
|
||||
TmdbEntityBrowseScreen(
|
||||
entityKind = TmdbEntityKind.fromRouteValue(route.entityKind),
|
||||
entityId = route.entityId,
|
||||
entityName = route.entityName,
|
||||
sourceType = route.sourceType,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenMeta = { preview ->
|
||||
coroutineScope.launch {
|
||||
val resolvedId = if (preview.id.startsWith("tmdb:")) {
|
||||
val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull()
|
||||
tmdbId?.let {
|
||||
TmdbService.tmdbToImdb(
|
||||
tmdbId = it,
|
||||
mediaType = preview.type,
|
||||
)
|
||||
} ?: preview.id
|
||||
} else {
|
||||
preview.id
|
||||
}
|
||||
navController.navigate(
|
||||
DetailRoute(
|
||||
type = preview.type,
|
||||
id = resolvedId,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
composable<StreamRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<StreamRoute>()
|
||||
val pauseDescription = remember(route.streamContextId) {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ fun MetaDetailsScreen(
|
|||
onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)? = null,
|
||||
onCastClick: ((MetaPerson) -> Unit)? = null,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -516,6 +517,7 @@ fun MetaDetailsScreen(
|
|||
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
|
||||
onOpenMeta = onOpenMeta,
|
||||
onCastClick = onCastClick,
|
||||
onCompanyClick = onCompanyClick,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
||||
|
|
@ -755,6 +757,7 @@ private fun ConfiguredMetaSections(
|
|||
onEpisodeLongPress: (MetaVideo) -> Unit,
|
||||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
onCastClick: ((MetaPerson) -> Unit)?,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||
) {
|
||||
settings.items
|
||||
.filter { it.enabled }
|
||||
|
|
@ -777,7 +780,10 @@ private fun ConfiguredMetaSections(
|
|||
|
||||
MetaScreenSectionKey.PRODUCTION -> {
|
||||
if (hasProductionSection) {
|
||||
DetailProductionSection(meta = meta)
|
||||
DetailProductionSection(
|
||||
meta = meta,
|
||||
onCompanyClick = onCompanyClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ private fun PersonDetailSkeleton(personName: String) {
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 0.dp),
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
repeat(4) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.components.DetailPosterRailSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.tmdb.TmdbEntityBrowseData
|
||||
import com.nuvio.app.features.tmdb.TmdbEntityKind
|
||||
import com.nuvio.app.features.tmdb.TmdbEntityMediaType
|
||||
import com.nuvio.app.features.tmdb.TmdbEntityRailType
|
||||
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||
|
||||
private sealed interface EntityBrowseUiState {
|
||||
data object Loading : EntityBrowseUiState
|
||||
data class Error(val message: String) : EntityBrowseUiState
|
||||
data class Success(val data: TmdbEntityBrowseData) : EntityBrowseUiState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TmdbEntityBrowseScreen(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
entityName: String,
|
||||
sourceType: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenMeta: (MetaPreview) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var uiState by remember(entityKind, entityId) {
|
||||
mutableStateOf<EntityBrowseUiState>(EntityBrowseUiState.Loading)
|
||||
}
|
||||
|
||||
LaunchedEffect(entityKind, entityId) {
|
||||
uiState = EntityBrowseUiState.Loading
|
||||
val data = TmdbMetadataService.fetchEntityBrowse(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
sourceType = sourceType,
|
||||
fallbackName = entityName,
|
||||
)
|
||||
uiState = if (data != null) {
|
||||
EntityBrowseUiState.Success(data)
|
||||
} else {
|
||||
EntityBrowseUiState.Error("Could not load $entityName")
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Crossfade(
|
||||
targetState = uiState,
|
||||
label = "EntityBrowseCrossfade",
|
||||
) { state ->
|
||||
when (state) {
|
||||
is EntityBrowseUiState.Loading -> EntityBrowseSkeleton()
|
||||
is EntityBrowseUiState.Error -> EntityBrowseError(
|
||||
message = state.message,
|
||||
onRetry = { uiState = EntityBrowseUiState.Loading },
|
||||
)
|
||||
is EntityBrowseUiState.Success -> EntityBrowseContent(
|
||||
data = state.data,
|
||||
sourceType = sourceType,
|
||||
onOpenMeta = onOpenMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(start = 4.dp, top = 4.dp)
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityBrowseContent(
|
||||
data: TmdbEntityBrowseData,
|
||||
sourceType: String,
|
||||
onOpenMeta: (MetaPreview) -> Unit,
|
||||
) {
|
||||
val backgroundUrl = remember(data.rails, sourceType) {
|
||||
val preferredMediaType = if (sourceType.trim().equals("movie", ignoreCase = true)) {
|
||||
TmdbEntityMediaType.MOVIE
|
||||
} else {
|
||||
TmdbEntityMediaType.TV
|
||||
}
|
||||
data.rails.firstOrNull { it.mediaType == preferredMediaType }
|
||||
?.items?.firstOrNull()?.poster
|
||||
?: data.rails.firstOrNull()?.items?.firstOrNull()?.poster
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (backgroundUrl != null) {
|
||||
AsyncImage(
|
||||
model = backgroundUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.10f,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to MaterialTheme.colorScheme.background.copy(alpha = 0.7f),
|
||||
0.3f to MaterialTheme.colorScheme.background.copy(alpha = 0.95f),
|
||||
1f to MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (data.rails.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No titles found",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(top = 56.dp),
|
||||
) {
|
||||
EntityHeroSection(
|
||||
header = data.header,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
data.rails.forEach { rail ->
|
||||
val railTitle = remember(rail.mediaType, rail.railType) {
|
||||
val mediaLabel = when (rail.mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> "Movies"
|
||||
TmdbEntityMediaType.TV -> "Series"
|
||||
}
|
||||
val railLabel = when (rail.railType) {
|
||||
TmdbEntityRailType.POPULAR -> "Popular"
|
||||
TmdbEntityRailType.TOP_RATED -> "Top Rated"
|
||||
TmdbEntityRailType.RECENT -> "Recent"
|
||||
}
|
||||
"$mediaLabel • $railLabel"
|
||||
}
|
||||
|
||||
DetailPosterRailSection(
|
||||
title = railTitle,
|
||||
items = rail.items,
|
||||
watchedKeys = emptySet(),
|
||||
headerHorizontalPadding = 20.dp,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityHeroSection(
|
||||
header: com.nuvio.app.features.tmdb.TmdbEntityHeader,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasLogo = !header.logo.isNullOrBlank()
|
||||
|
||||
Column(modifier = modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = when (header.kind) {
|
||||
TmdbEntityKind.COMPANY -> "Production Company"
|
||||
TmdbEntityKind.NETWORK -> "Network"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (hasLogo) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = header.logo,
|
||||
contentDescription = header.name,
|
||||
modifier = Modifier.height(44.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = header.name,
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
val metaLine = listOfNotNull(
|
||||
header.originCountry?.takeIf { it.isNotBlank() },
|
||||
header.secondaryLabel?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" • ")
|
||||
if (metaLine.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = metaLine,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
header.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityBrowseSkeleton() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(top = 56.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(120.dp)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(200.dp)
|
||||
.height(28.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
repeat(3) {
|
||||
Column(modifier = Modifier.padding(bottom = 20.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.width(160.dp)
|
||||
.height(16.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
repeat(4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(110.dp)
|
||||
.height(163.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntityBrowseError(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ fun DetailPosterRailSection(
|
|||
entries = items,
|
||||
modifier = modifier,
|
||||
headerHorizontalPadding = headerHorizontalPadding,
|
||||
rowContentPadding = PaddingValues(0.dp),
|
||||
rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
|
||||
showHeaderAccent = false,
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
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.BoxWithConstraints
|
||||
|
|
@ -30,8 +31,10 @@ import com.nuvio.app.features.details.MetaDetails
|
|||
fun DetailProductionSection(
|
||||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
|
||||
) {
|
||||
val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null }
|
||||
val isNetworkSource = isSeriesLike && meta.networks.isNotEmpty()
|
||||
val sourceItems = if (isSeriesLike) {
|
||||
meta.networks.ifEmpty { meta.productionCompanies }
|
||||
} else {
|
||||
|
|
@ -39,6 +42,8 @@ fun DetailProductionSection(
|
|||
}
|
||||
if (sourceItems.isEmpty()) return
|
||||
|
||||
val entityKind = if (isNetworkSource) "network" else "company"
|
||||
|
||||
val displayItems = if (isSeriesLike) {
|
||||
sourceItems.take(6)
|
||||
} else {
|
||||
|
|
@ -78,6 +83,9 @@ fun DetailProductionSection(
|
|||
chipHeight = chipHeight,
|
||||
logoWidth = logoWidth,
|
||||
logoHeight = logoHeight,
|
||||
onClick = if (onCompanyClick != null && item.tmdbId != null) {
|
||||
{ onCompanyClick(item, entityKind) }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,11 +99,15 @@ private fun ProductionChip(
|
|||
chipHeight: androidx.compose.ui.unit.Dp,
|
||||
logoWidth: androidx.compose.ui.unit.Dp,
|
||||
logoHeight: androidx.compose.ui.unit.Dp,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(color = ProductionChipBackground)
|
||||
.then(
|
||||
if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(chipHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ object TmdbMetadataService {
|
|||
private val collectionCache = mutableMapOf<String, Pair<String?, List<MetaPreview>>>()
|
||||
private val trailerCache = mutableMapOf<String, List<MetaTrailer>>()
|
||||
private val personCache = mutableMapOf<String, PersonDetail>()
|
||||
private val entityBrowseCache = mutableMapOf<String, TmdbEntityBrowseData>()
|
||||
private val entityHeaderCache = mutableMapOf<String, TmdbEntityHeader>()
|
||||
private val entityRailCache = mutableMapOf<String, List<MetaPreview>>()
|
||||
|
||||
suspend fun fetchPersonDetail(
|
||||
personId: Int,
|
||||
|
|
@ -189,6 +192,259 @@ object TmdbMetadataService {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun fetchEntityBrowse(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
sourceType: String,
|
||||
fallbackName: String? = null,
|
||||
): TmdbEntityBrowseData? = withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
if (!settings.enabled || !settings.hasApiKey) return@withContext null
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
val normalizedSourceType = normalizeEntitySourceType(sourceType)
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$language"
|
||||
entityBrowseCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
val header = fetchEntityHeader(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
fallbackName = fallbackName,
|
||||
language = language,
|
||||
)
|
||||
|
||||
val rails = buildEntityMediaOrder(entityKind, normalizedSourceType)
|
||||
.flatMap { mediaType ->
|
||||
TmdbEntityRailType.entries.mapNotNull { railType ->
|
||||
val pageResult = fetchEntityRailPage(
|
||||
entityKind = entityKind,
|
||||
entityId = entityId,
|
||||
mediaType = mediaType,
|
||||
railType = railType,
|
||||
language = language,
|
||||
page = 1,
|
||||
)
|
||||
if (pageResult.items.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
TmdbEntityRail(
|
||||
mediaType = mediaType,
|
||||
railType = railType,
|
||||
items = pageResult.items,
|
||||
currentPage = 1,
|
||||
hasMore = pageResult.hasMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (header == null && rails.isEmpty()) return@withContext null
|
||||
|
||||
val data = TmdbEntityBrowseData(
|
||||
header = header ?: TmdbEntityHeader(
|
||||
id = entityId,
|
||||
kind = entityKind,
|
||||
name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown",
|
||||
logo = null,
|
||||
originCountry = null,
|
||||
secondaryLabel = null,
|
||||
description = null,
|
||||
),
|
||||
rails = rails,
|
||||
)
|
||||
entityBrowseCache[cacheKey] = data
|
||||
data
|
||||
}
|
||||
|
||||
suspend fun fetchEntityRailPage(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
mediaType: TmdbEntityMediaType,
|
||||
railType: TmdbEntityRailType,
|
||||
language: String,
|
||||
page: Int,
|
||||
): TmdbEntityRailPageResult {
|
||||
if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) {
|
||||
return TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
|
||||
}
|
||||
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page"
|
||||
entityRailCache[cacheKey]?.let { cached ->
|
||||
return TmdbEntityRailPageResult(items = cached, hasMore = cached.isNotEmpty())
|
||||
}
|
||||
|
||||
val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) ENTITY_TOP_RATED_VOTE_FLOOR else null
|
||||
|
||||
val result = try {
|
||||
val sortBy = when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> when (railType) {
|
||||
TmdbEntityRailType.POPULAR -> "popularity.desc"
|
||||
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
|
||||
TmdbEntityRailType.RECENT -> "primary_release_date.desc"
|
||||
}
|
||||
TmdbEntityMediaType.TV -> when (railType) {
|
||||
TmdbEntityRailType.POPULAR -> "popularity.desc"
|
||||
TmdbEntityRailType.TOP_RATED -> "vote_average.desc"
|
||||
TmdbEntityRailType.RECENT -> "first_air_date.desc"
|
||||
}
|
||||
}
|
||||
|
||||
val queryParams = buildMap {
|
||||
put("language", language)
|
||||
put("page", page.toString())
|
||||
put("sort_by", sortBy)
|
||||
when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> {
|
||||
put("with_companies", entityId.toString())
|
||||
}
|
||||
TmdbEntityMediaType.TV -> {
|
||||
if (entityKind == TmdbEntityKind.COMPANY) put("with_companies", entityId.toString())
|
||||
if (entityKind == TmdbEntityKind.NETWORK) put("with_networks", entityId.toString())
|
||||
}
|
||||
}
|
||||
if (voteCountFloor != null) put("vote_count.gte", voteCountFloor.toString())
|
||||
}
|
||||
|
||||
val endpoint = when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> "discover/movie"
|
||||
TmdbEntityMediaType.TV -> "discover/tv"
|
||||
}
|
||||
|
||||
val response = fetch<TmdbDiscoverResponse>(endpoint = endpoint, query = queryParams)
|
||||
val results = response?.results.orEmpty()
|
||||
val totalPages = response?.totalPages ?: page
|
||||
|
||||
val mappedItems = results
|
||||
.filter { it.id > 0 }
|
||||
.mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) }
|
||||
.take(ENTITY_RAIL_MAX_ITEMS)
|
||||
|
||||
TmdbEntityRailPageResult(
|
||||
items = mappedItems,
|
||||
hasMore = page < totalPages && mappedItems.isNotEmpty(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.w(e) { "Failed to fetch entity rail ${railType.value}/${mediaType.value} for $entityId" }
|
||||
TmdbEntityRailPageResult(items = emptyList(), hasMore = false)
|
||||
}
|
||||
|
||||
if (result.items.isNotEmpty()) {
|
||||
entityRailCache[cacheKey] = result.items
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun fetchEntityHeader(
|
||||
entityKind: TmdbEntityKind,
|
||||
entityId: Int,
|
||||
fallbackName: String?,
|
||||
language: String,
|
||||
): TmdbEntityHeader? {
|
||||
val cacheKey = "${entityKind.routeValue}:$entityId:$language:header"
|
||||
entityHeaderCache[cacheKey]?.let { return it }
|
||||
|
||||
val header = try {
|
||||
when (entityKind) {
|
||||
TmdbEntityKind.COMPANY -> {
|
||||
val body = fetch<TmdbCompanyDetailsResponse>(endpoint = "company/$entityId")
|
||||
body?.let {
|
||||
TmdbEntityHeader(
|
||||
id = it.id,
|
||||
kind = entityKind,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() }
|
||||
?: fallbackName?.takeIf { n -> n.isNotBlank() }
|
||||
?: "Unknown",
|
||||
logo = buildImageUrl(it.logoPath, "w500"),
|
||||
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
|
||||
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
|
||||
description = it.description?.takeIf { d -> d.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
TmdbEntityKind.NETWORK -> {
|
||||
val body = fetch<TmdbNetworkDetailsResponse>(endpoint = "network/$entityId")
|
||||
body?.let {
|
||||
TmdbEntityHeader(
|
||||
id = it.id,
|
||||
kind = entityKind,
|
||||
name = it.name?.takeIf { n -> n.isNotBlank() }
|
||||
?: fallbackName?.takeIf { n -> n.isNotBlank() }
|
||||
?: "Unknown",
|
||||
logo = buildImageUrl(it.logoPath, "w500"),
|
||||
originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() },
|
||||
secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() },
|
||||
description = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.w(e) { "Failed to fetch ${entityKind.routeValue} header for $entityId" }
|
||||
null
|
||||
} ?: fallbackName?.takeIf { it.isNotBlank() }?.let {
|
||||
TmdbEntityHeader(
|
||||
id = entityId,
|
||||
kind = entityKind,
|
||||
name = it,
|
||||
logo = null,
|
||||
originCountry = null,
|
||||
secondaryLabel = null,
|
||||
description = null,
|
||||
)
|
||||
}
|
||||
|
||||
if (header != null) {
|
||||
entityHeaderCache[cacheKey] = header
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
private fun mapEntityDiscoverResult(
|
||||
result: TmdbDiscoverResult,
|
||||
mediaType: TmdbEntityMediaType,
|
||||
): MetaPreview? {
|
||||
val title = result.title?.takeIf { it.isNotBlank() }
|
||||
?: result.name?.takeIf { it.isNotBlank() }
|
||||
?: result.originalTitle?.takeIf { it.isNotBlank() }
|
||||
?: result.originalName?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val poster = buildImageUrl(result.posterPath, "w500")
|
||||
?: buildImageUrl(result.backdropPath, "w780")
|
||||
?: return null
|
||||
val releaseInfo = when (mediaType) {
|
||||
TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4)
|
||||
TmdbEntityMediaType.TV -> result.firstAirDate?.take(4)
|
||||
}
|
||||
return MetaPreview(
|
||||
id = "tmdb:${result.id}",
|
||||
type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie",
|
||||
name = title,
|
||||
poster = poster,
|
||||
description = result.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = releaseInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildEntityMediaOrder(
|
||||
entityKind: TmdbEntityKind,
|
||||
sourceType: String,
|
||||
): List<TmdbEntityMediaType> {
|
||||
if (entityKind == TmdbEntityKind.NETWORK) {
|
||||
return listOf(TmdbEntityMediaType.TV)
|
||||
}
|
||||
return when (sourceType) {
|
||||
"movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV)
|
||||
else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeEntitySourceType(sourceType: String): String {
|
||||
return when (sourceType.trim().lowercase()) {
|
||||
"movie" -> "movie"
|
||||
"tv", "series", "show" -> "tv"
|
||||
else -> "tv"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enrichMeta(
|
||||
meta: MetaDetails,
|
||||
fallbackItemId: String,
|
||||
|
|
@ -1278,3 +1534,105 @@ private data class TmdbPersonCreditCrew(
|
|||
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||
)
|
||||
|
||||
// ─── Entity Browse (Company / Network) Models ───
|
||||
|
||||
private const val ENTITY_RAIL_MAX_ITEMS = 20
|
||||
private const val ENTITY_TOP_RATED_VOTE_FLOOR = 200
|
||||
|
||||
enum class TmdbEntityKind(val routeValue: String) {
|
||||
COMPANY("company"),
|
||||
NETWORK("network");
|
||||
|
||||
companion object {
|
||||
fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase()) {
|
||||
"network" -> NETWORK
|
||||
else -> COMPANY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class TmdbEntityMediaType(val value: String) {
|
||||
MOVIE("movie"),
|
||||
TV("tv"),
|
||||
}
|
||||
|
||||
enum class TmdbEntityRailType(val value: String) {
|
||||
POPULAR("popular"),
|
||||
TOP_RATED("top_rated"),
|
||||
RECENT("recent"),
|
||||
}
|
||||
|
||||
data class TmdbEntityHeader(
|
||||
val id: Int,
|
||||
val kind: TmdbEntityKind,
|
||||
val name: String,
|
||||
val logo: String?,
|
||||
val originCountry: String?,
|
||||
val secondaryLabel: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
data class TmdbEntityRail(
|
||||
val mediaType: TmdbEntityMediaType,
|
||||
val railType: TmdbEntityRailType,
|
||||
val items: List<MetaPreview>,
|
||||
val currentPage: Int = 1,
|
||||
val hasMore: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
data class TmdbEntityBrowseData(
|
||||
val header: TmdbEntityHeader,
|
||||
val rails: List<TmdbEntityRail>,
|
||||
)
|
||||
|
||||
data class TmdbEntityRailPageResult(
|
||||
val items: List<MetaPreview>,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TmdbCompanyDetailsResponse(
|
||||
val id: Int,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val headquarters: String? = null,
|
||||
val homepage: String? = null,
|
||||
@SerialName("logo_path") val logoPath: String? = null,
|
||||
@SerialName("origin_country") val originCountry: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TmdbNetworkDetailsResponse(
|
||||
val id: Int,
|
||||
val name: String? = null,
|
||||
val headquarters: String? = null,
|
||||
val homepage: String? = null,
|
||||
@SerialName("logo_path") val logoPath: String? = null,
|
||||
@SerialName("origin_country") val originCountry: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TmdbDiscoverResponse(
|
||||
val page: Int? = null,
|
||||
val results: List<TmdbDiscoverResult> = emptyList(),
|
||||
@SerialName("total_pages") val totalPages: Int? = null,
|
||||
@SerialName("total_results") val totalResults: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TmdbDiscoverResult(
|
||||
val id: Int,
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
@SerialName("original_title") val originalTitle: String? = null,
|
||||
@SerialName("original_name") val originalName: String? = null,
|
||||
@SerialName("poster_path") val posterPath: String? = null,
|
||||
@SerialName("backdrop_path") val backdropPath: String? = null,
|
||||
val overview: String? = null,
|
||||
@SerialName("release_date") val releaseDate: String? = null,
|
||||
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||
@SerialName("vote_count") val voteCount: Int? = null,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue