TV client: LibVLC for full audio, richer home rows, playback track picker.

This commit is contained in:
Jos Vooges | STH 2026-08-31 01:41:05 +02:00
parent 09be3be3a5
commit 700947d2e7
8 changed files with 459 additions and 237 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 = 2 versionCode = 3
versionName = "0.2.0" versionName = "0.3.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
} }
@ -38,6 +38,14 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
packaging {
jniLibs {
useLegacyPackaging = true
}
resources {
excludes += setOf("META-INF/{AL2.0,LGPL2.1}")
}
}
} }
dependencies { dependencies {
@ -53,9 +61,8 @@ dependencies {
implementation("androidx.tv:tv-foundation:1.0.0-alpha11") implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0") implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.5.1") // LibVLC: speelt DTS/TrueHD/HDR-remuxes met geluid op Shield
implementation("androidx.media3:media3-ui:1.5.1") implementation("org.videolan.android:libvlc-all:3.6.0")
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

View file

@ -270,7 +270,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }, year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
posterUrl = o.optString("posterUrl").ifBlank { null }, posterUrl = o.optString("posterUrl").ifBlank { null },
backdropUrl = o.optString("backdropUrl").ifBlank { null }, backdropUrl = o.optString("backdropUrl").ifBlank { null },
mediaFileId = o.optString("mediaFileId").ifBlank { null }, mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null },
positionMs = o.optLong("positionMs"), positionMs = o.optLong("positionMs"),
durationMs = o.optLong("durationMs"), durationMs = o.optLong("durationMs"),
progressId = o.optString("progressId").ifBlank { null }, progressId = o.optString("progressId").ifBlank { null },

View file

@ -1,88 +0,0 @@
package nl.vonas.mediacluster.tv.playback
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
/** ExoPlayer tuned for Shield / Android TV direct play + audible output. */
fun buildTvPlayer(context: Context): ExoPlayer {
val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters()
.setPreferredAudioMimeTypes(
MimeTypes.AUDIO_E_AC3,
MimeTypes.AUDIO_AC3,
MimeTypes.AUDIO_AAC,
MimeTypes.AUDIO_MPEG,
MimeTypes.AUDIO_OPUS,
)
.setAllowAudioMixedMimeTypeAdaptiveness(true)
.setExceedRendererCapabilitiesIfNecessary(true)
.build()
}
val renderersFactory = DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
return ExoPlayer.Builder(context, renderersFactory)
.setTrackSelector(trackSelector)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setSeekBackIncrementMs(10_000)
.setSeekForwardIncrementMs(30_000)
.build()
.also { player ->
player.volume = 1f
player.playWhenReady = true
}
}
/** Pick the most playable audio track (avoid silent DTS/TrueHD when alternatives exist). */
fun ExoPlayer.selectBestAudioTrack() {
val groups = currentTracks.groups
var bestGroup: androidx.media3.common.Tracks.Group? = null
var bestTrackIndex = -1
var bestScore = -1
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != C.TRACK_TYPE_AUDIO) continue
for (ti in 0 until group.length) {
if (!group.isTrackSupported(ti)) continue
val format = group.getTrackFormat(ti)
val mime = (format.sampleMimeType ?: "").lowercase()
val score = when {
"eac3" in mime || "ec-3" in mime -> 90
"ac3" in mime || "ac-3" in mime -> 80
"mp4a" in mime || "aac" in mime -> 70
"mpeg" in mime -> 60
"opus" in mime -> 50
"dts" in mime || "truehd" in mime || "mlp" in mime -> 15
else -> 35
} + format.channelCount.coerceIn(0, 8)
if (score > bestScore) {
bestScore = score
bestGroup = group
bestTrackIndex = ti
}
}
}
val group = bestGroup ?: return
if (bestTrackIndex < 0) return
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, listOf(bestTrackIndex)))
.build()
volume = 1f
}

View file

@ -378,7 +378,7 @@ private fun HeroHeader(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(360.dp), .height(420.dp),
) { ) {
AsyncImage( AsyncImage(
model = hero?.backdropUrl ?: hero?.posterUrl, model = hero?.backdropUrl ?: hero?.posterUrl,

View file

@ -1,40 +1,62 @@
package nl.vonas.mediacluster.tv.ui package nl.vonas.mediacluster.tv.ui
import android.view.LayoutInflater import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.R
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.buildTvPlayer import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.playback.selectBestAudioTrack
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout
data class VlcTrack(val id: Int, val name: String)
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -49,48 +71,79 @@ fun PlayerScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var buffering by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var controlsVisible by remember { mutableStateOf(true) }
var playing by remember { mutableStateOf(true) }
var positionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) }
var spuTracks by remember { mutableStateOf<List<VlcTrack>>(emptyList()) }
var trackMenu by remember { mutableStateOf<String?>(null) } // "audio" | "spu"
val player = remember { val libVlc = remember {
buildTvPlayer(context) LibVLC(
context,
arrayListOf(
"--aout=android_audiotrack",
"--audio-time-stretch",
"--network-caching=2500",
"--file-caching=2500",
"--avcodec-skiploopfilter=4",
"--android-display-chroma=RV32",
),
)
} }
val mediaPlayer = remember { MediaPlayer(libVlc) }
BackHandler(onBack = onBack) BackHandler {
if (trackMenu != null) trackMenu = null
else if (controlsVisible) onBack()
else controlsVisible = true
}
LaunchedEffect(mediaFileId) { LaunchedEffect(mediaFileId) {
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) val media = Media(libVlc, Uri.parse(play.streamUrl))
player.prepare() media.setHWDecoderEnabled(true, false)
if (startMs > 5_000) player.seekTo(startMs) media.addOption(":network-caching=2500")
player.volume = 1f mediaPlayer.media = media
player.play() media.release()
mediaPlayer.play()
if (startMs > 5_000) {
delay(600)
mediaPlayer.time = startMs
}
loading = false
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Afspelen mislukt" error = e.message ?: "Afspelen mislukt"
buffering = false loading = false
} }
} }
DisposableEffect(player) { DisposableEffect(mediaPlayer) {
val listener = object : Player.Listener { val listener = MediaPlayer.EventListener { event ->
override fun onPlaybackStateChanged(playbackState: Int) { when (event.type) {
buffering = playbackState == Player.STATE_BUFFERING MediaPlayer.Event.Playing -> {
if (playbackState == Player.STATE_READY) { playing = true
player.selectBestAudioTrack() loading = false
player.volume = 1f audioTracks = mediaPlayer.audioTracks
?.map { VlcTrack(it.id, it.name ?: "Audio ${it.id}") }
?.filter { it.id >= 0 }
.orEmpty()
spuTracks = mediaPlayer.spuTracks
?.map { VlcTrack(it.id, it.name ?: "Ondertitel ${it.id}") }
.orEmpty()
} }
} MediaPlayer.Event.Paused -> playing = false
MediaPlayer.Event.Buffering -> loading = event.buffering < 100f
override fun onPlayerError(e: PlaybackException) { MediaPlayer.Event.EncounteredError -> error = "Afspeelfout (codec/stream)"
error = e.message ?: "Spelerfout" MediaPlayer.Event.EndReached -> onBack()
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
player.selectBestAudioTrack()
} }
} }
player.addListener(listener) mediaPlayer.setEventListener(listener)
onDispose { onDispose {
try { try {
runBlocking { runBlocking {
@ -99,29 +152,39 @@ fun PlayerScreen(
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = mediaPlayer.time.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0), durationMs = mediaPlayer.length.coerceAtLeast(0),
) )
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
player.removeListener(listener) mediaPlayer.stop()
player.release() mediaPlayer.detachViews()
mediaPlayer.release()
libVlc.release()
} }
} }
LaunchedEffect(player) { LaunchedEffect(mediaPlayer) {
while (true) {
delay(500)
positionMs = mediaPlayer.time.coerceAtLeast(0)
durationMs = mediaPlayer.length.coerceAtLeast(0)
}
}
LaunchedEffect(mediaPlayer) {
while (true) { while (true) {
delay(12_000) delay(12_000)
if (player.isPlaying || player.playbackState == Player.STATE_READY) { if (mediaPlayer.isPlaying) {
try { try {
api.postProgress( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = mediaPlayer.time.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0), durationMs = mediaPlayer.length.coerceAtLeast(0),
) )
} catch (_: Exception) { } catch (_: Exception) {
} }
@ -129,50 +192,186 @@ fun PlayerScreen(
} }
} }
Box(Modifier.fillMaxSize().background(Color.Black)) { LaunchedEffect(controlsVisible, trackMenu) {
if (controlsVisible && trackMenu == null && playing) {
delay(4_500)
controlsVisible = false
}
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.focusable()
.onKeyEvent { ev ->
if (ev.type != KeyEventType.KeyUp) return@onKeyEvent false
when (ev.key) {
Key.DirectionCenter, Key.Enter -> {
controlsVisible = true
if (mediaPlayer.isPlaying) mediaPlayer.pause() else mediaPlayer.play()
true
}
Key.DirectionLeft -> {
controlsVisible = true
mediaPlayer.time = (mediaPlayer.time - 10_000).coerceAtLeast(0)
true
}
Key.DirectionRight -> {
controlsVisible = true
mediaPlayer.time = mediaPlayer.time + 30_000
true
}
Key.Menu, Key.ButtonY -> {
controlsVisible = true
trackMenu = "audio"
true
}
else -> false
}
},
) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
val root = LayoutInflater.from(ctx).inflate(R.layout.mc_player_view, null, false) as PlayerView val layout = VLCVideoLayout(ctx)
root.layoutParams = FrameLayout.LayoutParams( layout.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
) )
root.player = player mediaPlayer.attachViews(layout, null, false, false)
root.controllerShowTimeoutMs = 4_500 layout
root.setShowNextButton(false)
root.setShowPreviousButton(false)
root.setShowFastForwardButton(true)
root.setShowRewindButton(true)
root.setShowSubtitleButton(true)
root.useController = true
root.setControllerHideOnTouch(true)
// Request focus so remote OK toggles controls
root.requestFocus()
root
},
update = { view ->
if (view.player !== player) view.player = player
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
if (buffering && error == null) { if (loading && error == null) {
Text( Text("Laden…", color = McColors.TextDim, fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
"Laden…",
color = McColors.TextDim,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center),
)
} }
if (error != null) { if (error != null) {
Text( Column(
error!!, Modifier.align(Alignment.Center).padding(32.dp),
color = McColors.Danger, horizontalAlignment = Alignment.CenterHorizontally,
fontSize = 18.sp, verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier ) {
.align(Alignment.Center) Text(error!!, color = McColors.Danger, fontSize = 18.sp)
.padding(32.dp), McButton("Terug", onClick = onBack)
) }
}
if (controlsVisible && error == null) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0x66000000), Color.Transparent, Color(0xEE000000)),
),
),
) {
Row(
Modifier
.align(Alignment.TopStart)
.padding(28.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
McButton("Terug", onClick = onBack)
}
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Progress
Box(
Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color(0x44FFFFFF)),
) {
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Box(
Modifier
.fillMaxWidth(pct)
.height(6.dp)
.background(McColors.Accent),
)
}
Text(
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
color = McColors.TextDim,
fontSize = 13.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
McButton(if (playing) "Pauze" else "Play", primary = true) {
if (mediaPlayer.isPlaying) mediaPlayer.pause() else mediaPlayer.play()
}
McButton("-10s") { mediaPlayer.time = (mediaPlayer.time - 10_000).coerceAtLeast(0) }
McButton("+30s") { mediaPlayer.time = mediaPlayer.time + 30_000 }
McButton("Audio (${audioTracks.size})") { trackMenu = "audio" }
McButton("Ondertitel") { trackMenu = "spu" }
}
}
}
}
if (trackMenu != null) {
val isAudio = trackMenu == "audio"
val tracks = if (isAudio) audioTracks else spuTracks
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(480.dp)
.clip(RoundedCornerShape(18.dp))
.background(McColors.BgElevated)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (isAudio) "Audiospoor" else "Ondertitel",
color = McColors.Text,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
if (!isAudio) {
McButton("Uit") {
mediaPlayer.spuTrack = -1
trackMenu = null
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(280.dp)) {
items(tracks, key = { it.id }) { track ->
McButton(track.name) {
if (isAudio) mediaPlayer.audioTrack = track.id
else mediaPlayer.spuTrack = track.id
trackMenu = null
scope.launch {
delay(100)
controlsVisible = true
}
}
}
}
McButton("Sluiten") { trackMenu = null }
}
}
} }
} }
} }
private fun formatTime(ms: Long): String {
if (ms <= 0) return "0:00"
val totalSec = ms / 1000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}

View file

@ -68,8 +68,8 @@ fun PosterCard(
item: MediaItem, item: MediaItem,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
width: Dp = 148.dp, width: Dp = 160.dp,
height: Dp = 222.dp, height: Dp = 240.dp,
) { ) {
val progress = if (item.durationMs > 0) { val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f) (item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:keepScreenOn="true"
app:auto_show="true"
app:hide_on_touch="true"
app:resize_mode="fit"
app:show_buffering="when_playing"
app:show_subtitle_button="true"
app:show_timeout="4500"
app:surface_type="surface_view"
app:use_controller="true" />

View file

@ -289,13 +289,16 @@ export class ViewerService {
} }
async home(viewerId: string) { async home(viewerId: string) {
const [shelves, prefs, continueItems] = await Promise.all([ const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([
prisma.libraryShelf.findMany({ prisma.libraryShelf.findMany({
where: { enabled: true }, where: { enabled: true },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
}), }),
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }), prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
this.continueWatching(viewerId), this.continueWatching(viewerId),
this.recentMovies(48),
this.recentSeries(36),
this.fourKMovies(36),
]); ]);
const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
@ -306,17 +309,52 @@ export class ViewerService {
id: s.id, id: s.id,
name: s.name, name: s.name,
slug: s.slug, slug: s.slug,
kind: s.kind, kind: s.kind as "MOVIE" | "SERIES",
visible: pref?.visible ?? true, visible: pref?.visible ?? true,
sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx, sortOrder: pref?.sortOrder ?? 1000 + s.sortOrder * 10 + idx,
}; };
}) })
.filter((s) => s.visible) .filter((s) => s.visible)
.sort((a, b) => a.sortOrder - b.sortOrder); .sort((a, b) => a.sortOrder - b.sortOrder);
const rows = []; const rows: Array<{
id: string;
title: string;
kind: "MOVIE" | "SERIES";
slug: string;
items: Awaited<ReturnType<ViewerService["shelfItems"]>>;
}> = [];
if (recentMovies.length) {
rows.push({
id: "system:recent-movies",
title: "Recent toegevoegd",
kind: "MOVIE",
slug: "recent-movies",
items: recentMovies,
});
}
if (recentSeries.length) {
rows.push({
id: "system:recent-series",
title: "Recent toegevoegde series",
kind: "SERIES",
slug: "recent-series",
items: recentSeries,
});
}
if (fourK.length) {
rows.push({
id: "system:4k-movies",
title: "4K films",
kind: "MOVIE",
slug: "4k-movies",
items: fourK,
});
}
for (const shelf of ordered) { for (const shelf of ordered) {
const items = await this.shelfItems(shelf.id, shelf.kind, 24); const items = await this.shelfItems(shelf.id, shelf.kind, 48);
if (items.length) { if (items.length) {
rows.push({ rows.push({
id: shelf.id, id: shelf.id,
@ -334,56 +372,138 @@ export class ViewerService {
}; };
} }
async recentMovies(take: number) {
const movies = await prisma.movie.findMany({
where: { mediaFiles: { some: { available: true } } },
orderBy: { createdAt: "desc" },
take,
include: {
mediaFiles: {
where: { available: true },
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
return movies.map((m) => ({
type: "movie" as const,
id: m.id,
title: m.title,
year: m.year,
posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null,
}));
}
async recentSeries(take: number) {
const series = await prisma.series.findMany({
where: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
orderBy: { createdAt: "desc" },
take,
});
return series.map((s) => ({
type: "series" as const,
id: s.id,
title: s.title,
year: s.year,
posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
}));
}
async fourKMovies(take: number) {
const movies = await prisma.movie.findMany({
where: {
mediaFiles: {
some: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
{ resolution: { contains: "UHD", mode: "insensitive" } },
],
},
},
},
orderBy: { createdAt: "desc" },
take,
include: {
mediaFiles: {
where: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
{ resolution: { contains: "UHD", mode: "insensitive" } },
],
},
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
return movies.map((m) => ({
type: "movie" as const,
id: m.id,
title: m.title,
year: m.year,
posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null,
}));
}
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
if (kind === "MOVIE") { if (kind === "MOVIE") {
const files = await prisma.mediaFile.findMany({ const movies = await prisma.movie.findMany({
where: { shelfId, available: true, movieId: { not: null } }, where: {
include: { movie: true }, mediaFiles: { some: { shelfId, available: true } },
orderBy: { updatedAt: "desc" }, },
take: take * 3, orderBy: { createdAt: "desc" },
take,
include: {
mediaFiles: {
where: { shelfId, available: true },
orderBy: { createdAt: "desc" },
take: 1,
},
},
}); });
const seen = new Set<string>(); return movies.map((m) => ({
const out = []; type: "movie" as const,
for (const f of files) { id: m.id,
if (!f.movie || seen.has(f.movie.id)) continue; title: m.title,
seen.add(f.movie.id); year: m.year,
out.push({ posterUrl: m.posterUrl,
type: "movie" as const, backdropUrl: m.backdropUrl,
id: f.movie.id, mediaFileId: m.mediaFiles[0]?.id ?? null,
title: f.movie.title, }));
year: f.movie.year,
posterUrl: f.movie.posterUrl,
backdropUrl: f.movie.backdropUrl,
mediaFileId: f.id,
});
if (out.length >= take) break;
}
return out;
} }
const files = await prisma.mediaFile.findMany({ const series = await prisma.series.findMany({
where: { shelfId, available: true, episodeId: { not: null } }, where: {
include: { episode: { include: { series: true } } }, episodes: {
orderBy: { updatedAt: "desc" }, some: {
take: take * 5, mediaFiles: { some: { shelfId, available: true } },
},
},
},
orderBy: { createdAt: "desc" },
take,
}); });
const seen = new Set<string>(); return series.map((s) => ({
const out = []; type: "series" as const,
for (const f of files) { id: s.id,
const series = f.episode?.series; title: s.title,
if (!series || seen.has(series.id)) continue; year: s.year,
seen.add(series.id); posterUrl: s.posterUrl,
out.push({ backdropUrl: s.backdropUrl,
type: "series" as const, mediaFileId: null as string | null,
id: series.id, }));
title: series.title,
year: series.year,
posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl,
});
if (out.length >= take) break;
}
return out;
} }
async continueWatching(viewerId: string) { async continueWatching(viewerId: string) {