From 700947d2e740d155259222e4f547d5fea15433a8 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 31 Aug 2026 01:41:05 +0200 Subject: [PATCH] TV client: LibVLC for full audio, richer home rows, playback track picker. --- apps/android-tv/app/build.gradle.kts | 17 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 2 +- .../mediacluster/tv/playback/PlayerFactory.kt | 88 ----- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 2 +- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 349 ++++++++++++++---- .../tv/ui/components/Components.kt | 4 +- .../src/main/res/layout/mc_player_view.xml | 16 - apps/master-api/src/viewer/service.ts | 218 ++++++++--- 8 files changed, 459 insertions(+), 237 deletions(-) delete mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt delete mode 100644 apps/android-tv/app/src/main/res/layout/mc_player_view.xml diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 01e8862..d544704 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.3.0" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") } @@ -38,6 +38,14 @@ android { compose = true buildConfig = true } + packaging { + jniLibs { + useLegacyPackaging = true + } + resources { + excludes += setOf("META-INF/{AL2.0,LGPL2.1}") + } + } } dependencies { @@ -53,9 +61,8 @@ dependencies { implementation("androidx.tv:tv-foundation:1.0.0-alpha11") implementation("androidx.tv:tv-material:1.0.0") - implementation("androidx.media3:media3-exoplayer:1.5.1") - implementation("androidx.media3:media3-ui:1.5.1") - implementation("androidx.media3:media3-datasource-okhttp:1.5.1") + // LibVLC: speelt DTS/TrueHD/HDR-remuxes met geluid op Shield + implementation("org.videolan.android:libvlc-all:3.6.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 90b512e..0981a00 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -270,7 +270,7 @@ private fun JSONArray?.toItems(): List { year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }, posterUrl = o.optString("posterUrl").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"), durationMs = o.optLong("durationMs"), progressId = o.optString("progressId").ifBlank { null }, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt deleted file mode 100644 index 75f06bc..0000000 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ /dev/null @@ -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 -} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 7f67bc9..1534a7f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -378,7 +378,7 @@ private fun HeroHeader( Box( Modifier .fillMaxWidth() - .height(360.dp), + .height(420.dp), ) { AsyncImage( model = hero?.backdropUrl ?: hero?.posterUrl, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index e6bde2d..bf9bd75 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -1,40 +1,62 @@ package nl.vonas.mediacluster.tv.ui -import android.view.LayoutInflater +import android.net.Uri import android.view.ViewGroup import android.widget.FrameLayout import androidx.activity.compose.BackHandler 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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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.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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf 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.graphics.Brush 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.Text import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import nl.vonas.mediacluster.tv.R import nl.vonas.mediacluster.tv.data.ApiClient -import nl.vonas.mediacluster.tv.playback.buildTvPlayer -import nl.vonas.mediacluster.tv.playback.selectBestAudioTrack +import nl.vonas.mediacluster.tv.ui.components.McButton 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) @Composable @@ -49,48 +71,79 @@ fun PlayerScreen( onBack: () -> Unit, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() var error by remember { mutableStateOf(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>(emptyList()) } + var spuTracks by remember { mutableStateOf>(emptyList()) } + var trackMenu by remember { mutableStateOf(null) } // "audio" | "spu" - val player = remember { - buildTvPlayer(context) + val libVlc = remember { + 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) { try { val play = api.play(mediaFileId) - player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) - player.prepare() - if (startMs > 5_000) player.seekTo(startMs) - player.volume = 1f - player.play() + val media = Media(libVlc, Uri.parse(play.streamUrl)) + media.setHWDecoderEnabled(true, false) + media.addOption(":network-caching=2500") + mediaPlayer.media = media + media.release() + mediaPlayer.play() + if (startMs > 5_000) { + delay(600) + mediaPlayer.time = startMs + } + loading = false } catch (e: Exception) { error = e.message ?: "Afspelen mislukt" - buffering = false + loading = false } } - DisposableEffect(player) { - val listener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - buffering = playbackState == Player.STATE_BUFFERING - if (playbackState == Player.STATE_READY) { - player.selectBestAudioTrack() - player.volume = 1f + DisposableEffect(mediaPlayer) { + val listener = MediaPlayer.EventListener { event -> + when (event.type) { + MediaPlayer.Event.Playing -> { + playing = true + loading = false + 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() } - } - - override fun onPlayerError(e: PlaybackException) { - error = e.message ?: "Spelerfout" - } - - override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { - player.selectBestAudioTrack() + MediaPlayer.Event.Paused -> playing = false + MediaPlayer.Event.Buffering -> loading = event.buffering < 100f + MediaPlayer.Event.EncounteredError -> error = "Afspeelfout (codec/stream)" + MediaPlayer.Event.EndReached -> onBack() } } - player.addListener(listener) + mediaPlayer.setEventListener(listener) onDispose { try { runBlocking { @@ -99,29 +152,39 @@ fun PlayerScreen( movieId = movieId, episodeId = episodeId, seriesId = seriesId, - positionMs = player.currentPosition.coerceAtLeast(0), - durationMs = player.duration.coerceAtLeast(0), + positionMs = mediaPlayer.time.coerceAtLeast(0), + durationMs = mediaPlayer.length.coerceAtLeast(0), ) } } catch (_: Exception) { } - player.removeListener(listener) - player.release() + mediaPlayer.stop() + 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) { delay(12_000) - if (player.isPlaying || player.playbackState == Player.STATE_READY) { + if (mediaPlayer.isPlaying) { try { api.postProgress( mediaType = mediaType, movieId = movieId, episodeId = episodeId, seriesId = seriesId, - positionMs = player.currentPosition.coerceAtLeast(0), - durationMs = player.duration.coerceAtLeast(0), + positionMs = mediaPlayer.time.coerceAtLeast(0), + durationMs = mediaPlayer.length.coerceAtLeast(0), ) } 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( factory = { ctx -> - val root = LayoutInflater.from(ctx).inflate(R.layout.mc_player_view, null, false) as PlayerView - root.layoutParams = FrameLayout.LayoutParams( + val layout = VLCVideoLayout(ctx) + layout.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - root.player = player - root.controllerShowTimeoutMs = 4_500 - 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 + mediaPlayer.attachViews(layout, null, false, false) + layout }, modifier = Modifier.fillMaxSize(), ) - if (buffering && error == null) { - Text( - "Laden…", - color = McColors.TextDim, - fontSize = 16.sp, - modifier = Modifier.align(Alignment.Center), - ) + if (loading && error == null) { + Text("Laden…", color = McColors.TextDim, fontSize = 18.sp, modifier = Modifier.align(Alignment.Center)) } if (error != null) { - Text( - error!!, - color = McColors.Danger, - fontSize = 18.sp, - modifier = Modifier - .align(Alignment.Center) - .padding(32.dp), - ) + Column( + Modifier.align(Alignment.Center).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(error!!, color = McColors.Danger, fontSize = 18.sp) + 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) +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index 6dd286d..4931364 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -68,8 +68,8 @@ fun PosterCard( item: MediaItem, onClick: () -> Unit, modifier: Modifier = Modifier, - width: Dp = 148.dp, - height: Dp = 222.dp, + width: Dp = 160.dp, + height: Dp = 240.dp, ) { val progress = if (item.durationMs > 0) { (item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f) diff --git a/apps/android-tv/app/src/main/res/layout/mc_player_view.xml b/apps/android-tv/app/src/main/res/layout/mc_player_view.xml deleted file mode 100644 index 22c4d74..0000000 --- a/apps/android-tv/app/src/main/res/layout/mc_player_view.xml +++ /dev/null @@ -1,16 +0,0 @@ - - diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 1a2fa17..cbaf3f8 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -289,13 +289,16 @@ export class ViewerService { } async home(viewerId: string) { - const [shelves, prefs, continueItems] = await Promise.all([ + const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([ prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }), prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }), this.continueWatching(viewerId), + this.recentMovies(48), + this.recentSeries(36), + this.fourKMovies(36), ]); const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); @@ -306,17 +309,52 @@ export class ViewerService { id: s.id, name: s.name, slug: s.slug, - kind: s.kind, + kind: s.kind as "MOVIE" | "SERIES", visible: pref?.visible ?? true, - sortOrder: pref?.sortOrder ?? s.sortOrder * 10 + idx, + sortOrder: pref?.sortOrder ?? 1000 + s.sortOrder * 10 + idx, }; }) .filter((s) => s.visible) .sort((a, b) => a.sortOrder - b.sortOrder); - const rows = []; + const rows: Array<{ + id: string; + title: string; + kind: "MOVIE" | "SERIES"; + slug: string; + items: Awaited>; + }> = []; + + 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) { - const items = await this.shelfItems(shelf.id, shelf.kind, 24); + const items = await this.shelfItems(shelf.id, shelf.kind, 48); if (items.length) { rows.push({ 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) { if (kind === "MOVIE") { - const files = await prisma.mediaFile.findMany({ - where: { shelfId, available: true, movieId: { not: null } }, - include: { movie: true }, - orderBy: { updatedAt: "desc" }, - take: take * 3, + const movies = await prisma.movie.findMany({ + where: { + mediaFiles: { some: { shelfId, available: true } }, + }, + orderBy: { createdAt: "desc" }, + take, + include: { + mediaFiles: { + where: { shelfId, available: true }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, }); - const seen = new Set(); - const out = []; - for (const f of files) { - if (!f.movie || seen.has(f.movie.id)) continue; - seen.add(f.movie.id); - out.push({ - type: "movie" as const, - id: f.movie.id, - 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; + 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, + })); } - const files = await prisma.mediaFile.findMany({ - where: { shelfId, available: true, episodeId: { not: null } }, - include: { episode: { include: { series: true } } }, - orderBy: { updatedAt: "desc" }, - take: take * 5, + const series = await prisma.series.findMany({ + where: { + episodes: { + some: { + mediaFiles: { some: { shelfId, available: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take, }); - const seen = new Set(); - const out = []; - for (const f of files) { - const series = f.episode?.series; - if (!series || seen.has(series.id)) continue; - seen.add(series.id); - out.push({ - type: "series" as const, - id: series.id, - title: series.title, - year: series.year, - posterUrl: series.posterUrl, - backdropUrl: series.backdropUrl, - }); - if (out.length >= take) break; - } - return out; + 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 continueWatching(viewerId: string) {