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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(
|
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?
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue