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"
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")

View file

@ -270,7 +270,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
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 },

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(
Modifier
.fillMaxWidth()
.height(360.dp),
.height(420.dp),
) {
AsyncImage(
model = hero?.backdropUrl ?: hero?.posterUrl,

View file

@ -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<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 {
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)
}

View file

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

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) {
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<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) {
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<string>();
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<string>();
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) {