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"
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(360.dp),
|
.height(420.dp),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = hero?.backdropUrl ?: hero?.posterUrl,
|
model = hero?.backdropUrl ?: hero?.posterUrl,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
MediaPlayer.Event.EncounteredError -> error = "Afspeelfout (codec/stream)"
|
||||||
|
MediaPlayer.Event.EndReached -> onBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mediaPlayer.setEventListener(listener)
|
||||||
override fun onPlayerError(e: PlaybackException) {
|
|
||||||
error = e.message ?: "Spelerfout"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
|
||||||
player.selectBestAudioTrack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
player.addListener(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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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 shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
async recentMovies(take: number) {
|
||||||
if (kind === "MOVIE") {
|
const movies = await prisma.movie.findMany({
|
||||||
const files = await prisma.mediaFile.findMany({
|
where: { mediaFiles: { some: { available: true } } },
|
||||||
where: { shelfId, available: true, movieId: { not: null } },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { movie: true },
|
take,
|
||||||
orderBy: { updatedAt: "desc" },
|
include: {
|
||||||
take: take * 3,
|
mediaFiles: {
|
||||||
|
where: { available: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const seen = new Set<string>();
|
return movies.map((m) => ({
|
||||||
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,
|
type: "movie" as const,
|
||||||
id: f.movie.id,
|
id: m.id,
|
||||||
title: f.movie.title,
|
title: m.title,
|
||||||
year: f.movie.year,
|
year: m.year,
|
||||||
posterUrl: f.movie.posterUrl,
|
posterUrl: m.posterUrl,
|
||||||
backdropUrl: f.movie.backdropUrl,
|
backdropUrl: m.backdropUrl,
|
||||||
mediaFileId: f.id,
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
||||||
});
|
}));
|
||||||
if (out.length >= take) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await prisma.mediaFile.findMany({
|
async recentSeries(take: number) {
|
||||||
where: { shelfId, available: true, episodeId: { not: null } },
|
const series = await prisma.series.findMany({
|
||||||
include: { episode: { include: { series: true } } },
|
where: {
|
||||||
orderBy: { updatedAt: "desc" },
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
take: take * 5,
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take,
|
||||||
});
|
});
|
||||||
const seen = new Set<string>();
|
return series.map((s) => ({
|
||||||
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,
|
type: "series" as const,
|
||||||
id: series.id,
|
id: s.id,
|
||||||
title: series.title,
|
title: s.title,
|
||||||
year: series.year,
|
year: s.year,
|
||||||
posterUrl: series.posterUrl,
|
posterUrl: s.posterUrl,
|
||||||
backdropUrl: series.backdropUrl,
|
backdropUrl: s.backdropUrl,
|
||||||
});
|
mediaFileId: null as string | null,
|
||||||
if (out.length >= take) break;
|
}));
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
|
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 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 series = await prisma.series.findMany({
|
||||||
|
where: {
|
||||||
|
episodes: {
|
||||||
|
some: {
|
||||||
|
mediaFiles: { some: { shelfId, 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 continueWatching(viewerId: string) {
|
async continueWatching(viewerId: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue