Rich movie and series detail pages with logo, trailer, and meta.

API returns clear logos, cast, rating and YouTube trailers; TV detail screens use a Netflix-style hero layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 03:55:25 +02:00
parent 47dcbafa87
commit 6c96b0ca01
7 changed files with 651 additions and 170 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 15 versionCode = 16
versionName = "0.7.8" versionName = "0.7.9"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -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<String> {
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<String>) {
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<String?>(null) }
var poster by remember { mutableStateOf<String?>(null) }
var logoUrl by remember { mutableStateOf<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var runtime by remember { mutableStateOf<Int?>(null) }
var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
var director by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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<Pair<String, String>>()
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<String?>(null) }
var logoUrl by remember { mutableStateOf<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by remember { mutableIntStateOf(0) }
var error by remember { mutableStateOf<String?>(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<JSONObject>()
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)
}
}
}
}
}

View file

@ -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<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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<Pair<String, String>>()
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<String?>(null) }
var episodes by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(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<Triple<String, String, String>>()
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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(

View file

@ -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;

View file

@ -342,6 +342,7 @@ model Movie {
overview String? overview String?
posterUrl String? @map("poster_url") posterUrl String? @map("poster_url")
backdropUrl String? @map("backdrop_url") backdropUrl String? @map("backdrop_url")
logoUrl String? @map("logo_url")
genres String[] @default([]) genres String[] @default([])
runtime Int? runtime Int?
imdbId String? @unique @map("imdb_id") imdbId String? @unique @map("imdb_id")

View file

@ -97,6 +97,9 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT` `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) { } catch (err) {
console.warn("Could not ensure still_url/logo_url columns:", err); console.warn("Could not ensure still_url/logo_url columns:", err);
} }

View file

@ -13,6 +13,7 @@ import type { FastifyRequest } from "fastify";
import { cleanMeta, formatMediaFileLabel } from "./media-label"; import { cleanMeta, formatMediaFileLabel } from "./media-label";
import type { DownloadService } from "../downloads/service"; import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client"; import type { SubtitleSource } from "../opensubtitles/client";
import { MetadataService } from "../metadata/service";
const CODE_TTL_MS = 10 * 60 * 1000; const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -42,11 +43,15 @@ export function hashLinkCode(raw: string): string {
} }
export class ViewerService { export class ViewerService {
private readonly metadata: MetadataService;
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly playback: PlaybackService, private readonly playback: PlaybackService,
private readonly downloads?: DownloadService private readonly downloads?: DownloadService
) {} ) {
this.metadata = new MetadataService(config.TMDB_API_KEY);
}
async createViewer(input: { async createViewer(input: {
email: string; email: string;
@ -1082,6 +1087,20 @@ export class ViewerService {
}, },
}); });
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404); 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 { return {
type: "movie" as const, type: "movie" as const,
id: movie.id, id: movie.id,
@ -1090,8 +1109,13 @@ export class ViewerService {
overview: movie.overview, overview: movie.overview,
posterUrl: movie.posterUrl, posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl, backdropUrl: movie.backdropUrl,
runtime: movie.runtime, logoUrl,
genres: movie.genres, 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) => ({ mediaFiles: movie.mediaFiles.map((f, index) => ({
id: f.id, id: f.id,
resolution: cleanMeta(f.resolution), resolution: cleanMeta(f.resolution),
@ -1138,6 +1162,24 @@ export class ViewerService {
}, },
}); });
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404); 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 { return {
type: "series" as const, type: "series" as const,
id: series.id, id: series.id,
@ -1146,6 +1188,12 @@ export class ViewerService {
overview: series.overview, overview: series.overview,
posterUrl: series.posterUrl, posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl, 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) => ({ seasons: series.seasons.map((s) => ({
seasonNumber: s.seasonNumber, seasonNumber: s.seasonNumber,
episodes: s.episodes.map((e) => ({ episodes: s.episodes.map((e) => ({