diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 09a9d5f..7148cdc 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 15 - versionName = "0.7.8" + versionCode = 16 + versionName = "0.7.9" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt new file mode 100644 index 0000000..d4672c3 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -0,0 +1,592 @@ +package nl.vonas.mediacluster.tv.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors +import org.json.JSONArray +import org.json.JSONObject + +private fun JSONObject.optCleanString(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key).trim().takeIf { it.isNotEmpty() && it != "null" } +} + +private fun JSONArray?.stringList(): List { + if (this == null) return emptyList() + return buildList { + for (i in 0 until length()) { + val v = optString(i).trim() + if (v.isNotEmpty()) add(v) + } + } +} + +private fun formatRuntime(minutes: Int?): String? { + if (minutes == null || minutes <= 0) return null + val h = minutes / 60 + val m = minutes % 60 + return when { + h > 0 && m > 0 -> "${h}u ${m}m" + h > 0 -> "${h}u" + else -> "${m}m" + } +} + +private fun openYoutubeTrailer(context: android.content.Context, key: String) { + val appPackages = listOf( + "com.google.android.youtube.tv", + "com.google.android.youtube.googletv", + "com.google.android.youtube", + ) + val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + for (pkg in appPackages) { + try { + appIntent.setPackage(pkg) + context.startActivity(appIntent) + return + } catch (_: ActivityNotFoundException) { + // try next + } catch (_: Exception) { + // try next + } + } + try { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://www.youtube.com/watch?v=$key"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (_: Exception) { + // ignore + } +} + +@Composable +private fun DetailBackdrop(url: String?) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + if (!url.isNullOrBlank()) { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xF20B0B0B), + 0.42f to Color(0xCC0B0B0B), + 0.72f to Color(0x660B0B0B), + 1f to Color(0x220B0B0B), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0f to Color(0x660B0B0B), + 0.35f to Color.Transparent, + 0.7f to Color(0xAA0B0B0B), + 1f to Color(0xFF0B0B0B), + ), + ), + ) + } +} + +@Composable +private fun DetailLogoOrTitle(logoUrl: String?, title: String) { + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = title, + modifier = Modifier + .widthIn(max = 420.dp) + .heightIn(max = 120.dp) + .fillMaxWidth(0.85f), + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) + } else { + Text( + title, + color = Color.White, + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + lineHeight = 46.sp, + ) + } +} + +@Composable +private fun MetaChips(parts: List) { + if (parts.isEmpty()) return + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + parts.forEachIndexed { index, part -> + if (index > 0) { + Text("·", color = Color(0xFF666666), fontSize = 14.sp) + } + Text( + part, + color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim, + fontSize = 14.sp, + fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun MovieDetailScreen( + api: ApiClient, + movieId: String, + onBack: () -> Unit, + onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit, +) { + val context = LocalContext.current + var title by remember { mutableStateOf("…") } + var overview by remember { mutableStateOf("") } + var backdrop by remember { mutableStateOf(null) } + var poster by remember { mutableStateOf(null) } + var logoUrl by remember { mutableStateOf(null) } + var year by remember { mutableStateOf(null) } + var runtime by remember { mutableStateOf(null) } + var rating by remember { mutableStateOf(null) } + var genres by remember { mutableStateOf>(emptyList()) } + var cast by remember { mutableStateOf>(emptyList()) } + var director by remember { mutableStateOf>(emptyList()) } + var trailerKey by remember { mutableStateOf(null) } + var files by remember { mutableStateOf>>(emptyList()) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val playFocus = remember { FocusRequester() } + + LaunchedEffect(movieId) { + try { + val o = api.movie(movieId) + title = o.optString("title") + overview = o.optString("overview") + backdrop = o.optCleanString("backdropUrl") ?: o.optCleanString("posterUrl") + poster = o.optCleanString("posterUrl") + logoUrl = o.optCleanString("logoUrl") + year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") } + runtime = o.optInt("runtime").takeIf { o.has("runtime") && !o.isNull("runtime") && o.optInt("runtime") > 0 } + rating = o.optCleanString("rating") + genres = o.optJSONArray("genres").stringList() + cast = o.optJSONArray("cast").stringList() + director = o.optJSONArray("director").stringList() + trailerKey = o.optCleanString("trailerYoutubeKey") + val arr = o.optJSONArray("mediaFiles") + val list = mutableListOf>() + if (arr != null) { + for (i in 0 until arr.length()) { + val f = arr.getJSONObject(i) + val fromApi = f.optCleanString("label") + val label = fromApi ?: buildList { + f.optCleanString("resolution")?.let(::add) + f.optCleanString("hdrType")?.let(::add) + f.optCleanString("videoCodec")?.let(::add) + f.optCleanString("audioCodec")?.let(::add) + f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) } + f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) } + }.joinToString(" · ").ifBlank { "Versie ${i + 1}" } + list += f.getString("id") to label + } + } + files = list + } catch (e: Exception) { + error = e.message + } + } + + LaunchedEffect(files) { + if (files.isNotEmpty()) { + delay(80) + runCatching { playFocus.requestFocus() } + } + } + + val metaParts = buildList { + year?.let { add(it.toString()) } + formatRuntime(runtime)?.let(::add) + genres.take(3).forEach(::add) + rating?.let { add("★ $it") } + } + + BackHandler(onBack = onBack) + Box(Modifier.fillMaxSize()) { + DetailBackdrop(backdrop) + Column( + Modifier + .fillMaxHeight() + .fillMaxWidth(0.58f) + .padding(start = 44.dp, end = 20.dp, top = 28.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + McButton("← Terug", onClick = onBack) + Spacer(Modifier.height(8.dp)) + DetailLogoOrTitle(logoUrl, title) + MetaChips(metaParts) + if (director.isNotEmpty()) { + Text( + "Regie: ${director.take(2).joinToString(", ")}", + color = Color(0xFF9A9A9A), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (overview.isNotBlank()) { + Text( + overview, + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + lineHeight = 22.sp, + ) + } + if (cast.isNotEmpty()) { + Text( + "Met: ${cast.take(5).joinToString(", ")}", + color = Color(0xFF8A8A8A), + fontSize = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (error != null) Text(error!!, color = McColors.Danger) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + files.firstOrNull()?.let { (id, _) -> + McButton("▶ Afspelen", primary = true, focusRequester = playFocus) { + onPlay(id, title, 0) + } + } + if (!trailerKey.isNullOrBlank()) { + McButton("Trailer") { + openYoutubeTrailer(context, trailerKey!!) + } + } + McButton("Als gekeken") { + scope.launch { + runCatching { api.markWatched(movieId = movieId) } + onBack() + } + } + } + if (files.size > 1) { + Spacer(Modifier.height(4.dp)) + Text("Versies", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(files) { (id, label) -> + McButton(label.take(42), primary = id == files.first().first) { + onPlay(id, title, 0) + } + } + } + } + } + + if (!poster.isNullOrBlank()) { + AsyncImage( + model = poster, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 56.dp) + .width(200.dp) + .height(300.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF1A1A1A)), + contentScale = ContentScale.Crop, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SeriesDetailScreen( + api: ApiClient, + seriesId: String, + onBack: () -> Unit, + onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, +) { + val context = LocalContext.current + var title by remember { mutableStateOf("…") } + var overview by remember { mutableStateOf("") } + var backdrop by remember { mutableStateOf(null) } + var logoUrl by remember { mutableStateOf(null) } + var year by remember { mutableStateOf(null) } + var rating by remember { mutableStateOf(null) } + var genres by remember { mutableStateOf>(emptyList()) } + var trailerKey by remember { mutableStateOf(null) } + var seasons by remember { mutableStateOf>(emptyList()) } + var selectedSeason by remember { mutableIntStateOf(0) } + var error by remember { mutableStateOf(null) } + val firstEpFocus = remember { FocusRequester() } + + LaunchedEffect(seriesId) { + try { + val o = api.series(seriesId) + title = o.optString("title") + overview = o.optString("overview") + backdrop = o.optCleanString("backdropUrl") ?: o.optCleanString("posterUrl") + logoUrl = o.optCleanString("logoUrl") + year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") } + rating = o.optCleanString("rating") + genres = o.optJSONArray("genres").stringList() + trailerKey = o.optCleanString("trailerYoutubeKey") + val arr = o.optJSONArray("seasons") + val list = mutableListOf() + if (arr != null) { + for (i in 0 until arr.length()) list += arr.getJSONObject(i) + } + seasons = list + selectedSeason = list.firstOrNull()?.optInt("seasonNumber") ?: 0 + } catch (e: Exception) { + error = e.message + } + } + + val episodes = remember(seasons, selectedSeason) { + val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason } + val eps = season?.optJSONArray("episodes") ?: return@remember emptyList() + buildList { + for (i in 0 until eps.length()) { + val e = eps.getJSONObject(i) + if (!e.optBoolean("available", false)) continue + val fileId = e.optCleanString("mediaFileId") ?: continue + add( + EpisodeRow( + id = e.getString("id"), + fileId = fileId, + season = e.optInt("seasonNumber"), + number = e.optInt("episodeNumber"), + title = e.optString("title").ifBlank { "Aflevering ${e.optInt("episodeNumber")}" }, + overview = e.optString("overview"), + stillUrl = e.optCleanString("stillUrl"), + runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 }, + ) + ) + } + } + } + + LaunchedEffect(episodes) { + if (episodes.isNotEmpty()) { + delay(80) + runCatching { firstEpFocus.requestFocus() } + } + } + + val metaParts = buildList { + year?.let { add(it.toString()) } + genres.take(3).forEach(::add) + rating?.let { add("★ $it") } + } + + BackHandler(onBack = onBack) + Box(Modifier.fillMaxSize()) { + DetailBackdrop(backdrop) + LazyColumn( + contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + McButton("← Terug", onClick = onBack) + Spacer(Modifier.height(10.dp)) + DetailLogoOrTitle(logoUrl, title) + MetaChips(metaParts) + if (overview.isNotBlank()) { + Text( + overview, + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + lineHeight = 22.sp, + modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp), + ) + } + if (error != null) Text(error!!, color = McColors.Danger) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + episodes.firstOrNull()?.let { ep -> + McButton("▶ Afspelen", primary = true, focusRequester = firstEpFocus) { + onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") + } + } + if (!trailerKey.isNullOrBlank()) { + McButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } + } + } + if (seasons.size > 1) { + Spacer(Modifier.height(8.dp)) + Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(seasons) { s -> + val n = s.optInt("seasonNumber") + McButton( + "Seizoen $n", + primary = n == selectedSeason, + ) { selectedSeason = n } + } + } + } + Spacer(Modifier.height(4.dp)) + Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + itemsIndexed(episodes) { index, ep -> + EpisodeCard( + episode = ep, + focusRequester = null, + onClick = { + onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}") + }, + ) + } + } + } +} + +private data class EpisodeRow( + val id: String, + val fileId: String, + val season: Int, + val number: Int, + val title: String, + val overview: String, + val stillUrl: String?, + val runtime: Int?, +) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeCard( + episode: EpisodeRow, + focusRequester: FocusRequester?, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xCC141414), + focusedContainerColor = Color(0xFF222222), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = episode.stillUrl, + contentDescription = null, + modifier = Modifier + .width(160.dp) + .height(90.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF1A1A1A)), + contentScale = ContentScale.Crop, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "S${episode.season}E${episode.number} · ${episode.title}", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (episode.overview.isNotBlank()) { + Text( + episode.overview, + color = McColors.TextDim, + fontSize = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp, + ) + } + formatRuntime(episode.runtime)?.let { + Text(it, color = Color(0xFF888888), fontSize = 12.sp) + } + } + } + } +} \ No newline at end of file diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index b684c0c..46cc931 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -867,171 +867,6 @@ private fun ShelfBlock( } } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun MovieDetailScreen( - api: ApiClient, - movieId: String, - onBack: () -> Unit, - onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit, -) { - var title by remember { mutableStateOf("…") } - var overview by remember { mutableStateOf("") } - var backdrop by remember { mutableStateOf(null) } - var year by remember { mutableStateOf(null) } - var files by remember { mutableStateOf>>(emptyList()) } - var error by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val playFocus = remember { FocusRequester() } - - LaunchedEffect(movieId) { - try { - val o = api.movie(movieId) - title = o.optString("title") - overview = o.optString("overview") - backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } } - year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") } - val arr = o.optJSONArray("mediaFiles") - val list = mutableListOf>() - if (arr != null) { - for (i in 0 until arr.length()) { - val f = arr.getJSONObject(i) - val fromApi = f.optCleanString("label") - val label = fromApi ?: buildList { - f.optCleanString("resolution")?.let(::add) - f.optCleanString("hdrType")?.let(::add) - f.optCleanString("videoCodec")?.let(::add) - f.optCleanString("audioCodec")?.let(::add) - f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) } - f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) } - }.joinToString(" · ").ifBlank { "Versie ${i + 1}" } - list += f.getString("id") to label - } - } - files = list - } catch (e: Exception) { - error = e.message - } - } - - LaunchedEffect(files) { - if (files.isNotEmpty()) { - delay(80) - runCatching { playFocus.requestFocus() } - } - } - - BackHandler(onBack = onBack) - Box(Modifier.fillMaxSize().background(McColors.Bg)) { - AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent)))) - Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg)))) - Column( - Modifier.fillMaxHeight().fillMaxWidth(0.58f).padding(40.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - McButton("Terug", onClick = onBack) - Text(title, color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3) - if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp) - Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp) - if (error != null) Text(error!!, color = McColors.Danger) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - files.firstOrNull()?.let { (id, _) -> - McButton("Afspelen", primary = true, focusRequester = playFocus) { onPlay(id, title, 0) } - } - McButton("Als gekeken") { - scope.launch { - runCatching { api.markWatched(movieId = movieId) } - onBack() - } - } - } - if (files.isNotEmpty()) { - Text("Versies", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(files) { (id, label) -> - McButton(label.take(48), primary = id == files.first().first) { onPlay(id, title, 0) } - } - } - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SeriesDetailScreen( - api: ApiClient, - seriesId: String, - onBack: () -> Unit, - onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit, -) { - var title by remember { mutableStateOf("…") } - var overview by remember { mutableStateOf("") } - var backdrop by remember { mutableStateOf(null) } - var episodes by remember { mutableStateOf>>(emptyList()) } - var error by remember { mutableStateOf(null) } - val firstEpFocus = remember { FocusRequester() } - - LaunchedEffect(seriesId) { - try { - val o = api.series(seriesId) - title = o.optString("title") - overview = o.optString("overview") - backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } } - val seasons = o.optJSONArray("seasons") - val list = mutableListOf>() - if (seasons != null) { - for (si in 0 until seasons.length()) { - val season = seasons.getJSONObject(si) - val eps = season.optJSONArray("episodes") ?: continue - for (ei in 0 until eps.length()) { - val e = eps.getJSONObject(ei) - if (!e.optBoolean("available", false)) continue - val fileId = e.optString("mediaFileId").ifBlank { null } ?: continue - val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} ${e.optString("title")}" - list += Triple(e.getString("id"), label, fileId) - } - } - } - episodes = list - } catch (e: Exception) { - error = e.message - } - } - - LaunchedEffect(episodes) { - if (episodes.isNotEmpty()) { - delay(80) - runCatching { firstEpFocus.requestFocus() } - } - } - - BackHandler(onBack = onBack) - Box(Modifier.fillMaxSize().background(McColors.Bg)) { - AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f) - Box(Modifier.fillMaxSize().background(McColors.HeroScrim)) - LazyColumn(contentPadding = PaddingValues(40.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - item { - McButton("Terug", onClick = onBack) - Spacer(Modifier.height(12.dp)) - Text(title, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold) - Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis) - if (error != null) Text(error!!, color = McColors.Danger) - Spacer(Modifier.height(16.dp)) - Text("Afleveringen", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) - } - itemsIndexed(episodes) { index, (id, label, fileId) -> - McButton( - label, - primary = index == 0, - focusRequester = if (index == 0) firstEpFocus else null, - ) { onPlay(fileId, id, "$title · $label") } - } - } - } -} - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SettingsScreen( diff --git a/apps/master-api/prisma/migrations/20260901020000_movie_logo_url/migration.sql b/apps/master-api/prisma/migrations/20260901020000_movie_logo_url/migration.sql new file mode 100644 index 0000000..8b9dc05 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260901020000_movie_logo_url/migration.sql @@ -0,0 +1,2 @@ +-- Clear logo for movie detail pages (same as series.logo_url). +ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "logo_url" TEXT; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 88376ea..0a889c3 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -342,6 +342,7 @@ model Movie { overview String? posterUrl String? @map("poster_url") backdropUrl String? @map("backdrop_url") + logoUrl String? @map("logo_url") genres String[] @default([]) runtime Int? imdbId String? @unique @map("imdb_id") diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 17aa363..3ac2c22 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -97,6 +97,9 @@ async function main() { await prisma.$executeRawUnsafe( `ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT` ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "logo_url" TEXT` + ); } catch (err) { console.warn("Could not ensure still_url/logo_url columns:", err); } diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 9a6d801..f68b55d 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -13,6 +13,7 @@ import type { FastifyRequest } from "fastify"; import { cleanMeta, formatMediaFileLabel } from "./media-label"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; +import { MetadataService } from "../metadata/service"; const CODE_TTL_MS = 10 * 60 * 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -42,11 +43,15 @@ export function hashLinkCode(raw: string): string { } export class ViewerService { + private readonly metadata: MetadataService; + constructor( private readonly config: Config, private readonly playback: PlaybackService, private readonly downloads?: DownloadService - ) {} + ) { + this.metadata = new MetadataService(config.TMDB_API_KEY); + } async createViewer(input: { email: string; @@ -1082,6 +1087,20 @@ export class ViewerService { }, }); if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404); + + const extras = await this.metadata.getStremioMetaExtras( + "movie", + movie.tmdbId, + movie.imdbId, + movie.logoUrl + ); + const logoUrl = extras.logo ? extras.logo.replace("/w300/", "/w500/") : null; + if (logoUrl && logoUrl !== movie.logoUrl) { + void prisma.movie + .update({ where: { id: movie.id }, data: { logoUrl } }) + .catch(() => undefined); + } + return { type: "movie" as const, id: movie.id, @@ -1090,8 +1109,13 @@ export class ViewerService { overview: movie.overview, posterUrl: movie.posterUrl, backdropUrl: movie.backdropUrl, - runtime: movie.runtime, - genres: movie.genres, + logoUrl, + runtime: movie.runtime ?? extras.runtimeMinutes ?? null, + genres: (movie.genres?.length ? movie.genres : extras.genres) ?? [], + rating: extras.imdbRating ?? null, + cast: extras.cast ?? [], + director: extras.director ?? [], + trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, mediaFiles: movie.mediaFiles.map((f, index) => ({ id: f.id, resolution: cleanMeta(f.resolution), @@ -1138,6 +1162,24 @@ export class ViewerService { }, }); if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404); + + const extras = await this.metadata.getStremioMetaExtras( + "series", + series.tmdbId, + series.imdbId, + series.logoUrl + ); + const logoUrl = extras.logo + ? extras.logo.replace("/w300/", "/w500/") + : series.logoUrl + ? series.logoUrl.replace("/w300/", "/w500/") + : null; + if (logoUrl && logoUrl !== series.logoUrl) { + void prisma.series + .update({ where: { id: series.id }, data: { logoUrl } }) + .catch(() => undefined); + } + return { type: "series" as const, id: series.id, @@ -1146,6 +1188,12 @@ export class ViewerService { overview: series.overview, posterUrl: series.posterUrl, backdropUrl: series.backdropUrl, + logoUrl, + genres: (series.genres?.length ? series.genres : extras.genres) ?? [], + rating: extras.imdbRating ?? null, + cast: extras.cast ?? [], + director: extras.director ?? [], + trailerYoutubeKey: extras.trailers?.[0]?.source ?? null, seasons: series.seasons.map((s) => ({ seasonNumber: s.seasonNumber, episodes: s.episodes.map((e) => ({