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:
parent
47dcbafa87
commit
6c96b0ca01
7 changed files with 651 additions and 170 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
Loading…
Reference in a new issue