TV client: LibVLC for full audio, richer home rows, playback track picker.
This commit is contained in:
parent
09be3be3a5
commit
700947d2e7
8 changed files with 459 additions and 237 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -378,7 +378,7 @@ private fun HeroHeader(
|
|||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(360.dp),
|
||||
.height(420.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = hero?.backdropUrl ?: hero?.posterUrl,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue