Ship F1 hub on Android TV and harden iOS live/ClearKey playback.
Add the WOW-style F1 hub navigation/rails and improve event playback paths on TV/mobile plus iOS AVLive/ClearKey proxy handling.
This commit is contained in:
parent
4b1f9eb763
commit
85da6f59ad
16 changed files with 4915 additions and 432 deletions
|
|
@ -299,11 +299,12 @@ private class InjectingClearKeyDrmSessionManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatWithClearKeyScheme(format: Format): Format {
|
private fun formatWithClearKeyScheme(format: Format): Format {
|
||||||
|
if (keyIds.isEmpty()) return format
|
||||||
val existing = format.drmInitData
|
val existing = format.drmInitData
|
||||||
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
|
val onlyClearKey = existing != null &&
|
||||||
existing.get(it).matches(C.CLEARKEY_UUID)
|
existing.schemeDataCount > 0 &&
|
||||||
}
|
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
|
||||||
if (hasClearKey || keyIds.isEmpty()) return format
|
if (onlyClearKey) return format
|
||||||
|
|
||||||
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
||||||
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
||||||
|
|
@ -312,7 +313,10 @@ private class InjectingClearKeyDrmSessionManager(
|
||||||
MimeTypes.VIDEO_MP4,
|
MimeTypes.VIDEO_MP4,
|
||||||
pssh,
|
pssh,
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
|
||||||
|
)
|
||||||
return format.buildUpon()
|
return format.buildUpon()
|
||||||
.setDrmInitData(DrmInitData(schemeData))
|
.setDrmInitData(DrmInitData(schemeData))
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -331,10 +335,11 @@ fun liveMediaItem(
|
||||||
format: String,
|
format: String,
|
||||||
drm: DrmInfo? = null,
|
drm: DrmInfo? = null,
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
val isDash = format.equals("dash", ignoreCase = true) ||
|
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
||||||
url.contains(".mpd", ignoreCase = true)
|
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
||||||
val isHls = !isDash && (
|
format.equals("hls", ignoreCase = true)
|
||||||
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
val isDash = !isHls && (
|
||||||
|
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
|
||||||
)
|
)
|
||||||
val mime = when {
|
val mime = when {
|
||||||
isDash -> MimeTypes.APPLICATION_MPD
|
isDash -> MimeTypes.APPLICATION_MPD
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,32 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
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.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.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
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.material3.Text
|
||||||
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
|
||||||
|
|
@ -20,35 +38,51 @@ import androidx.compose.runtime.remember
|
||||||
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.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.C
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
|
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||||
|
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||||
|
import nl.vonas.mediacluster.tv.playback.listTracks
|
||||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||||
|
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
private const val ReadyTimeoutMs = 20_000L
|
private const val ReadyTimeoutMs = 20_000L
|
||||||
|
private const val ChromeHideMs = 5_000L
|
||||||
|
|
||||||
|
/** Stadium-energy palette — event-player (zelfde taal als TV Events). */
|
||||||
|
private object EventPlayerLook {
|
||||||
|
val Neon = Color(0xFF00E8A0)
|
||||||
|
val NeonSoft = Color(0x3300E8A0)
|
||||||
|
val NeonGlow = Color(0x6600E8A0)
|
||||||
|
val Live = Color(0xFFFF2D55)
|
||||||
|
val Panel = Color(0xFF121A18)
|
||||||
|
val PanelHot = Color(0xFF1A2824)
|
||||||
|
val Scrim = Color(0xCC050A08)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EventPlayerScreen(
|
fun EventPlayerScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
|
@ -66,9 +100,30 @@ fun EventPlayerScreen(
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var showChrome by remember { mutableStateOf(true) }
|
var showChrome by remember { mutableStateOf(true) }
|
||||||
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||||
|
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||||
|
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedTextKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var textDisabled by remember { mutableStateOf(true) }
|
||||||
|
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||||
|
var chromeBump by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
RememberPlayerImmersiveMode()
|
RememberPlayerImmersiveMode()
|
||||||
|
|
||||||
|
fun refreshTracks() {
|
||||||
|
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||||
|
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||||
|
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
|
||||||
|
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
|
||||||
|
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
|
||||||
|
selectedTextKey == null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revealChrome() {
|
||||||
|
showChrome = true
|
||||||
|
chromeBump++
|
||||||
|
}
|
||||||
|
|
||||||
fun safeStopAndClear() {
|
fun safeStopAndClear() {
|
||||||
runCatching {
|
runCatching {
|
||||||
player.playWhenReady = false
|
player.playWhenReady = false
|
||||||
|
|
@ -83,6 +138,9 @@ fun EventPlayerScreen(
|
||||||
watchSessionId = null
|
watchSessionId = null
|
||||||
reachedReady.set(false)
|
reachedReady.set(false)
|
||||||
showChrome = true
|
showChrome = true
|
||||||
|
menu = null
|
||||||
|
audioTracks = emptyList()
|
||||||
|
textTracks = emptyList()
|
||||||
try {
|
try {
|
||||||
val play = api.scheduleEventPlay(eventId)
|
val play = api.scheduleEventPlay(eventId)
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
|
|
@ -146,9 +204,9 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(showChrome, error, loading) {
|
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
|
||||||
if (showChrome && error == null && !loading) {
|
if (showChrome && menu == null && error == null && !loading) {
|
||||||
delay(4_000)
|
delay(ChromeHideMs)
|
||||||
showChrome = false
|
showChrome = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,12 +217,22 @@ fun EventPlayerScreen(
|
||||||
when (state) {
|
when (state) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
reachedReady.set(true)
|
reachedReady.set(true)
|
||||||
runCatching { player.ensureAudioSelected() }
|
runCatching {
|
||||||
|
player.ensureAudioSelected()
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||||
|
runCatching {
|
||||||
|
player.ensureAudioSelected()
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlayerError(e: PlaybackException) {
|
override fun onPlayerError(e: PlaybackException) {
|
||||||
val cause = generateSequence(e as Throwable?) { it.cause }
|
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||||
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||||
|
|
@ -190,7 +258,13 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler {
|
||||||
|
when {
|
||||||
|
menu != null -> menu = null
|
||||||
|
showChrome -> showChrome = false
|
||||||
|
else -> onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -200,7 +274,13 @@ fun EventPlayerScreen(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {
|
) {
|
||||||
if (error == null && !loading) showChrome = !showChrome
|
if (error != null || loading) return@clickable
|
||||||
|
if (menu != null) {
|
||||||
|
menu = null
|
||||||
|
} else {
|
||||||
|
showChrome = !showChrome
|
||||||
|
if (showChrome) chromeBump++
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (error == null) {
|
if (error == null) {
|
||||||
|
|
@ -249,26 +329,324 @@ fun EventPlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (showChrome) {
|
} else {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showChrome || menu != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(
|
||||||
|
EventPlayerLook.Scrim,
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Transparent,
|
||||||
|
EventPlayerLook.Scrim,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.background(EventPlayerLook.Live, RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"LIVE",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = 3.dp, height = 12.dp)
|
||||||
|
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"EVENT",
|
||||||
|
color = EventPlayerLook.Neon,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
McButton("Terug", compact = true, onClick = onBack)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MobileEventChip(
|
||||||
|
label = "Audio",
|
||||||
|
subtitle = when {
|
||||||
|
audioTracks.isEmpty() -> "—"
|
||||||
|
else -> audioTracks.firstOrNull {
|
||||||
|
trackKey(it) == selectedAudioKey
|
||||||
|
}?.label ?: audioTracks.first().label
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = {
|
||||||
|
revealChrome()
|
||||||
|
menu = "audio"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MobileEventChip(
|
||||||
|
label = "Ondertiteling",
|
||||||
|
subtitle = when {
|
||||||
|
textDisabled || textTracks.isEmpty() -> "Uit"
|
||||||
|
else -> textTracks.firstOrNull {
|
||||||
|
trackKey(it) == selectedTextKey
|
||||||
|
}?.label ?: "Aan"
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = {
|
||||||
|
revealChrome()
|
||||||
|
menu = "text"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = menu != null,
|
||||||
|
enter = fadeIn() + slideInVertically { it / 2 },
|
||||||
|
exit = fadeOut() + slideOutVertically { it / 2 },
|
||||||
|
) {
|
||||||
|
val isAudio = menu == "audio"
|
||||||
|
val tracks = if (isAudio) audioTracks else textTracks
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0x99050A08))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { menu = null },
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.background(
|
||||||
|
EventPlayerLook.Panel,
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
brush = Brush.horizontalGradient(
|
||||||
|
listOf(EventPlayerLook.NeonGlow, Color.Transparent, EventPlayerLook.NeonSoft),
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||||
|
)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { /* consume */ }
|
||||||
|
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.width(40.dp)
|
||||||
|
.height(4.dp)
|
||||||
|
.background(Color(0x44FFFFFF), RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (isAudio) "Audio" else "Ondertiteling",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(EventPlayerLook.Neon, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
if (!isAudio) {
|
||||||
|
MobileEventTrackRow(
|
||||||
|
label = "Uit",
|
||||||
|
selected = textDisabled || tracks.isEmpty(),
|
||||||
|
onClick = {
|
||||||
|
player.disableTrackType(C.TRACK_TYPE_TEXT)
|
||||||
|
textDisabled = true
|
||||||
|
selectedTextKey = null
|
||||||
|
menu = null
|
||||||
|
revealChrome()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
if (isAudio) "Geen audiosporen in deze stream"
|
||||||
|
else "Geen ondertitels in deze stream",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height((tracks.size.coerceAtMost(6) * 56).dp.coerceAtLeast(56.dp)),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(tracks, key = { trackKey(it) }) { track ->
|
||||||
|
val selected = if (isAudio) {
|
||||||
|
trackKey(track) == selectedAudioKey
|
||||||
|
} else {
|
||||||
|
!textDisabled && trackKey(track) == selectedTextKey
|
||||||
|
}
|
||||||
|
MobileEventTrackRow(
|
||||||
|
label = track.label,
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
player.selectTrack(track)
|
||||||
|
if (isAudio) {
|
||||||
|
selectedAudioKey = trackKey(track)
|
||||||
|
} else {
|
||||||
|
textDisabled = false
|
||||||
|
selectedTextKey = trackKey(track)
|
||||||
|
}
|
||||||
|
menu = null
|
||||||
|
revealChrome()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
MobileEventChip(
|
||||||
|
label = "Sluiten",
|
||||||
|
subtitle = null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { menu = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
|
||||||
|
|
||||||
|
private fun ExoPlayer.activeTrackKey(type: Int): String? {
|
||||||
|
val groups = currentTracks.groups
|
||||||
|
for (gi in 0 until groups.size) {
|
||||||
|
val group = groups[gi]
|
||||||
|
if (group.type != type || !group.isSelected) continue
|
||||||
|
for (ti in 0 until group.length) {
|
||||||
|
if (group.isTrackSelected(ti)) return "$gi:$ti"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MobileEventChip(
|
||||||
|
label: String,
|
||||||
|
subtitle: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.background(EventPlayerLook.PanelHot, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, EventPlayerLook.NeonSoft, RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = EventPlayerLook.Neon,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
subtitle,
|
||||||
color = Color.White,
|
color = McColors.TextDim,
|
||||||
fontSize = 18.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
maxLines = 1,
|
||||||
modifier = Modifier
|
overflow = TextOverflow.Ellipsis,
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(20.dp),
|
|
||||||
)
|
|
||||||
McButton(
|
|
||||||
"Terug",
|
|
||||||
compact = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(16.dp),
|
|
||||||
onClick = onBack,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MobileEventTrackRow(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
if (selected) EventPlayerLook.NeonSoft else Color.Transparent,
|
||||||
|
RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (selected) "●" else "○",
|
||||||
|
color = if (selected) EventPlayerLook.Neon else Color(0x55FFFFFF),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 135
|
versionCode = 143
|
||||||
versionName = "0.14.72"
|
versionName = "0.14.80"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -636,37 +636,152 @@ class ApiClient(
|
||||||
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||||
val enc = android.net.Uri.encode(eventId)
|
val enc = android.net.Uri.encode(eventId)
|
||||||
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build()
|
val req = authed("$baseUrl/api/v1/client/events/$enc/play").get().build()
|
||||||
|
parseObject(execute(req)) { o -> o.toIptvPlayInfo(fallbackStreamId = eventId, defaultFormat = "dash") }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getF1tvHome(): F1tvHomePayload = withContext(Dispatchers.IO) {
|
||||||
|
val req = authed("$baseUrl/api/v1/client/f1/home").get().build()
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
val drmObj = o.optJSONObject("drm")
|
fun eventsOf(key: String): List<ScheduleEvent> {
|
||||||
val drm = if (drmObj != null) {
|
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||||
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
|
return buildList {
|
||||||
val keys = buildList {
|
for (i in 0 until arr.length()) {
|
||||||
for (i in 0 until keysArr.length()) {
|
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||||
val k = keysArr.optJSONObject(i) ?: continue
|
|
||||||
val kid = k.optString("kid").ifBlank { null } ?: continue
|
|
||||||
val key = k.optString("key").ifBlank { null } ?: continue
|
|
||||||
add(ClearKeyEntry(kid = kid, key = key))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DrmInfo(
|
}
|
||||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
fun meetingsOf(key: String): List<F1tvMeeting> {
|
||||||
keys = keys,
|
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||||
).takeIf { it.keys.isNotEmpty() }
|
return buildList {
|
||||||
} else null
|
for (i in 0 until arr.length()) {
|
||||||
IptvPlayInfo(
|
arr.optJSONObject(i)?.toF1tvMeeting()?.let { add(it) }
|
||||||
streamId = o.optString("eventId").ifBlank { eventId },
|
}
|
||||||
name = o.getString("name"),
|
}
|
||||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
}
|
||||||
streamUrl = o.getString("streamUrl"),
|
fun replayGroupsOf(key: String): List<F1tvReplayGroup> {
|
||||||
format = o.optString("format").ifBlank { "dash" },
|
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||||
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
return buildList {
|
||||||
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
for (i in 0 until arr.length()) {
|
||||||
drm = drm,
|
val g = arr.optJSONObject(i) ?: continue
|
||||||
watchSessionId = o.optString("watchSessionId").ifBlank { null },
|
val sessionsArr = g.optJSONArray("sessions") ?: JSONArray()
|
||||||
|
val sessions = buildList {
|
||||||
|
for (si in 0 until sessionsArr.length()) {
|
||||||
|
sessionsArr.optJSONObject(si)?.toScheduleEvent()?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val meetingKey = g.optClean("meetingKey") ?: continue
|
||||||
|
add(
|
||||||
|
F1tvReplayGroup(
|
||||||
|
meetingKey = meetingKey,
|
||||||
|
title = g.optClean("title") ?: meetingKey,
|
||||||
|
location = g.optClean("location"),
|
||||||
|
meetingNumber = g.optInt("meetingNumber").takeIf { it > 0 },
|
||||||
|
imageUrl = g.optClean("imageUrl"),
|
||||||
|
latestStart = g.optClean("latestStart") ?: "",
|
||||||
|
sessions = sessions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
F1tvHomePayload(
|
||||||
|
generatedAt = o.optClean("generatedAt"),
|
||||||
|
live = eventsOf("live"),
|
||||||
|
upcoming = eventsOf("upcoming"),
|
||||||
|
recentReplays = eventsOf("recentReplays"),
|
||||||
|
replayGroups = replayGroupsOf("replayGroups"),
|
||||||
|
meetings = meetingsOf("meetings"),
|
||||||
|
nextMeeting = o.optJSONObject("nextMeeting")?.toF1tvMeeting(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
|
||||||
|
val enc = android.net.Uri.encode(meetingKey)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
val sessionsArr = o.optJSONArray("sessions") ?: JSONArray()
|
||||||
|
val sessions = buildList {
|
||||||
|
for (i in 0 until sessionsArr.length()) {
|
||||||
|
sessionsArr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun eventsOf(key: String): List<ScheduleEvent> {
|
||||||
|
val arr = o.optJSONArray(key) ?: JSONArray()
|
||||||
|
return buildList {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
F1tvMeetingDetail(
|
||||||
|
meeting = o.optJSONObject("meeting")?.toF1tvMeeting(),
|
||||||
|
sessions = sessions,
|
||||||
|
replays = eventsOf("replays"),
|
||||||
|
upcoming = eventsOf("upcoming"),
|
||||||
|
live = eventsOf("live"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hub play (live + replay). */
|
||||||
|
suspend fun f1tvContentPlay(contentId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||||
|
val enc = android.net.Uri.encode(contentId)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/f1/content/$enc/play").get().build()
|
||||||
|
parseObject(execute(req)) { o ->
|
||||||
|
o.toIptvPlayInfo(fallbackStreamId = "f1tv:$contentId", defaultFormat = "hls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.toIptvPlayInfo(
|
||||||
|
fallbackStreamId: String,
|
||||||
|
defaultFormat: String,
|
||||||
|
): IptvPlayInfo {
|
||||||
|
val drmObj = optJSONObject("drm")
|
||||||
|
val drm = if (drmObj != null) {
|
||||||
|
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
|
||||||
|
val keys = buildList {
|
||||||
|
for (i in 0 until keysArr.length()) {
|
||||||
|
val k = keysArr.optJSONObject(i) ?: continue
|
||||||
|
val kid = k.optString("kid").ifBlank { null } ?: continue
|
||||||
|
val key = k.optString("key").ifBlank { null } ?: continue
|
||||||
|
add(ClearKeyEntry(kid = kid, key = key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrmInfo(
|
||||||
|
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||||
|
keys = keys,
|
||||||
|
).takeIf { it.keys.isNotEmpty() }
|
||||||
|
} else null
|
||||||
|
return IptvPlayInfo(
|
||||||
|
streamId = optString("eventId").ifBlank { fallbackStreamId },
|
||||||
|
name = getString("name"),
|
||||||
|
logoUrl = optString("logoUrl").ifBlank { null },
|
||||||
|
streamUrl = getString("streamUrl"),
|
||||||
|
format = optString("format").ifBlank { defaultFormat },
|
||||||
|
fallbackStreamUrl = optString("fallbackStreamUrl").ifBlank { null },
|
||||||
|
fallbackFormat = optString("fallbackFormat").ifBlank { null },
|
||||||
|
drm = drm,
|
||||||
|
watchSessionId = optString("watchSessionId").ifBlank { null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.toF1tvMeeting(): F1tvMeeting? {
|
||||||
|
val meetingKey = optClean("meetingKey") ?: return null
|
||||||
|
return F1tvMeeting(
|
||||||
|
meetingKey = meetingKey,
|
||||||
|
contentId = optLong("contentId").takeIf { it > 0 },
|
||||||
|
title = optClean("title") ?: "Meeting $meetingKey",
|
||||||
|
pageId = optLong("pageId").takeIf { it > 0 },
|
||||||
|
pageUri = optClean("pageUri"),
|
||||||
|
meetingNumber = optInt("meetingNumber").takeIf { it > 0 },
|
||||||
|
location = optClean("location"),
|
||||||
|
startMs = optLong("startMs").takeIf { it > 0 },
|
||||||
|
endMs = optLong("endMs").takeIf { it > 0 },
|
||||||
|
imageUrl = optClean("imageUrl"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presence-heartbeat voor events / custom live-lists.
|
* Presence-heartbeat voor events / custom live-lists.
|
||||||
* Best-effort: never throws to callers that ignore failures.
|
* Best-effort: never throws to callers that ignore failures.
|
||||||
|
|
@ -1137,6 +1252,47 @@ data class ScheduleEvent(
|
||||||
val audioLanguages: List<String> = emptyList(),
|
val audioLanguages: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class F1tvMeeting(
|
||||||
|
val meetingKey: String,
|
||||||
|
val contentId: Long? = null,
|
||||||
|
val title: String,
|
||||||
|
val pageId: Long? = null,
|
||||||
|
val pageUri: String? = null,
|
||||||
|
val meetingNumber: Int? = null,
|
||||||
|
val location: String? = null,
|
||||||
|
val startMs: Long? = null,
|
||||||
|
val endMs: Long? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class F1tvReplayGroup(
|
||||||
|
val meetingKey: String,
|
||||||
|
val title: String,
|
||||||
|
val location: String? = null,
|
||||||
|
val meetingNumber: Int? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val latestStart: String = "",
|
||||||
|
val sessions: List<ScheduleEvent> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class F1tvHomePayload(
|
||||||
|
val generatedAt: String? = null,
|
||||||
|
val live: List<ScheduleEvent> = emptyList(),
|
||||||
|
val upcoming: List<ScheduleEvent> = emptyList(),
|
||||||
|
val recentReplays: List<ScheduleEvent> = emptyList(),
|
||||||
|
val replayGroups: List<F1tvReplayGroup> = emptyList(),
|
||||||
|
val meetings: List<F1tvMeeting> = emptyList(),
|
||||||
|
val nextMeeting: F1tvMeeting? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class F1tvMeetingDetail(
|
||||||
|
val meeting: F1tvMeeting? = null,
|
||||||
|
val sessions: List<ScheduleEvent> = emptyList(),
|
||||||
|
val replays: List<ScheduleEvent> = emptyList(),
|
||||||
|
val upcoming: List<ScheduleEvent> = emptyList(),
|
||||||
|
val live: List<ScheduleEvent> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
|
/** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */
|
||||||
val SportBucketOrder = listOf(
|
val SportBucketOrder = listOf(
|
||||||
"football", "racing", "tennis", "golf", "darts", "snooker",
|
"football", "racing", "tennis", "golf", "darts", "snooker",
|
||||||
|
|
|
||||||
|
|
@ -309,11 +309,14 @@ private class InjectingClearKeyDrmSessionManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatWithClearKeyScheme(format: Format): Format {
|
private fun formatWithClearKeyScheme(format: Format): Format {
|
||||||
|
if (keyIds.isEmpty()) return format
|
||||||
val existing = format.drmInitData
|
val existing = format.drmInitData
|
||||||
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
|
// Alleen ClearKey → laten staan. Anders forceren (ESPN HLS heeft Widevine/PlayReady
|
||||||
existing.get(it).matches(C.CLEARKEY_UUID)
|
// SESSION-KEY; wij hebben ClearKey-kids uit de feed).
|
||||||
}
|
val onlyClearKey = existing != null &&
|
||||||
if (hasClearKey || keyIds.isEmpty()) return format
|
existing.schemeDataCount > 0 &&
|
||||||
|
(0 until existing.schemeDataCount).all { existing.get(it).matches(C.CLEARKEY_UUID) }
|
||||||
|
if (onlyClearKey) return format
|
||||||
|
|
||||||
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
||||||
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
||||||
|
|
@ -322,7 +325,10 @@ private class InjectingClearKeyDrmSessionManager(
|
||||||
MimeTypes.VIDEO_MP4,
|
MimeTypes.VIDEO_MP4,
|
||||||
pssh,
|
pssh,
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Forcing ClearKey PSSH kids=${keyIds.size} wasSchemes=${existing?.schemeDataCount ?: 0}",
|
||||||
|
)
|
||||||
return format.buildUpon()
|
return format.buildUpon()
|
||||||
.setDrmInitData(DrmInitData(schemeData))
|
.setDrmInitData(DrmInitData(schemeData))
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -341,10 +347,11 @@ fun liveMediaItem(
|
||||||
format: String,
|
format: String,
|
||||||
drm: DrmInfo? = null,
|
drm: DrmInfo? = null,
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
val isDash = format.equals("dash", ignoreCase = true) ||
|
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
|
||||||
url.contains(".mpd", ignoreCase = true)
|
val isHls = url.contains("m3u8", ignoreCase = true) ||
|
||||||
val isHls = !isDash && (
|
format.equals("hls", ignoreCase = true)
|
||||||
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
val isDash = !isHls && (
|
||||||
|
format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true)
|
||||||
)
|
)
|
||||||
val mime = when {
|
val mime = when {
|
||||||
isDash -> MimeTypes.APPLICATION_MPD
|
isDash -> MimeTypes.APPLICATION_MPD
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
package nl.vonas.mediacluster.tv.ui
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.os.Handler
|
||||||
import android.net.Uri
|
import android.os.Looper
|
||||||
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
|
|
@ -26,11 +34,13 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
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.rememberCoroutineScope
|
||||||
|
|
@ -39,24 +49,23 @@ 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.draw.clip
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
|
||||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
|
||||||
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
|
|
||||||
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
|
|
||||||
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.tv.material3.Border
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
|
@ -67,10 +76,15 @@ import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.MediaVersion
|
import nl.vonas.mediacluster.tv.ui.components.MediaVersion
|
||||||
import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
|
import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
|
||||||
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
|
import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
|
||||||
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
|
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -101,36 +115,197 @@ private fun formatRuntime(minutes: Int?): String? {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openYoutubeTrailer(context: android.content.Context, key: String) {
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
val appPackages = listOf(
|
@Composable
|
||||||
"com.google.android.youtube.tv",
|
private fun TrailerPlayerOverlay(
|
||||||
"com.google.android.youtube.googletv",
|
youtubeKey: String,
|
||||||
"com.google.android.youtube",
|
onDismiss: () -> Unit,
|
||||||
)
|
) {
|
||||||
val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply {
|
var loading by remember { mutableStateOf(true) }
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
var paused by remember { mutableStateOf(false) }
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
val catcherFocus = remember { FocusRequester() }
|
||||||
|
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(50)
|
||||||
|
catcherFocus.requestFocus()
|
||||||
}
|
}
|
||||||
for (pkg in appPackages) {
|
|
||||||
try {
|
Dialog(
|
||||||
appIntent.setPackage(pkg)
|
onDismissRequest = onDismiss,
|
||||||
context.startActivity(appIntent)
|
properties = DialogProperties(
|
||||||
return
|
dismissOnBackPress = true,
|
||||||
} catch (_: ActivityNotFoundException) {
|
dismissOnClickOutside = false,
|
||||||
// try next
|
usePlatformDefaultWidth = false,
|
||||||
} catch (_: Exception) {
|
),
|
||||||
// try next
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
WebView(ctx).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
|
isFocusable = false
|
||||||
|
isFocusableInTouchMode = false
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.mediaPlaybackRequiresUserGesture = false
|
||||||
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
webChromeClient = WebChromeClient()
|
||||||
|
webViewClient = WebViewClient()
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onPlaying() {
|
||||||
|
mainHandler.post {
|
||||||
|
loading = false
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onPaused() {
|
||||||
|
mainHandler.post { paused = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onEnded() {
|
||||||
|
mainHandler.post(onDismiss)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"VmcTrailer",
|
||||||
|
)
|
||||||
|
// Cinema mode: scaled/cropped iframe + pointer-events none hides
|
||||||
|
// YouTube chrome/watermark and blocks "Watch on YouTube" taps.
|
||||||
|
val html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;background:#000;overflow:hidden}
|
||||||
|
#stage{position:fixed;inset:0;overflow:hidden;background:#000}
|
||||||
|
#player{position:absolute;top:50%;left:50%;width:128vw;height:128vh;
|
||||||
|
transform:translate(-50%,-50%);pointer-events:none}
|
||||||
|
#player iframe{width:100%!important;height:100%!important;border:0;
|
||||||
|
pointer-events:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage"><div id="player"></div></div>
|
||||||
|
<script>
|
||||||
|
var tag=document.createElement('script');
|
||||||
|
tag.src='https://www.youtube.com/iframe_api';
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
var player;
|
||||||
|
function onYouTubeIframeAPIReady(){
|
||||||
|
player=new YT.Player('player',{
|
||||||
|
height:'100%',width:'100%',
|
||||||
|
videoId:'$youtubeKey',
|
||||||
|
host:'https://www.youtube-nocookie.com',
|
||||||
|
playerVars:{
|
||||||
|
autoplay:1,controls:0,disablekb:1,fs:0,rel:0,
|
||||||
|
iv_load_policy:3,playsinline:1,cc_load_policy:0,
|
||||||
|
modestbranding:1,showinfo:0,
|
||||||
|
origin:'https://nl.vonas.mediacluster.tv'
|
||||||
|
},
|
||||||
|
events:{
|
||||||
|
onReady:function(e){
|
||||||
|
try{e.target.setPlaybackQuality('hd1080');}catch(_){}
|
||||||
|
e.target.playVideo();
|
||||||
|
},
|
||||||
|
onStateChange:function(e){
|
||||||
|
if(e.data===1) VmcTrailer.onPlaying();
|
||||||
|
else if(e.data===2) VmcTrailer.onPaused();
|
||||||
|
else if(e.data===0) VmcTrailer.onEnded();
|
||||||
|
},
|
||||||
|
onError:function(){VmcTrailer.onEnded();}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function vmcToggle(){
|
||||||
|
if(!player||!player.getPlayerState) return;
|
||||||
|
if(player.getPlayerState()===1) player.pauseVideo();
|
||||||
|
else player.playVideo();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
loadDataWithBaseURL(
|
||||||
|
"https://nl.vonas.mediacluster.tv/",
|
||||||
|
html,
|
||||||
|
"text/html",
|
||||||
|
"UTF-8",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
webView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onRelease = {
|
||||||
|
it.destroy()
|
||||||
|
webView = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Catch remote input so YouTube UI never receives focus/clicks.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(catcherFocus)
|
||||||
|
.focusable()
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
|
||||||
|
when (event.nativeKeyEvent.keyCode) {
|
||||||
|
AndroidKeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
AndroidKeyEvent.KEYCODE_ENTER,
|
||||||
|
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||||
|
AndroidKeyEvent.KEYCODE_MEDIA_PLAY,
|
||||||
|
AndroidKeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||||
|
-> {
|
||||||
|
webView?.evaluateJavascript("vmcToggle();", null)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
Text(
|
||||||
|
"Bezig met laden…",
|
||||||
|
color = Color(0xFFB0B8C8),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paused && !loading) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0x99000000)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("❚❚", color = Color.White, fontSize = 22.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
context.startActivity(
|
|
||||||
Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
Uri.parse("https://www.youtube.com/watch?v=$key"),
|
|
||||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -142,25 +317,28 @@ private fun DetailBackdrop(url: String?) {
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
alignment = Alignment.TopCenter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Events-achtige cinematic scrims (VMC zwart)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xF20B0B0B),
|
0.0f to Color(0xF20B0B0B),
|
||||||
0.42f to Color(0xCC0B0B0B),
|
0.40f to Color(0xCC0B0B0B),
|
||||||
0.72f to Color(0x660B0B0B),
|
0.72f to Color(0x550B0B0B),
|
||||||
1f to Color(0x220B0B0B),
|
1.0f to Color(0x180B0B0B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0f to Color(0x660B0B0B),
|
0.0f to Color(0x66000000),
|
||||||
0.35f to Color.Transparent,
|
0.12f to Color.Transparent,
|
||||||
0.7f to Color(0xAA0B0B0B),
|
0.45f to Color.Transparent,
|
||||||
1f to Color(0xFF0B0B0B),
|
0.70f to Color(0xAA0B0B0B),
|
||||||
|
1.0f to Color(0xFF0B0B0B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -168,15 +346,25 @@ private fun DetailBackdrop(url: String?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
private fun DetailLogoOrTitle(
|
||||||
|
logoUrl: String?,
|
||||||
|
title: String,
|
||||||
|
compact: Boolean = false,
|
||||||
|
) {
|
||||||
if (!logoUrl.isNullOrBlank()) {
|
if (!logoUrl.isNullOrBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = logoUrl,
|
model = logoUrl,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier
|
modifier = if (compact) {
|
||||||
.widthIn(max = 420.dp)
|
Modifier
|
||||||
.heightIn(max = 120.dp)
|
.heightIn(max = 56.dp)
|
||||||
.fillMaxWidth(0.85f),
|
.widthIn(max = 280.dp)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 420.dp)
|
||||||
|
.heightIn(max = 100.dp)
|
||||||
|
.fillMaxWidth(0.85f)
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
alignment = Alignment.CenterStart,
|
alignment = Alignment.CenterStart,
|
||||||
)
|
)
|
||||||
|
|
@ -184,15 +372,43 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 40.sp,
|
fontSize = if (compact) 32.sp else 40.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 3,
|
maxLines = if (compact) 2 else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 46.sp,
|
lineHeight = if (compact) 36.sp else 46.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenrePills(genres: List<String>) {
|
||||||
|
if (genres.isEmpty()) return
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
genres.take(4).forEach { genre ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(McColors.AccentSoft)
|
||||||
|
.border(BorderStroke(1.dp, Color(0x55E50914)), RoundedCornerShape(999.dp))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
genre.uppercase(),
|
||||||
|
color = Color(0xFFFFCDD2),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 0.8.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MetaChips(parts: List<String>) {
|
private fun MetaChips(parts: List<String>) {
|
||||||
if (parts.isEmpty()) return
|
if (parts.isEmpty()) return
|
||||||
|
|
@ -207,8 +423,8 @@ private fun MetaChips(parts: List<String>) {
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
part,
|
part,
|
||||||
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
|
color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF),
|
||||||
fontSize = 14.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +447,7 @@ private fun DetailBackButton(onClick: () -> Unit) {
|
||||||
),
|
),
|
||||||
border = ClickableSurfaceDefaults.border(
|
border = ClickableSurfaceDefaults.border(
|
||||||
focusedBorder = Border(
|
focusedBorder = Border(
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
border = BorderStroke(1.dp, Color.White),
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -254,38 +470,43 @@ private fun DetailActionButton(
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bg = Color(0xE6181818)
|
val shape = RoundedCornerShape(6.dp)
|
||||||
val bgFocus = Color.White
|
val idleBg = if (primary) McColors.Accent else Color(0xE6181818)
|
||||||
val fg = Color.White
|
val focusBg = if (primary) Color.White else Color.White
|
||||||
|
val idleFg = Color.White
|
||||||
|
val focusFg = if (primary) McColors.Accent else Color.Black
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = shape),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = bg,
|
containerColor = idleBg,
|
||||||
focusedContainerColor = bgFocus,
|
focusedContainerColor = focusBg,
|
||||||
contentColor = fg,
|
contentColor = idleFg,
|
||||||
focusedContentColor = Color.Black,
|
focusedContentColor = focusFg,
|
||||||
),
|
),
|
||||||
border = ClickableSurfaceDefaults.border(
|
border = ClickableSurfaceDefaults.border(
|
||||||
border = Border(
|
border = Border(
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
|
border = BorderStroke(
|
||||||
shape = RoundedCornerShape(4.dp),
|
if (primary) 0.dp else 1.dp,
|
||||||
|
if (primary) Color.Transparent else Color(0x55FFFFFF),
|
||||||
|
),
|
||||||
|
shape = shape,
|
||||||
),
|
),
|
||||||
focusedBorder = Border(
|
focusedBorder = Border(
|
||||||
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
|
border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White),
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = shape,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
color = Color.Unspecified,
|
color = Color.Unspecified,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +566,6 @@ fun MovieDetailScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
var title by remember { mutableStateOf("…") }
|
var title by remember { mutableStateOf("…") }
|
||||||
var overview by remember { mutableStateOf("") }
|
var overview by remember { mutableStateOf("") }
|
||||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -358,13 +578,13 @@ fun MovieDetailScreen(
|
||||||
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
|
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var director by remember { mutableStateOf<List<String>>(emptyList()) }
|
var director by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var trailerKey by remember { mutableStateOf<String?>(null) }
|
var trailerKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showTrailer by remember { mutableStateOf(false) }
|
||||||
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
||||||
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var onWatchlist by remember { mutableStateOf(false) }
|
var onWatchlist by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val playFocus = remember { FocusRequester() }
|
val playFocus = remember { FocusRequester() }
|
||||||
val scroll = rememberScrollState()
|
|
||||||
|
|
||||||
LaunchedEffect(movieId) {
|
LaunchedEffect(movieId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -426,115 +646,130 @@ fun MovieDetailScreen(
|
||||||
rating?.let { add("★ $it") }
|
rating?.let { add("★ $it") }
|
||||||
}
|
}
|
||||||
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
|
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
|
||||||
|
val creditLine = buildList {
|
||||||
|
if (director.isNotEmpty()) add(director.first())
|
||||||
|
cast.take(3).forEach(::add)
|
||||||
|
}.joinToString(" · ")
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
DetailBackdrop(backdrop)
|
DetailBackdrop(backdrop)
|
||||||
Column(
|
|
||||||
|
// Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten
|
||||||
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxSize()
|
||||||
.fillMaxWidth(0.58f)
|
.padding(
|
||||||
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
|
start = HomeChrome.EdgeInset,
|
||||||
.verticalScroll(scroll),
|
end = HomeChrome.EdgeInset,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
top = 28.dp,
|
||||||
|
bottom = 32.dp,
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(36.dp),
|
||||||
) {
|
) {
|
||||||
DetailBackButton(onClick = onBack)
|
Column(
|
||||||
Spacer(Modifier.height(4.dp))
|
Modifier
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
.weight(1f)
|
||||||
MetaChips(metaParts)
|
.fillMaxHeight(),
|
||||||
if (endsAtLabel != null) {
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
Text(
|
) {
|
||||||
endsAtLabel,
|
DetailBackButton(onClick = onBack)
|
||||||
color = Color(0xFF9A9AA8),
|
Spacer(Modifier.height(16.dp))
|
||||||
fontSize = 14.sp,
|
DetailLogoOrTitle(logoUrl, title, compact = true)
|
||||||
fontWeight = FontWeight.Medium,
|
Spacer(Modifier.height(10.dp))
|
||||||
)
|
MetaChips(metaParts)
|
||||||
}
|
if (endsAtLabel != null) {
|
||||||
if (versions.size > 1) {
|
Spacer(Modifier.height(4.dp))
|
||||||
SelectedQualityLine(selected)
|
Text(
|
||||||
}
|
endsAtLabel,
|
||||||
Spacer(Modifier.height(4.dp))
|
color = Color(0xFFB8B8B8),
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
fontSize = 14.sp,
|
||||||
selected?.let { ver ->
|
fontWeight = FontWeight.Medium,
|
||||||
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
|
maxLines = 1,
|
||||||
onPlay(ver.id, title, 0)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!trailerKey.isNullOrBlank()) {
|
if (overview.isNotBlank()) {
|
||||||
DetailActionButton("Trailer") {
|
Spacer(Modifier.height(12.dp))
|
||||||
openYoutubeTrailer(context, trailerKey!!)
|
Text(
|
||||||
}
|
overview,
|
||||||
|
color = Color(0xFFD6D6D6),
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
if (creditLine.isNotBlank()) {
|
||||||
scope.launch {
|
Spacer(Modifier.height(8.dp))
|
||||||
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
|
Text(
|
||||||
}
|
creditLine,
|
||||||
|
color = Color(0xFF8A8A8A),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DetailActionButton("Markeer als gezien") {
|
if (error != null) {
|
||||||
scope.launch {
|
Spacer(Modifier.height(6.dp))
|
||||||
runCatching { api.markWatched(movieId = movieId) }
|
Text(error!!, color = McColors.Danger, fontSize = 13.sp, maxLines = 1)
|
||||||
onBack()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (overview.isNotBlank()) {
|
Spacer(Modifier.height(18.dp))
|
||||||
ExpandableOverview(overview)
|
Row(
|
||||||
}
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
if (director.isNotEmpty()) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text(
|
|
||||||
"Regie · ${director.take(2).joinToString(", ")}",
|
|
||||||
color = Color(0xFF9A9A9A),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (cast.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Met · ${cast.take(4).joinToString(", ")}",
|
|
||||||
color = Color(0xFF8A8A8A),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
|
||||||
if (versions.size > 1) {
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
"Kwaliteit",
|
|
||||||
color = Color(0xFF8F8F8F),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
letterSpacing = 0.6.sp,
|
|
||||||
)
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
|
|
||||||
) {
|
) {
|
||||||
items(versions, key = { it.id }) { ver ->
|
selected?.let { ver ->
|
||||||
VersionPickChip(
|
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
|
||||||
version = ver,
|
onPlay(ver.id, title, 0)
|
||||||
selected = ver.id == selected?.id,
|
}
|
||||||
onClick = { selectedVersionId = ver.id },
|
}
|
||||||
)
|
if (!trailerKey.isNullOrBlank()) {
|
||||||
|
DetailActionButton("Trailer") { showTrailer = true }
|
||||||
|
}
|
||||||
|
DetailActionButton(if (onWatchlist) "✓ Lijst" else "+ Lijst") {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (versions.size > 1) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(versions, key = { it.id }) { ver ->
|
||||||
|
VersionPickChip(
|
||||||
|
version = ver,
|
||||||
|
selected = ver.id == selected?.id,
|
||||||
|
onClick = { selectedVersionId = ver.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!poster.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = poster,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Top)
|
||||||
|
.padding(top = 40.dp)
|
||||||
|
.width(180.dp)
|
||||||
|
.height(270.dp)
|
||||||
|
.shadow(22.dp, RoundedCornerShape(10.dp))
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.border(BorderStroke(1.dp, Color(0x33FFFFFF)), RoundedCornerShape(10.dp))
|
||||||
|
.background(Color(0xFF1A1A1A)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!poster.isNullOrBlank()) {
|
if (showTrailer && !trailerKey.isNullOrBlank()) {
|
||||||
AsyncImage(
|
TrailerPlayerOverlay(
|
||||||
model = poster,
|
youtubeKey = trailerKey!!,
|
||||||
contentDescription = null,
|
onDismiss = { showTrailer = false },
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterEnd)
|
|
||||||
.padding(end = 56.dp)
|
|
||||||
.width(200.dp)
|
|
||||||
.height(300.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color(0xFF1A1A1A)),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -585,7 +820,6 @@ fun SeriesDetailScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
var title by remember { mutableStateOf("…") }
|
var title by remember { mutableStateOf("…") }
|
||||||
var overview by remember { mutableStateOf("") }
|
var overview by remember { mutableStateOf("") }
|
||||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -594,6 +828,7 @@ fun SeriesDetailScreen(
|
||||||
var rating by remember { mutableStateOf<String?>(null) }
|
var rating by remember { mutableStateOf<String?>(null) }
|
||||||
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
|
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var trailerKey by remember { mutableStateOf<String?>(null) }
|
var trailerKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showTrailer by remember { mutableStateOf(false) }
|
||||||
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
||||||
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
|
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -698,7 +933,6 @@ fun SeriesDetailScreen(
|
||||||
|
|
||||||
val metaParts = buildList {
|
val metaParts = buildList {
|
||||||
year?.let { add(it.toString()) }
|
year?.let { add(it.toString()) }
|
||||||
genres.take(3).forEach(::add)
|
|
||||||
rating?.let { add("★ $it") }
|
rating?.let { add("★ $it") }
|
||||||
}
|
}
|
||||||
val playEpPreview = remember(episodes) {
|
val playEpPreview = remember(episodes) {
|
||||||
|
|
@ -708,21 +942,22 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
DetailBackdrop(backdrop)
|
DetailBackdrop(backdrop)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = HomeChrome.EdgeInset,
|
start = HomeChrome.EdgeInset,
|
||||||
end = HomeChrome.EdgeInset,
|
end = HomeChrome.EdgeInset,
|
||||||
top = 28.dp,
|
top = HomeChrome.BadgeTop,
|
||||||
bottom = 40.dp,
|
bottom = 40.dp,
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
DetailBackButton(onClick = onBack)
|
DetailBackButton(onClick = onBack)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
DetailLogoOrTitle(logoUrl, title)
|
||||||
|
GenrePills(genres)
|
||||||
MetaChips(metaParts)
|
MetaChips(metaParts)
|
||||||
if (overview.isNotBlank()) {
|
if (overview.isNotBlank()) {
|
||||||
ExpandableOverview(
|
ExpandableOverview(
|
||||||
|
|
@ -732,7 +967,7 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
playEpPreview?.let { ep ->
|
playEpPreview?.let { ep ->
|
||||||
val resume = !ep.completed && ep.positionMs > 30_000L
|
val resume = !ep.completed && ep.positionMs > 30_000L
|
||||||
DetailActionButton(
|
DetailActionButton(
|
||||||
|
|
@ -749,7 +984,7 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!trailerKey.isNullOrBlank()) {
|
if (!trailerKey.isNullOrBlank()) {
|
||||||
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
DetailActionButton("Trailer") { showTrailer = true }
|
||||||
}
|
}
|
||||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -759,7 +994,7 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
if (seasons.size > 1) {
|
if (seasons.size > 1) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
Text("SEIZOENEN", color = Color(0xFF8F8F8F), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(seasons) { s ->
|
items(seasons) { s ->
|
||||||
|
|
@ -791,6 +1026,13 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTrailer && !trailerKey.isNullOrBlank()) {
|
||||||
|
TrailerPlayerOverlay(
|
||||||
|
youtubeKey = trailerKey!!,
|
||||||
|
onDismiss = { showTrailer = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val menuEp = episodeMenu
|
val menuEp = episodeMenu
|
||||||
if (menuEp != null) {
|
if (menuEp != null) {
|
||||||
BackHandler { episodeMenu = null }
|
BackHandler { episodeMenu = null }
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,29 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.focusable
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
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.size
|
||||||
|
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
|
||||||
|
|
@ -20,6 +37,8 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
|
@ -31,25 +50,34 @@ 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.C
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.Glow
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
|
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||||
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
|
||||||
|
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||||
|
import nl.vonas.mediacluster.tv.playback.listTracks
|
||||||
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
import nl.vonas.mediacluster.tv.playback.liveMediaItem
|
||||||
|
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
private const val ReadyTimeoutMs = 20_000L
|
private const val ReadyTimeoutMs = 20_000L
|
||||||
|
private const val ChromeHideMs = 5_000L
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -58,6 +86,8 @@ fun EventPlayerScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
eventId: String,
|
eventId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
/** Hub-play (live + replay via /client/f1/content/…/play). */
|
||||||
|
hubPlay: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||||
|
|
@ -65,12 +95,38 @@ fun EventPlayerScreen(
|
||||||
buildLiveTvPlayer(context).also { playerRef.set(it) }
|
buildLiveTvPlayer(context).also { playerRef.set(it) }
|
||||||
}
|
}
|
||||||
val rootFocus = remember { FocusRequester() }
|
val rootFocus = remember { FocusRequester() }
|
||||||
|
val audioBtnFocus = remember { FocusRequester() }
|
||||||
|
val panelFocus = remember { FocusRequester() }
|
||||||
val reachedReady = remember { AtomicBoolean(false) }
|
val reachedReady = remember { AtomicBoolean(false) }
|
||||||
var title by remember { mutableStateOf("Live event") }
|
var title by remember { mutableStateOf("Live event") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var showChrome by remember { mutableStateOf(true) }
|
var showChrome by remember { mutableStateOf(true) }
|
||||||
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
var watchSessionId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||||
|
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||||
|
var selectedAudioKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var selectedTextKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var textDisabled by remember { mutableStateOf(true) }
|
||||||
|
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||||
|
var chromeBump by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
fun refreshTracks() {
|
||||||
|
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||||
|
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||||
|
selectedAudioKey = player.activeTrackKey(C.TRACK_TYPE_AUDIO)
|
||||||
|
selectedTextKey = player.activeTrackKey(C.TRACK_TYPE_TEXT)
|
||||||
|
textDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT) ||
|
||||||
|
selectedTextKey == null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revealChrome(focusControls: Boolean = false) {
|
||||||
|
showChrome = true
|
||||||
|
chromeBump++
|
||||||
|
if (focusControls && menu == null) {
|
||||||
|
runCatching { audioBtnFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun safeStopAndClear() {
|
fun safeStopAndClear() {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -80,14 +136,24 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(eventId) {
|
LaunchedEffect(eventId, hubPlay) {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
watchSessionId = null
|
watchSessionId = null
|
||||||
reachedReady.set(false)
|
reachedReady.set(false)
|
||||||
showChrome = true
|
showChrome = true
|
||||||
|
menu = null
|
||||||
|
audioTracks = emptyList()
|
||||||
|
textTracks = emptyList()
|
||||||
try {
|
try {
|
||||||
val play = api.scheduleEventPlay(eventId)
|
val play = if (hubPlay) {
|
||||||
|
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
||||||
|
?.groupValues?.getOrNull(1)
|
||||||
|
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
|
||||||
|
api.f1tvContentPlay(contentId)
|
||||||
|
} else {
|
||||||
|
api.scheduleEventPlay(eventId)
|
||||||
|
}
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
title = play.name
|
title = play.name
|
||||||
watchSessionId = play.watchSessionId
|
watchSessionId = play.watchSessionId
|
||||||
|
|
@ -96,14 +162,11 @@ fun EventPlayerScreen(
|
||||||
error = "Geen stream-URL beschikbaar"
|
error = "Geen stream-URL beschikbaar"
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
if (play.drm == null || play.drm.keys.isEmpty()) {
|
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
|
||||||
error = "Encryptiesleutels ontbreken — stream start binnenkort"
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
val item = try {
|
val item = try {
|
||||||
liveMediaItem(url, play.format, play.drm)
|
liveMediaItem(url, play.format, play.drm)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Ongeldige DRM-sleutels"
|
error = e.message ?: "Kon mediastream niet openen"
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
if (!isActive) return@LaunchedEffect
|
if (!isActive) return@LaunchedEffect
|
||||||
|
|
@ -151,13 +214,27 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(showChrome, error, loading) {
|
LaunchedEffect(showChrome, menu, error, loading, chromeBump) {
|
||||||
if (showChrome && error == null && !loading) {
|
if (showChrome && menu == null && error == null && !loading) {
|
||||||
delay(4_000)
|
delay(ChromeHideMs)
|
||||||
showChrome = false
|
showChrome = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(menu) {
|
||||||
|
if (menu != null) {
|
||||||
|
delay(60)
|
||||||
|
runCatching { panelFocus.requestFocus() }
|
||||||
|
} else {
|
||||||
|
delay(60)
|
||||||
|
if (showChrome) {
|
||||||
|
runCatching { audioBtnFocus.requestFocus() }
|
||||||
|
} else {
|
||||||
|
runCatching { rootFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(80)
|
delay(80)
|
||||||
runCatching { rootFocus.requestFocus() }
|
runCatching { rootFocus.requestFocus() }
|
||||||
|
|
@ -169,12 +246,22 @@ fun EventPlayerScreen(
|
||||||
when (state) {
|
when (state) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
reachedReady.set(true)
|
reachedReady.set(true)
|
||||||
runCatching { player.ensureAudioSelected() }
|
runCatching {
|
||||||
|
player.ensureAudioSelected()
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||||
|
runCatching {
|
||||||
|
player.ensureAudioSelected()
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPlayerError(e: PlaybackException) {
|
override fun onPlayerError(e: PlaybackException) {
|
||||||
val cause = generateSequence(e as Throwable?) { it.cause }
|
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||||
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||||
|
|
@ -200,7 +287,13 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler {
|
||||||
|
when {
|
||||||
|
menu != null -> menu = null
|
||||||
|
showChrome -> showChrome = false
|
||||||
|
else -> onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -209,13 +302,37 @@ fun EventPlayerScreen(
|
||||||
.focusRequester(rootFocus)
|
.focusRequester(rootFocus)
|
||||||
.focusable()
|
.focusable()
|
||||||
.onPreviewKeyEvent { ev ->
|
.onPreviewKeyEvent { ev ->
|
||||||
if (ev.type == KeyEventType.KeyDown &&
|
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
(ev.key == Key.Back || ev.key == Key.Escape)
|
when {
|
||||||
) {
|
ev.key == Key.Back || ev.key == Key.Escape -> {
|
||||||
onBack()
|
when {
|
||||||
true
|
menu != null -> {
|
||||||
} else {
|
menu = null
|
||||||
false
|
true
|
||||||
|
}
|
||||||
|
showChrome -> {
|
||||||
|
showChrome = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
onBack()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu != null -> false
|
||||||
|
ev.key == Key.Menu || ev.key == Key.Guide -> {
|
||||||
|
revealChrome(focusControls = true)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
!showChrome -> {
|
||||||
|
revealChrome(focusControls = true)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
revealChrome()
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -266,15 +383,353 @@ fun EventPlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (showChrome) {
|
} else {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showChrome || menu != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(
|
||||||
|
EventsLook.Scrim,
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Transparent,
|
||||||
|
EventsLook.Scrim,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(horizontal = 32.dp, vertical = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.background(EventsLook.Live, RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"LIVE",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = 3.dp, height = 14.dp)
|
||||||
|
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"EVENT",
|
||||||
|
color = EventsLook.Neon,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp, vertical = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
EventPlayerChip(
|
||||||
|
label = "Audio",
|
||||||
|
subtitle = when {
|
||||||
|
audioTracks.isEmpty() -> "—"
|
||||||
|
else -> audioTracks.firstOrNull {
|
||||||
|
trackKey(it) == selectedAudioKey
|
||||||
|
}?.label ?: audioTracks.first().label
|
||||||
|
},
|
||||||
|
focusRequester = audioBtnFocus,
|
||||||
|
onClick = {
|
||||||
|
revealChrome()
|
||||||
|
menu = "audio"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
EventPlayerChip(
|
||||||
|
label = "Ondertiteling",
|
||||||
|
subtitle = when {
|
||||||
|
textDisabled || textTracks.isEmpty() -> "Uit"
|
||||||
|
else -> textTracks.firstOrNull {
|
||||||
|
trackKey(it) == selectedTextKey
|
||||||
|
}?.label ?: "Aan"
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
revealChrome()
|
||||||
|
menu = "text"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"OK / Menu · audio & ondertiteling · Terug · sluiten",
|
||||||
|
color = Color(0x88FFFFFF),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = menu != null,
|
||||||
|
enter = fadeIn() + slideInHorizontally { it / 3 },
|
||||||
|
exit = fadeOut() + slideOutHorizontally { it / 3 },
|
||||||
|
) {
|
||||||
|
val isAudio = menu == "audio"
|
||||||
|
val tracks = if (isAudio) audioTracks else textTracks
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0x99050A08)),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(420.dp)
|
||||||
|
.background(EventsLook.Panel)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
listOf(EventsLook.NeonGlow, Color.Transparent, EventsLook.NeonSoft),
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (isAudio) "Audio" else "Ondertiteling",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(top = 2.dp, bottom = 10.dp)
|
||||||
|
.width(40.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(EventsLook.Neon, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
if (!isAudio) {
|
||||||
|
EventTrackRow(
|
||||||
|
label = "Uit",
|
||||||
|
selected = textDisabled || tracks.isEmpty(),
|
||||||
|
focusRequester = panelFocus,
|
||||||
|
onClick = {
|
||||||
|
player.disableTrackType(C.TRACK_TYPE_TEXT)
|
||||||
|
textDisabled = true
|
||||||
|
selectedTextKey = null
|
||||||
|
menu = null
|
||||||
|
revealChrome()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
if (isAudio) "Geen audiosporen in deze stream" else "Geen ondertitels in deze stream",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(tracks, key = { trackKey(it) }) { track ->
|
||||||
|
val selected = if (isAudio) {
|
||||||
|
trackKey(track) == selectedAudioKey
|
||||||
|
} else {
|
||||||
|
!textDisabled && trackKey(track) == selectedTextKey
|
||||||
|
}
|
||||||
|
EventTrackRow(
|
||||||
|
label = track.label,
|
||||||
|
selected = selected,
|
||||||
|
focusRequester = if (isAudio && tracks.firstOrNull() == track) {
|
||||||
|
panelFocus
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
player.selectTrack(track)
|
||||||
|
if (isAudio) {
|
||||||
|
selectedAudioKey = trackKey(track)
|
||||||
|
} else {
|
||||||
|
textDisabled = false
|
||||||
|
selectedTextKey = trackKey(track)
|
||||||
|
}
|
||||||
|
menu = null
|
||||||
|
revealChrome()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
EventPlayerChip(
|
||||||
|
label = "Sluiten",
|
||||||
|
subtitle = null,
|
||||||
|
compact = true,
|
||||||
|
onClick = { menu = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackKey(track: PlayerTrack): String = "${track.groupIndex}:${track.trackIndex}"
|
||||||
|
|
||||||
|
private fun ExoPlayer.activeTrackKey(type: Int): String? {
|
||||||
|
val groups = currentTracks.groups
|
||||||
|
for (gi in 0 until groups.size) {
|
||||||
|
val group = groups[gi]
|
||||||
|
if (group.type != type || !group.isSelected) continue
|
||||||
|
for (ti in 0 until group.length) {
|
||||||
|
if (group.isTrackSelected(ti)) return "$gi:$ti"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EventPlayerChip(
|
||||||
|
label: String,
|
||||||
|
subtitle: String?,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
compact: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val mod = Modifier
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = mod,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
|
glow = ClickableSurfaceDefaults.glow(
|
||||||
|
focusedGlow = Glow(
|
||||||
|
elevationColor = EventsLook.Neon,
|
||||||
|
elevation = if (focused) 12.dp else 0.dp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = androidx.tv.material3.Border(
|
||||||
|
border = androidx.compose.foundation.BorderStroke(2.dp, EventsLook.Neon),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = EventsLook.PanelHot,
|
||||||
|
focusedContainerColor = EventsLook.NeonSoft,
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(
|
||||||
|
horizontal = if (compact) 18.dp else 20.dp,
|
||||||
|
vertical = if (compact) 12.dp else 14.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
label,
|
||||||
color = Color.White,
|
color = if (focused) EventsLook.Neon else Color.White,
|
||||||
fontSize = 22.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
)
|
||||||
.align(Alignment.TopStart)
|
if (subtitle != null) {
|
||||||
.padding(28.dp),
|
Text(
|
||||||
|
subtitle,
|
||||||
|
color = if (focused) Color(0xCCFFFFFF) else McColors.TextDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EventTrackRow(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val mod = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = mod,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = when {
|
||||||
|
selected -> EventsLook.NeonSoft
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
focusedContainerColor = EventsLook.PanelHot,
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = androidx.tv.material3.Border(
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.5.dp, EventsLook.Neon),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (selected) "●" else "○",
|
||||||
|
color = if (selected || focused) EventsLook.Neon else Color(0x55FFFFFF),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ fun EventsScreen(
|
||||||
pendingFocusRestore: Boolean = false,
|
pendingFocusRestore: Boolean = false,
|
||||||
onFocusRestoreConsumed: () -> Unit = {},
|
onFocusRestoreConsumed: () -> Unit = {},
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
|
onOpenF1Hub: (() -> Unit)? = null,
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
var events by remember { mutableStateOf<List<ScheduleEvent>>(emptyList()) }
|
||||||
|
|
@ -224,10 +225,13 @@ fun EventsScreen(
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus: TopNav → één filterrij (sport + bron) → first rail.
|
// Focus: TopNav → F1-tile (optioneel) → filterrij → first rail.
|
||||||
|
val hasF1Entry = onOpenF1Hub != null
|
||||||
val showFilterBar = showSportChips || showProviderChips
|
val showFilterBar = showSportChips || showProviderChips
|
||||||
|
val filterBarFocus = remember { FocusRequester() }
|
||||||
val firstRailUpTarget = when {
|
val firstRailUpTarget = when {
|
||||||
showFilterBar && filterBarFocusReady -> contentFocusRequester
|
showFilterBar && filterBarFocusReady -> filterBarFocus
|
||||||
|
hasF1Entry -> contentFocusRequester
|
||||||
else -> navFocusRequester
|
else -> navFocusRequester
|
||||||
}
|
}
|
||||||
val filterFocusDown: FocusRequester? = when {
|
val filterFocusDown: FocusRequester? = when {
|
||||||
|
|
@ -235,6 +239,7 @@ fun EventsScreen(
|
||||||
railFocusReady -> firstRailFocus
|
railFocusReady -> firstRailFocus
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
val railUsesDedicatedFocus = hasF1Entry || showFilterBar
|
||||||
|
|
||||||
fun claimRailFocus(owner: String, ready: Boolean) {
|
fun claimRailFocus(owner: String, ready: Boolean) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
|
|
@ -265,6 +270,7 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0
|
||||||
val restoreListIndex = remember(
|
val restoreListIndex = remember(
|
||||||
|
hasF1Entry,
|
||||||
showFilterBar,
|
showFilterBar,
|
||||||
live,
|
live,
|
||||||
upcomingRails,
|
upcomingRails,
|
||||||
|
|
@ -273,6 +279,7 @@ fun EventsScreen(
|
||||||
) {
|
) {
|
||||||
if (!canRestoreCard) return@remember -1
|
if (!canRestoreCard) return@remember -1
|
||||||
var idx = 0
|
var idx = 0
|
||||||
|
if (hasF1Entry) idx++
|
||||||
if (showFilterBar) idx++
|
if (showFilterBar) idx++
|
||||||
if (restoreInLive) return@remember idx
|
if (restoreInLive) return@remember idx
|
||||||
if (live.isNotEmpty()) idx++
|
if (live.isNotEmpty()) idx++
|
||||||
|
|
@ -295,7 +302,7 @@ fun EventsScreen(
|
||||||
val activeRestoreFocus = when {
|
val activeRestoreFocus = when {
|
||||||
!canRestoreCard -> null
|
!canRestoreCard -> null
|
||||||
restoreIsFirstOfFirstRail -> when {
|
restoreIsFirstOfFirstRail -> when {
|
||||||
showFilterBar -> firstRailFocus
|
railUsesDedicatedFocus -> firstRailFocus
|
||||||
else -> contentFocusRequester
|
else -> contentFocusRequester
|
||||||
}
|
}
|
||||||
else -> restoreCardFocus
|
else -> restoreCardFocus
|
||||||
|
|
@ -313,12 +320,14 @@ fun EventsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val entryReady = when {
|
val entryReady = when {
|
||||||
|
hasF1Entry -> true
|
||||||
showFilterBar -> filterBarFocusReady
|
showFilterBar -> filterBarFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
val needContentSink = when {
|
val needContentSink = when {
|
||||||
loading && events.isEmpty() -> true
|
loading && events.isEmpty() -> true
|
||||||
error != null && events.isEmpty() -> true
|
error != null && events.isEmpty() -> true
|
||||||
|
hasF1Entry -> false
|
||||||
showFilterBar -> !filterBarFocusReady
|
showFilterBar -> !filterBarFocusReady
|
||||||
!hasRails -> true
|
!hasRails -> true
|
||||||
else -> !railFocusReady
|
else -> !railFocusReady
|
||||||
|
|
@ -343,6 +352,7 @@ fun EventsScreen(
|
||||||
if (restoredSuccessfully) return
|
if (restoredSuccessfully) return
|
||||||
val entryOk = awaitReady({
|
val entryOk = awaitReady({
|
||||||
when {
|
when {
|
||||||
|
hasF1Entry -> true
|
||||||
showFilterBar -> filterBarFocusReady
|
showFilterBar -> filterBarFocusReady
|
||||||
else -> railFocusReady
|
else -> railFocusReady
|
||||||
}
|
}
|
||||||
|
|
@ -465,6 +475,16 @@ fun EventsScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 56.dp),
|
contentPadding = PaddingValues(bottom = 56.dp),
|
||||||
) {
|
) {
|
||||||
|
if (hasF1Entry) {
|
||||||
|
item(key = "f1_hub_entry") {
|
||||||
|
F1HubEntryTile(
|
||||||
|
focusRequester = contentFocusRequester,
|
||||||
|
focusUp = navFocusRequester,
|
||||||
|
onOpen = onOpenF1Hub!!,
|
||||||
|
onFocus = onContentFocused,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
|
// Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft.
|
||||||
if (showFilterBar) {
|
if (showFilterBar) {
|
||||||
item(key = "filters") {
|
item(key = "filters") {
|
||||||
|
|
@ -478,8 +498,8 @@ fun EventsScreen(
|
||||||
onProviderSelect = { providerFilter = it },
|
onProviderSelect = { providerFilter = it },
|
||||||
liveCount = live.size,
|
liveCount = live.size,
|
||||||
totalCount = filtered.size,
|
totalCount = filtered.size,
|
||||||
firstFocusRequester = contentFocusRequester,
|
firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester,
|
||||||
focusUp = navFocusRequester,
|
focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester,
|
||||||
focusDown = filterFocusDown,
|
focusDown = filterFocusDown,
|
||||||
onFocusReady = { filterBarFocusReady = it },
|
onFocusReady = { filterBarFocusReady = it },
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
|
|
@ -511,7 +531,7 @@ fun EventsScreen(
|
||||||
style = EventRailStyle.Live,
|
style = EventRailStyle.Live,
|
||||||
onOpen = onOpenEvent,
|
onOpen = onOpenEvent,
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = if (showFilterBar) {
|
firstItemFocusRequester = if (railUsesDedicatedFocus) {
|
||||||
firstRailFocus
|
firstRailFocus
|
||||||
} else {
|
} else {
|
||||||
contentFocusRequester
|
contentFocusRequester
|
||||||
|
|
@ -545,7 +565,7 @@ fun EventsScreen(
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = when {
|
firstItemFocusRequester = when {
|
||||||
!isFirstUpcoming -> null
|
!isFirstUpcoming -> null
|
||||||
showFilterBar -> firstRailFocus
|
railUsesDedicatedFocus -> firstRailFocus
|
||||||
else -> contentFocusRequester
|
else -> contentFocusRequester
|
||||||
},
|
},
|
||||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
||||||
|
|
@ -579,7 +599,7 @@ fun EventsScreen(
|
||||||
onFocus = onContentFocused,
|
onFocus = onContentFocused,
|
||||||
firstItemFocusRequester = when {
|
firstItemFocusRequester = when {
|
||||||
!isFirstUpcoming -> null
|
!isFirstUpcoming -> null
|
||||||
showFilterBar -> firstRailFocus
|
railUsesDedicatedFocus -> firstRailFocus
|
||||||
else -> contentFocusRequester
|
else -> contentFocusRequester
|
||||||
},
|
},
|
||||||
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
restoreEventId = bindRestoreCardId.takeIf { railHasRestore },
|
||||||
|
|
@ -1056,6 +1076,96 @@ private fun PulsingLiveDot(size: androidx.compose.ui.unit.Dp = 10.dp) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun F1HubEntryTile(
|
||||||
|
focusRequester: FocusRequester?,
|
||||||
|
focusUp: FocusRequester?,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onFocus: () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val f1Red = Color(0xFFE10600)
|
||||||
|
Surface(
|
||||||
|
onClick = onOpen,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = HomeChrome.EdgeInset)
|
||||||
|
.padding(top = 8.dp, bottom = 10.dp)
|
||||||
|
.height(72.dp)
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
|
.then(
|
||||||
|
if (focusUp != null) {
|
||||||
|
Modifier.focusProperties { up = focusUp }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.onFocusChanged {
|
||||||
|
focused = it.isFocused
|
||||||
|
if (it.isFocused) onFocus()
|
||||||
|
},
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
glow = ClickableSurfaceDefaults.glow(
|
||||||
|
focusedGlow = Glow(elevationColor = f1Red, elevation = 14.dp),
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.5.dp, f1Red),
|
||||||
|
inset = 4.dp,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color(0xFF141414),
|
||||||
|
focusedContainerColor = Color(0xFF1E1212),
|
||||||
|
),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(f1Red),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"F1",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Formule 1",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Agenda & terugkijken",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Open →",
|
||||||
|
color = if (focused) f1Red else McColors.TextDim,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventCard(
|
private fun EventCard(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -131,6 +131,10 @@ fun McNav() {
|
||||||
var eventsReturnNonce by remember { mutableIntStateOf(0) }
|
var eventsReturnNonce by remember { mutableIntStateOf(0) }
|
||||||
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
|
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
|
||||||
|
var homeReturnNonce by remember { mutableIntStateOf(0) }
|
||||||
|
var lastOpenedHomeKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingHomeFocusRestore by remember { mutableStateOf(false) }
|
||||||
|
var homeDetailFromHome by remember { mutableStateOf(false) }
|
||||||
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
||||||
val hubTab = remember(hubTabName) {
|
val hubTab = remember(hubTabName) {
|
||||||
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||||
|
|
@ -328,12 +332,21 @@ fun McNav() {
|
||||||
restoreEventId = lastOpenedEventId,
|
restoreEventId = lastOpenedEventId,
|
||||||
pendingEventsFocusRestore = pendingEventsFocusRestore,
|
pendingEventsFocusRestore = pendingEventsFocusRestore,
|
||||||
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
|
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
|
||||||
|
homeReturnNonce = homeReturnNonce,
|
||||||
|
restoreHomeKey = lastOpenedHomeKey,
|
||||||
|
pendingHomeFocusRestore = pendingHomeFocusRestore,
|
||||||
|
onHomeFocusRestoreConsumed = { pendingHomeFocusRestore = false },
|
||||||
|
onHomeItemOpened = { key ->
|
||||||
|
lastOpenedHomeKey = key
|
||||||
|
homeDetailFromHome = true
|
||||||
|
},
|
||||||
profileAvatarId = profileAvatarId,
|
profileAvatarId = profileAvatarId,
|
||||||
profileShowAvatar = profileShowAvatar,
|
profileShowAvatar = profileShowAvatar,
|
||||||
profileAvatarUrl = profileAvatarUrl,
|
profileAvatarUrl = profileAvatarUrl,
|
||||||
profileName = profileName,
|
profileName = profileName,
|
||||||
onOpenMovie = { nav.navigate("movie/$it") },
|
onOpenMovie = { nav.navigate("movie/$it") },
|
||||||
onOpenSeries = { nav.navigate("series/$it") },
|
onOpenSeries = { nav.navigate("series/$it") },
|
||||||
|
onLeaveHomeDetailOrigin = { homeDetailFromHome = false },
|
||||||
onBrowse = { catalogId, _title ->
|
onBrowse = { catalogId, _title ->
|
||||||
nav.navigate("browse/$catalogId")
|
nav.navigate("browse/$catalogId")
|
||||||
},
|
},
|
||||||
|
|
@ -352,6 +365,11 @@ fun McNav() {
|
||||||
lastOpenedEventId = eventId
|
lastOpenedEventId = eventId
|
||||||
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
nav.navigate("event/${android.net.Uri.encode(eventId)}")
|
||||||
},
|
},
|
||||||
|
onOpenF1Hub = {
|
||||||
|
nav.navigate("f1_hub") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
onSwitchProfile = {
|
onSwitchProfile = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.clearProfile() }
|
runCatching { api.clearProfile() }
|
||||||
|
|
@ -389,8 +407,14 @@ fun McNav() {
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = catalogId,
|
catalogId = catalogId,
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
onOpenMovie = { nav.navigate("movie/$it") },
|
onOpenMovie = {
|
||||||
onOpenSeries = { nav.navigate("series/$it") },
|
homeDetailFromHome = false
|
||||||
|
nav.navigate("movie/$it")
|
||||||
|
},
|
||||||
|
onOpenSeries = {
|
||||||
|
homeDetailFromHome = false
|
||||||
|
nav.navigate("series/$it")
|
||||||
|
},
|
||||||
onPlayContinue = if (catalogId == "continue-watching") {
|
onPlayContinue = if (catalogId == "continue-watching") {
|
||||||
{ mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
{ mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
||||||
val m = movieId?.ifBlank { null } ?: "-"
|
val m = movieId?.ifBlank { null } ?: "-"
|
||||||
|
|
@ -405,6 +429,14 @@ fun McNav() {
|
||||||
}
|
}
|
||||||
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
val id = entry.arguments?.getString("id") ?: return@composable
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
if (homeDetailFromHome) {
|
||||||
|
pendingHomeFocusRestore = true
|
||||||
|
homeReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
MovieDetailScreen(
|
MovieDetailScreen(
|
||||||
api = api,
|
api = api,
|
||||||
movieId = id,
|
movieId = id,
|
||||||
|
|
@ -416,6 +448,14 @@ fun McNav() {
|
||||||
}
|
}
|
||||||
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
val id = entry.arguments?.getString("id") ?: return@composable
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
if (homeDetailFromHome) {
|
||||||
|
pendingHomeFocusRestore = true
|
||||||
|
homeReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
SeriesDetailScreen(
|
SeriesDetailScreen(
|
||||||
api = api,
|
api = api,
|
||||||
seriesId = id,
|
seriesId = id,
|
||||||
|
|
@ -518,6 +558,72 @@ fun McNav() {
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable("f1_hub") {
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
pendingEventsFocusRestore = true
|
||||||
|
eventsReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
F1HubScreen(
|
||||||
|
api = api,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onOpenMeeting = { key ->
|
||||||
|
nav.navigate("f1_meeting/${android.net.Uri.encode(key)}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlay = { contentId ->
|
||||||
|
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
"f1_meeting/{meetingKey}",
|
||||||
|
arguments = listOf(navArgument("meetingKey") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
val meetingKey = entry.arguments?.getString("meetingKey")
|
||||||
|
?.let { android.net.Uri.decode(it) }
|
||||||
|
?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
pendingEventsFocusRestore = true
|
||||||
|
eventsReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
F1MeetingScreen(
|
||||||
|
api = api,
|
||||||
|
meetingKey = meetingKey,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onPlay = { contentId ->
|
||||||
|
nav.navigate("f1_play/${android.net.Uri.encode(contentId)}") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
"f1_play/{contentId}",
|
||||||
|
arguments = listOf(navArgument("contentId") { type = NavType.StringType }),
|
||||||
|
) { entry ->
|
||||||
|
val contentId = entry.arguments?.getString("contentId")
|
||||||
|
?.let { android.net.Uri.decode(it) }
|
||||||
|
?: return@composable
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
pendingEventsFocusRestore = true
|
||||||
|
eventsReturnNonce++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventPlayerScreen(
|
||||||
|
api = api,
|
||||||
|
eventId = "f1tv:$contentId",
|
||||||
|
hubPlay = true,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,6 +649,12 @@ private fun MainHub(
|
||||||
restoreEventId: String? = null,
|
restoreEventId: String? = null,
|
||||||
pendingEventsFocusRestore: Boolean = false,
|
pendingEventsFocusRestore: Boolean = false,
|
||||||
onEventsFocusRestoreConsumed: () -> Unit = {},
|
onEventsFocusRestoreConsumed: () -> Unit = {},
|
||||||
|
homeReturnNonce: Int = 0,
|
||||||
|
restoreHomeKey: String? = null,
|
||||||
|
pendingHomeFocusRestore: Boolean = false,
|
||||||
|
onHomeFocusRestoreConsumed: () -> Unit = {},
|
||||||
|
onHomeItemOpened: (String) -> Unit = {},
|
||||||
|
onLeaveHomeDetailOrigin: () -> Unit = {},
|
||||||
profileAvatarId: String?,
|
profileAvatarId: String?,
|
||||||
profileShowAvatar: Boolean = true,
|
profileShowAvatar: Boolean = true,
|
||||||
profileAvatarUrl: String? = null,
|
profileAvatarUrl: String? = null,
|
||||||
|
|
@ -553,6 +665,7 @@ private fun MainHub(
|
||||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||||
onPlayLive: (String) -> Unit,
|
onPlayLive: (String) -> Unit,
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
|
onOpenF1Hub: () -> Unit = {},
|
||||||
onSwitchProfile: () -> Unit,
|
onSwitchProfile: () -> Unit,
|
||||||
onManageProfiles: () -> Unit,
|
onManageProfiles: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
|
@ -719,6 +832,15 @@ private fun MainHub(
|
||||||
runCatching { eventsContentFocus.requestFocus() }
|
runCatching { eventsContentFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terug van Movie/Series detail vanaf Home: content-focus, card-restore in HomeScreen.
|
||||||
|
LaunchedEffect(homeReturnNonce) {
|
||||||
|
if (homeReturnNonce <= 0 || tab != MainTab.Home) return@LaunchedEffect
|
||||||
|
navFocused = false
|
||||||
|
if (pendingHomeFocusRestore) return@LaunchedEffect
|
||||||
|
delay(100)
|
||||||
|
runCatching { homeContentFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
|
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
|
||||||
LaunchedEffect(tab) {
|
LaunchedEffect(tab) {
|
||||||
val name = tab.name
|
val name = tab.name
|
||||||
|
|
@ -862,10 +984,20 @@ private fun MainHub(
|
||||||
listState = homeListState,
|
listState = homeListState,
|
||||||
contentFocusRequester = homeContentFocus,
|
contentFocusRequester = homeContentFocus,
|
||||||
navFocusRequester = navFocus,
|
navFocusRequester = navFocus,
|
||||||
|
focusRestoreNonce = homeReturnNonce,
|
||||||
|
restoreHomeKey = restoreHomeKey,
|
||||||
|
pendingFocusRestore = pendingHomeFocusRestore,
|
||||||
|
onFocusRestoreConsumed = onHomeFocusRestoreConsumed,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
onChromeCompact = { homeChromeCompact = it },
|
onChromeCompact = { homeChromeCompact = it },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = { id ->
|
||||||
onOpenSeries = onOpenSeries,
|
onHomeItemOpened("movie:$id")
|
||||||
|
onOpenMovie(id)
|
||||||
|
},
|
||||||
|
onOpenSeries = { id ->
|
||||||
|
onHomeItemOpened("series:$id")
|
||||||
|
onOpenSeries(id)
|
||||||
|
},
|
||||||
onSeeAll = onBrowse,
|
onSeeAll = onBrowse,
|
||||||
onPlay = onPlay,
|
onPlay = onPlay,
|
||||||
)
|
)
|
||||||
|
|
@ -873,8 +1005,8 @@ private fun MainHub(
|
||||||
api = api,
|
api = api,
|
||||||
gridState = watchlistGridState,
|
gridState = watchlistGridState,
|
||||||
onAtTopBack = { focusTopNav() },
|
onAtTopBack = { focusTopNav() },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||||
)
|
)
|
||||||
MainTab.Movies -> BrowseScreen(
|
MainTab.Movies -> BrowseScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
|
@ -882,8 +1014,8 @@ private fun MainHub(
|
||||||
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
|
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
|
||||||
gridState = moviesGridState,
|
gridState = moviesGridState,
|
||||||
onAtTopBack = { focusTopNav() },
|
onAtTopBack = { focusTopNav() },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||||
)
|
)
|
||||||
MainTab.Series -> BrowseScreen(
|
MainTab.Series -> BrowseScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
|
@ -891,8 +1023,8 @@ private fun MainHub(
|
||||||
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
|
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
|
||||||
gridState = seriesGridState,
|
gridState = seriesGridState,
|
||||||
onAtTopBack = { focusTopNav() },
|
onAtTopBack = { focusTopNav() },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||||
)
|
)
|
||||||
MainTab.Tv -> TvScreen(
|
MainTab.Tv -> TvScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
|
@ -931,12 +1063,13 @@ private fun MainHub(
|
||||||
pendingFocusRestore = pendingEventsFocusRestore,
|
pendingFocusRestore = pendingEventsFocusRestore,
|
||||||
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
|
||||||
onOpenEvent = onOpenEvent,
|
onOpenEvent = onOpenEvent,
|
||||||
|
onOpenF1Hub = onOpenF1Hub,
|
||||||
onContentFocused = { navFocused = false },
|
onContentFocused = { navFocused = false },
|
||||||
)
|
)
|
||||||
MainTab.Search -> SearchScreen(
|
MainTab.Search -> SearchScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) },
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) },
|
||||||
onAtTopBack = { focusTopNav() },
|
onAtTopBack = { focusTopNav() },
|
||||||
)
|
)
|
||||||
MainTab.Settings -> SettingsScreen(
|
MainTab.Settings -> SettingsScreen(
|
||||||
|
|
@ -1163,6 +1296,10 @@ private fun HomeScreen(
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
contentFocusRequester: FocusRequester,
|
contentFocusRequester: FocusRequester,
|
||||||
navFocusRequester: FocusRequester,
|
navFocusRequester: FocusRequester,
|
||||||
|
focusRestoreNonce: Int = 0,
|
||||||
|
restoreHomeKey: String? = null,
|
||||||
|
pendingFocusRestore: Boolean = false,
|
||||||
|
onFocusRestoreConsumed: () -> Unit = {},
|
||||||
onContentFocused: () -> Unit,
|
onContentFocused: () -> Unit,
|
||||||
onChromeCompact: (Boolean) -> Unit = {},
|
onChromeCompact: (Boolean) -> Unit = {},
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
|
|
@ -1177,12 +1314,14 @@ private fun HomeScreen(
|
||||||
var menuReady by remember { mutableStateOf(false) }
|
var menuReady by remember { mutableStateOf(false) }
|
||||||
var shelfFocusReady by remember { mutableStateOf(false) }
|
var shelfFocusReady by remember { mutableStateOf(false) }
|
||||||
var playFocusReady by remember { mutableStateOf(false) }
|
var playFocusReady by remember { mutableStateOf(false) }
|
||||||
|
var restoreCardReady by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val menuFocus = remember { FocusRequester() }
|
val menuFocus = remember { FocusRequester() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val store = remember { SessionStore(context) }
|
val store = remember { SessionStore(context) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val firstShelfFocus = remember { FocusRequester() }
|
val firstShelfFocus = remember { FocusRequester() }
|
||||||
|
val restoreCardFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
suspend fun reloadHome() {
|
suspend fun reloadHome() {
|
||||||
val fresh = api.home()
|
val fresh = api.home()
|
||||||
|
|
@ -1219,25 +1358,102 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
|
val continueItems = home?.continueWatching.orEmpty()
|
||||||
LaunchedEffect(Unit) {
|
val visibleShelves = remember(home?.shelves) {
|
||||||
delay(80)
|
home?.shelves.orEmpty().filter { it.items.size >= 3 }
|
||||||
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
|
}
|
||||||
if (scrolled) {
|
|
||||||
onContentFocused()
|
data class HomeRestoreTarget(
|
||||||
val deadline = System.currentTimeMillis() + 700
|
val listIndex: Int,
|
||||||
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
|
val shelfId: String?,
|
||||||
|
val itemIndex: Int,
|
||||||
|
val isFirstOfFirstShelf: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun MediaItem.detailKey(): String = when {
|
||||||
|
type.equals("movie", true) -> "movie:$id"
|
||||||
|
type.equals("episode", true) && !seriesId.isNullOrBlank() -> "series:$seriesId"
|
||||||
|
else -> "series:$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
val restoreTarget = remember(home, continueItems, visibleShelves, restoreHomeKey, error) {
|
||||||
|
if (restoreHomeKey.isNullOrBlank()) return@remember null
|
||||||
|
var listIndex = 1 // hero-spacer
|
||||||
|
if (error != null) listIndex++
|
||||||
|
if (continueItems.isNotEmpty()) {
|
||||||
|
val preview = continueItems.take(5)
|
||||||
|
val itemIndex = preview.indexOfFirst { it.detailKey() == restoreHomeKey }
|
||||||
|
if (itemIndex >= 0) {
|
||||||
|
return@remember HomeRestoreTarget(
|
||||||
|
listIndex = listIndex,
|
||||||
|
shelfId = "__continue__",
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
isFirstOfFirstShelf = itemIndex == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
listIndex++
|
||||||
|
}
|
||||||
|
visibleShelves.forEachIndexed { shelfPos, shelf ->
|
||||||
|
val itemIndex = shelf.items.indexOfFirst { it.detailKey() == restoreHomeKey }
|
||||||
|
if (itemIndex >= 0) {
|
||||||
|
val isFirstShelf = continueItems.isEmpty() && shelfPos == 0
|
||||||
|
return@remember HomeRestoreTarget(
|
||||||
|
listIndex = listIndex + shelfPos,
|
||||||
|
shelfId = shelf.id,
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
isFirstOfFirstShelf = isFirstShelf && itemIndex == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val bindRestoreKey = restoreHomeKey.takeIf {
|
||||||
|
pendingFocusRestore && restoreTarget != null && restoreTarget?.isFirstOfFirstShelf != true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bij terugkeer van detail: focus op de geopende card (of eerste plank als fallback).
|
||||||
|
LaunchedEffect(
|
||||||
|
pendingFocusRestore,
|
||||||
|
focusRestoreNonce,
|
||||||
|
restoreTarget,
|
||||||
|
home != null,
|
||||||
|
) {
|
||||||
|
if (!pendingFocusRestore || home == null) return@LaunchedEffect
|
||||||
|
delay(60)
|
||||||
|
onContentFocused()
|
||||||
|
val target = restoreTarget
|
||||||
|
if (target != null) {
|
||||||
|
runCatching { listState.scrollToItem(target.listIndex) }
|
||||||
|
delay(40)
|
||||||
|
val focusTarget = if (target.isFirstOfFirstShelf) firstShelfFocus else restoreCardFocus
|
||||||
|
val ready = if (target.isFirstOfFirstShelf) {
|
||||||
|
{ shelfFocusReady }
|
||||||
|
} else {
|
||||||
|
{ restoreCardReady }
|
||||||
|
}
|
||||||
|
val deadline = System.currentTimeMillis() + 900
|
||||||
|
while (!ready() && System.currentTimeMillis() < deadline) {
|
||||||
delay(40)
|
delay(40)
|
||||||
}
|
}
|
||||||
delay(40)
|
delay(40)
|
||||||
runCatching { firstShelfFocus.requestFocus() }
|
if (runCatching { focusTarget.requestFocus() }.isSuccess) {
|
||||||
|
onFocusRestoreConsumed()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: eerste zichtbare plank
|
||||||
|
val deadline = System.currentTimeMillis() + 500
|
||||||
|
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
|
||||||
|
delay(40)
|
||||||
|
}
|
||||||
|
runCatching { firstShelfFocus.requestFocus() }
|
||||||
|
onFocusRestoreConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||||
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
||||||
}
|
}
|
||||||
val continueItems = home?.continueWatching.orEmpty()
|
|
||||||
val screenH = LocalConfiguration.current.screenHeightDp.dp
|
val screenH = LocalConfiguration.current.screenHeightDp.dp
|
||||||
|
|
||||||
// Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade
|
// Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade
|
||||||
|
|
@ -1387,6 +1603,9 @@ private fun HomeScreen(
|
||||||
firstItemFocusRequester = firstShelfFocus,
|
firstItemFocusRequester = firstShelfFocus,
|
||||||
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
|
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
|
||||||
onFirstFocusTargetReady = { shelfFocusReady = it },
|
onFirstFocusTargetReady = { shelfFocusReady = it },
|
||||||
|
restoreItemKey = bindRestoreKey.takeIf { restoreTarget?.shelfId == "__continue__" },
|
||||||
|
restoreFocusRequester = restoreCardFocus.takeIf { restoreTarget?.shelfId == "__continue__" },
|
||||||
|
onRestoreFocusReady = { restoreCardReady = it },
|
||||||
onRowFocused = {
|
onRowFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
expandHeroFully()
|
expandHeroFully()
|
||||||
|
|
@ -1417,16 +1636,16 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
home?.shelves.orEmpty().filter { it.items.size >= 3 },
|
visibleShelves,
|
||||||
key = { it.id },
|
key = { it.id },
|
||||||
) { shelf ->
|
) { shelf ->
|
||||||
val catalogId = when (shelf.slug) {
|
val catalogId = when (shelf.slug) {
|
||||||
"recent-movies", "recent-series", "4k-movies" -> shelf.slug
|
"recent-movies", "recent-series", "4k-movies" -> shelf.slug
|
||||||
else -> shelf.slug.ifBlank { shelf.id }
|
else -> shelf.slug.ifBlank { shelf.id }
|
||||||
}
|
}
|
||||||
val firstVisibleShelfId = home?.shelves.orEmpty()
|
val firstVisibleShelfId = visibleShelves.firstOrNull()?.id
|
||||||
.firstOrNull { it.items.size >= 3 }?.id
|
|
||||||
val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId
|
val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId
|
||||||
|
val shelfHasRestore = restoreTarget?.shelfId == shelf.id
|
||||||
ShelfBlock(
|
ShelfBlock(
|
||||||
title = shelf.title,
|
title = shelf.title,
|
||||||
items = shelf.items,
|
items = shelf.items,
|
||||||
|
|
@ -1437,6 +1656,13 @@ private fun HomeScreen(
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
restoreItemKey = bindRestoreKey.takeIf { shelfHasRestore },
|
||||||
|
restoreFocusRequester = restoreCardFocus.takeIf { shelfHasRestore },
|
||||||
|
onRestoreFocusReady = if (shelfHasRestore) {
|
||||||
|
{ restoreCardReady = it }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onRowFocused = {
|
onRowFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
if (isFirstShelf) expandHeroFully()
|
if (isFirstShelf) expandHeroFully()
|
||||||
|
|
@ -1708,9 +1934,29 @@ private fun ShelfBlock(
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
focusUpTo: FocusRequester? = null,
|
focusUpTo: FocusRequester? = null,
|
||||||
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
|
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
|
||||||
|
restoreItemKey: String? = null,
|
||||||
|
restoreFocusRequester: FocusRequester? = null,
|
||||||
|
onRestoreFocusReady: ((Boolean) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var rowFocused by remember { mutableStateOf(false) }
|
var rowFocused by remember { mutableStateOf(false) }
|
||||||
|
val rowState = rememberLazyListState()
|
||||||
|
val restoreIndex = remember(items, restoreItemKey) {
|
||||||
|
if (restoreItemKey.isNullOrBlank()) -1
|
||||||
|
else items.indexOfFirst { item ->
|
||||||
|
val key = when {
|
||||||
|
item.type.equals("movie", true) -> "movie:${item.id}"
|
||||||
|
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
|
||||||
|
else -> "series:${item.id}"
|
||||||
|
}
|
||||||
|
key == restoreItemKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(restoreIndex) {
|
||||||
|
if (restoreIndex > 0) {
|
||||||
|
runCatching { rowState.scrollToItem(restoreIndex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
|
|
@ -1757,6 +2003,7 @@ private fun ShelfBlock(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = rowState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
|
|
@ -1767,23 +2014,40 @@ private fun ShelfBlock(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
val itemKey = when {
|
||||||
|
item.type.equals("movie", true) -> "movie:${item.id}"
|
||||||
|
item.type.equals("episode", true) && !item.seriesId.isNullOrBlank() -> "series:${item.seriesId}"
|
||||||
|
else -> "series:${item.id}"
|
||||||
|
}
|
||||||
|
val isRestore = restoreItemKey != null && itemKey == restoreItemKey
|
||||||
|
val cardRequester = when {
|
||||||
|
isRestore && restoreFocusRequester != null -> restoreFocusRequester
|
||||||
|
index == 0 -> firstItemFocusRequester
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (index == 0 && firstItemFocusRequester != null && cardRequester === firstItemFocusRequester) {
|
||||||
DisposableEffect(firstItemFocusRequester) {
|
DisposableEffect(firstItemFocusRequester) {
|
||||||
onFirstFocusTargetReady?.invoke(true)
|
onFirstFocusTargetReady?.invoke(true)
|
||||||
onDispose { onFirstFocusTargetReady?.invoke(false) }
|
onDispose { onFirstFocusTargetReady?.invoke(false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) {
|
||||||
|
DisposableEffect(restoreFocusRequester) {
|
||||||
|
onRestoreFocusReady?.invoke(true)
|
||||||
|
onDispose { onRestoreFocusReady?.invoke(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onClick(item) },
|
onClick = { onClick(item) },
|
||||||
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
||||||
showCaption = showCaptions,
|
showCaption = showCaptions,
|
||||||
modifier = Modifier.then(
|
modifier = Modifier.then(
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
if (cardRequester != null) {
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(firstItemFocusRequester)
|
.focusRequester(cardRequester)
|
||||||
.then(
|
.then(
|
||||||
if (focusUpTo != null) {
|
if (focusUpTo != null && (index == 0 || isRestore)) {
|
||||||
Modifier.focusProperties { up = focusUpTo }
|
Modifier.focusProperties { up = focusUpTo }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
|
A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; };
|
||||||
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
|
A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */; };
|
||||||
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; };
|
A11C1EAF0000000000000004 /* ClearKeyDashProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */; };
|
||||||
|
A11C1EAF0000000000000005 /* AVLivePlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000005 /* AVLivePlayerController.swift */; };
|
||||||
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
|
F8AE29FF63524E7D8C036C7E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522137D80C53480E95FF600F /* RootView.swift */; };
|
||||||
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
|
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
|
||||||
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
|
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
|
B11C1EAF0000000000000002 /* AesCtr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AesCtr.swift; sourceTree = "<group>"; };
|
||||||
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
|
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = "<group>"; };
|
||||||
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
|
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = "<group>"; };
|
||||||
|
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVLivePlayerController.swift; sourceTree = "<group>"; };
|
||||||
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
|
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
|
||||||
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -163,6 +165,7 @@
|
||||||
B11C1EAF0000000000000002 /* AesCtr.swift */,
|
B11C1EAF0000000000000002 /* AesCtr.swift */,
|
||||||
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
|
B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */,
|
||||||
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
|
B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */,
|
||||||
|
B11C1EAF0000000000000005 /* AVLivePlayerController.swift */,
|
||||||
);
|
);
|
||||||
path = Playback;
|
path = Playback;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -338,7 +341,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -353,7 +356,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.36;
|
MARKETING_VERSION = 0.5.39;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -371,7 +374,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 49;
|
CURRENT_PROJECT_VERSION = 52;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -386,7 +389,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.5.36;
|
MARKETING_VERSION = 0.5.39;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
417
apps/ios/VMC/Playback/AVLivePlayerController.swift
Normal file
417
apps/ios/VMC/Playback/AVLivePlayerController.swift
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AVMediaOptionItem: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native AVPlayer voor ClearKey-live via lokale clear-HLS proxy (on-device decrypt).
|
||||||
|
@MainActor
|
||||||
|
final class AVLivePlayerController: NSObject, ObservableObject {
|
||||||
|
@Published var isPlaying = false
|
||||||
|
@Published var stateText = "Laden…"
|
||||||
|
@Published var hasError = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
/// True als AVPlayer minstens één video-track / presentatiegrootte heeft.
|
||||||
|
@Published var hasVideo = false
|
||||||
|
@Published var audioOptions: [AVMediaOptionItem] = []
|
||||||
|
@Published var subtitleOptions: [AVMediaOptionItem] = []
|
||||||
|
@Published var currentAudioId: String?
|
||||||
|
@Published var currentSubtitleId: String?
|
||||||
|
|
||||||
|
let player = AVPlayer()
|
||||||
|
private var proxySession = 0
|
||||||
|
private var proxyActive = false
|
||||||
|
private var timeControlObs: NSKeyValueObservation?
|
||||||
|
private var itemStatusObs: NSKeyValueObservation?
|
||||||
|
private var endObs: NSObjectProtocol?
|
||||||
|
private var errorLogObs: NSObjectProtocol?
|
||||||
|
private var stopping = false
|
||||||
|
private var generation = 0
|
||||||
|
private var audioGroup: AVMediaSelectionGroup?
|
||||||
|
private var subtitleGroup: AVMediaSelectionGroup?
|
||||||
|
private var audioOptionById: [String: AVMediaSelectionOption] = [:]
|
||||||
|
private var subtitleOptionById: [String: AVMediaSelectionOption] = [:]
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
configureAudioSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
func play(
|
||||||
|
url: URL,
|
||||||
|
fallback: URL? = nil,
|
||||||
|
format: String? = nil,
|
||||||
|
clearKeys: [ClearKeyEntry] = []
|
||||||
|
) async {
|
||||||
|
generation &+= 1
|
||||||
|
let gen = generation
|
||||||
|
stopping = false
|
||||||
|
hasError = false
|
||||||
|
errorMessage = nil
|
||||||
|
hasVideo = false
|
||||||
|
audioOptions = []
|
||||||
|
subtitleOptions = []
|
||||||
|
currentAudioId = nil
|
||||||
|
currentSubtitleId = nil
|
||||||
|
audioGroup = nil
|
||||||
|
subtitleGroup = nil
|
||||||
|
audioOptionById = [:]
|
||||||
|
subtitleOptionById = [:]
|
||||||
|
stateText = "Live laden…"
|
||||||
|
|
||||||
|
stopProxyIfNeeded()
|
||||||
|
player.pause()
|
||||||
|
player.replaceCurrentItem(with: nil)
|
||||||
|
|
||||||
|
var playURL = url
|
||||||
|
|
||||||
|
if !clearKeys.isEmpty {
|
||||||
|
do {
|
||||||
|
stateText = "ClearKey laden…"
|
||||||
|
let started = try await ClearKeyDashProxy.shared.start(
|
||||||
|
manifestURL: url,
|
||||||
|
keys: clearKeys,
|
||||||
|
preferHlsForAVPlayer: true
|
||||||
|
)
|
||||||
|
guard gen == generation, !stopping else { return }
|
||||||
|
playURL = started.url
|
||||||
|
proxySession = started.session
|
||||||
|
proxyActive = true
|
||||||
|
proxyLogState("proxy ok \(playURL.absoluteString)")
|
||||||
|
} catch {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = "ClearKey-proxy: \(error.localizedDescription)"
|
||||||
|
stateText = "Fout"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let fmt = (format ?? "").lowercased()
|
||||||
|
let isHls = fmt == "hls" || ClearKeyDashProxy.isHlsURL(url)
|
||||||
|
if !isHls, let fallback, ClearKeyDashProxy.isHlsURL(fallback) {
|
||||||
|
playURL = fallback
|
||||||
|
} else if !isHls {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = "AVPlayer vereist HLS voor deze stream."
|
||||||
|
stateText = "Fout"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard gen == generation, !stopping else { return }
|
||||||
|
await startItem(url: playURL, gen: gen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard !stopping else { return }
|
||||||
|
stopping = true
|
||||||
|
generation &+= 1
|
||||||
|
clearObservers()
|
||||||
|
player.pause()
|
||||||
|
// Eerst item loskoppelen, daarna proxy — voorkomt crash op half-open sockets.
|
||||||
|
player.replaceCurrentItem(with: nil)
|
||||||
|
stopProxyIfNeeded()
|
||||||
|
isPlaying = false
|
||||||
|
hasVideo = false
|
||||||
|
audioOptions = []
|
||||||
|
subtitleOptions = []
|
||||||
|
stateText = "Gestopt"
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlay() {
|
||||||
|
if player.timeControlStatus == .playing {
|
||||||
|
player.pause()
|
||||||
|
} else {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMediaOptions() {
|
||||||
|
guard let item = player.currentItem else { return }
|
||||||
|
Task { await loadMediaOptions(for: item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAudio(id: String) {
|
||||||
|
guard let item = player.currentItem, let group = audioGroup,
|
||||||
|
let option = audioOptionById[id] else { return }
|
||||||
|
item.select(option, in: group)
|
||||||
|
currentAudioId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectSubtitle(id: String?) {
|
||||||
|
guard let item = player.currentItem, let group = subtitleGroup else { return }
|
||||||
|
if let id, let option = subtitleOptionById[id] {
|
||||||
|
item.select(option, in: group)
|
||||||
|
currentSubtitleId = id
|
||||||
|
} else {
|
||||||
|
item.select(nil, in: group)
|
||||||
|
currentSubtitleId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func startItem(url: URL, gen: Int) async {
|
||||||
|
stateText = "Openen…"
|
||||||
|
let asset = AVURLAsset(url: url, options: [
|
||||||
|
AVURLAssetAllowsCellularAccessKey: true,
|
||||||
|
AVURLAssetAllowsExpensiveNetworkAccessKey: true,
|
||||||
|
AVURLAssetAllowsConstrainedNetworkAccessKey: true,
|
||||||
|
])
|
||||||
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
item.preferredForwardBufferDuration = 8
|
||||||
|
clearObservers()
|
||||||
|
|
||||||
|
itemStatusObs = item.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.generation == gen, !self.stopping else { return }
|
||||||
|
switch item.status {
|
||||||
|
case .readyToPlay:
|
||||||
|
self.stateText = "Afspelen"
|
||||||
|
self.hasError = false
|
||||||
|
self.refreshHasVideo(from: item)
|
||||||
|
await self.loadMediaOptions(for: item)
|
||||||
|
self.player.play()
|
||||||
|
case .failed:
|
||||||
|
self.fail(from: item.error, gen: gen)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeControlObs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.generation == gen, !self.stopping else { return }
|
||||||
|
switch player.timeControlStatus {
|
||||||
|
case .playing:
|
||||||
|
self.isPlaying = true
|
||||||
|
self.stateText = "Afspelen"
|
||||||
|
self.hasError = false
|
||||||
|
if let item = player.currentItem {
|
||||||
|
self.refreshHasVideo(from: item)
|
||||||
|
}
|
||||||
|
case .waitingToPlayAtSpecifiedRate:
|
||||||
|
self.isPlaying = false
|
||||||
|
self.stateText = "Bufferen…"
|
||||||
|
case .paused:
|
||||||
|
self.isPlaying = false
|
||||||
|
if !self.hasError { self.stateText = "Pauze" }
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endObs = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.generation == gen, !self.stopping else { return }
|
||||||
|
let err = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
|
||||||
|
self.fail(from: err, gen: gen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errorLogObs = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemNewErrorLogEntry,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self, self.generation == gen else { return }
|
||||||
|
if let events = item.errorLog()?.events, let last = events.last {
|
||||||
|
let comment = last.errorComment ?? last.errorDomain
|
||||||
|
self.proxyLogState("AV errorLog: \(last.errorStatusCode) \(comment ?? "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.replaceCurrentItem(with: item)
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
|
player.play()
|
||||||
|
|
||||||
|
// Timeout alleen als er NOG NIETS speelt — nooit een werkende stream afbreken.
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 15_000_000_000)
|
||||||
|
guard self.generation == gen, !self.stopping else { return }
|
||||||
|
if let item = self.player.currentItem {
|
||||||
|
self.refreshHasVideo(from: item)
|
||||||
|
}
|
||||||
|
if !self.isPlaying && !self.hasError {
|
||||||
|
if let err = item.error {
|
||||||
|
self.fail(from: err, gen: gen)
|
||||||
|
} else {
|
||||||
|
self.hasError = true
|
||||||
|
self.errorMessage = self.bestErrorMessage(for: item)
|
||||||
|
?? "Stream opende niet binnen 15s (ClearKey/HLS)."
|
||||||
|
self.stateText = "Fout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshHasVideo(from item: AVPlayerItem) {
|
||||||
|
if item.presentationSize.height > 1 {
|
||||||
|
hasVideo = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let video = item.tracks.contains { track in
|
||||||
|
track.assetTrack?.mediaType == .video
|
||||||
|
}
|
||||||
|
hasVideo = video
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMediaOptions(for item: AVPlayerItem) async {
|
||||||
|
let asset = item.asset
|
||||||
|
do {
|
||||||
|
let audible = try await asset.loadMediaSelectionGroup(for: .audible)
|
||||||
|
audioGroup = audible
|
||||||
|
if let audible {
|
||||||
|
var opts: [AVMediaOptionItem] = []
|
||||||
|
var map: [String: AVMediaSelectionOption] = [:]
|
||||||
|
for (i, opt) in audible.options.enumerated() {
|
||||||
|
let id = "a-\(i)-\(opt.displayName)"
|
||||||
|
let name = opt.displayName.isEmpty ? "Audio \(i + 1)" : opt.displayName
|
||||||
|
opts.append(AVMediaOptionItem(id: id, name: name))
|
||||||
|
map[id] = opt
|
||||||
|
}
|
||||||
|
audioOptions = opts
|
||||||
|
audioOptionById = map
|
||||||
|
if let selected = item.selectedMediaOption(in: audible),
|
||||||
|
let match = map.first(where: { $0.value == selected })?.key {
|
||||||
|
currentAudioId = match
|
||||||
|
} else {
|
||||||
|
currentAudioId = opts.first?.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audioOptions = []
|
||||||
|
audioOptionById = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
let legible = try await asset.loadMediaSelectionGroup(for: .legible)
|
||||||
|
subtitleGroup = legible
|
||||||
|
if let legible {
|
||||||
|
var opts: [AVMediaOptionItem] = [AVMediaOptionItem(id: "off", name: "Uit")]
|
||||||
|
var map: [String: AVMediaSelectionOption] = [:]
|
||||||
|
for (i, opt) in legible.options.enumerated() where !opt.hasMediaCharacteristic(.containsOnlyForcedSubtitles) {
|
||||||
|
let id = "s-\(i)-\(opt.displayName)"
|
||||||
|
let name = opt.displayName.isEmpty ? "Ondertitel \(i + 1)" : opt.displayName
|
||||||
|
opts.append(AVMediaOptionItem(id: id, name: name))
|
||||||
|
map[id] = opt
|
||||||
|
}
|
||||||
|
subtitleOptions = opts
|
||||||
|
subtitleOptionById = map
|
||||||
|
if let selected = item.selectedMediaOption(in: legible),
|
||||||
|
let match = map.first(where: { $0.value == selected })?.key {
|
||||||
|
currentSubtitleId = match
|
||||||
|
} else {
|
||||||
|
currentSubtitleId = "off"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
subtitleOptions = [AVMediaOptionItem(id: "off", name: "Uit")]
|
||||||
|
subtitleOptionById = [:]
|
||||||
|
currentSubtitleId = "off"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
proxyLogState("media options: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(from error: Error?, gen: Int) {
|
||||||
|
guard generation == gen, !stopping else { return }
|
||||||
|
hasError = true
|
||||||
|
if let error {
|
||||||
|
errorMessage = dutchAVError(error)
|
||||||
|
} else if let item = player.currentItem {
|
||||||
|
errorMessage = bestErrorMessage(for: item) ?? "Kon stream niet openen"
|
||||||
|
} else {
|
||||||
|
errorMessage = "Kon stream niet openen"
|
||||||
|
}
|
||||||
|
stateText = "Fout"
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bestErrorMessage(for item: AVPlayerItem) -> String? {
|
||||||
|
if let err = item.error { return dutchAVError(err) }
|
||||||
|
if let events = item.errorLog()?.events, let last = events.last {
|
||||||
|
let code = last.errorStatusCode
|
||||||
|
let comment = last.errorComment ?? last.errorDomain
|
||||||
|
return "Afspeelfout (\(code)): \(comment)"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dutchAVError(_ error: Error) -> String {
|
||||||
|
let ns = error as NSError
|
||||||
|
if ns.domain == "CoreMediaErrorDomain" {
|
||||||
|
return "Mediafout (\(ns.code)). Vaak ongeldige HLS of decrypt. Probeer opnieuw."
|
||||||
|
}
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopProxyIfNeeded() {
|
||||||
|
if proxyActive {
|
||||||
|
let session = proxySession
|
||||||
|
proxyActive = false
|
||||||
|
proxySession = 0
|
||||||
|
ClearKeyDashProxy.shared.stop(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearObservers() {
|
||||||
|
timeControlObs?.invalidate()
|
||||||
|
timeControlObs = nil
|
||||||
|
itemStatusObs?.invalidate()
|
||||||
|
itemStatusObs = nil
|
||||||
|
if let endObs {
|
||||||
|
NotificationCenter.default.removeObserver(endObs)
|
||||||
|
self.endObs = nil
|
||||||
|
}
|
||||||
|
if let errorLogObs {
|
||||||
|
NotificationCenter.default.removeObserver(errorLogObs)
|
||||||
|
self.errorLogObs = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAudioSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playback, mode: .moviePlayback, options: [])
|
||||||
|
try session.setActive(true)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proxyLogState(_ msg: String) {
|
||||||
|
#if DEBUG
|
||||||
|
print("[AVLive] \(msg)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AVLivePlayerView: UIViewControllerRepresentable {
|
||||||
|
let controller: AVLivePlayerController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
|
let vc = AVPlayerViewController()
|
||||||
|
vc.player = controller.player
|
||||||
|
vc.showsPlaybackControls = false
|
||||||
|
vc.videoGravity = .resizeAspect
|
||||||
|
vc.allowsPictureInPicturePlayback = true
|
||||||
|
if #available(iOS 14.2, *) {
|
||||||
|
vc.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
|
}
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
|
||||||
|
if vc.player !== controller.player {
|
||||||
|
vc.player = controller.player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -69,7 +69,9 @@ struct PlayerScreen: View {
|
||||||
|
|
||||||
let request: PlayRequest
|
let request: PlayRequest
|
||||||
|
|
||||||
@StateObject private var player = VLCPlayerController()
|
/// ClearKey live → AVPlayer + lokale decrypt-HLS. FTA live + VOD → VLC (PiP intact).
|
||||||
|
@StateObject private var vlc = VLCPlayerController()
|
||||||
|
@StateObject private var av = AVLivePlayerController()
|
||||||
@State private var controlsVisible = true
|
@State private var controlsVisible = true
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
@ -81,23 +83,48 @@ struct PlayerScreen: View {
|
||||||
@State private var subtitleHits: [SubtitleHit] = []
|
@State private var subtitleHits: [SubtitleHit] = []
|
||||||
@State private var subsLoading = false
|
@State private var subsLoading = false
|
||||||
@State private var subsError: String?
|
@State private var subsError: String?
|
||||||
|
@State private var pendingStop: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var isLive: Bool { request.isLive }
|
||||||
|
private var useClearKeyAV: Bool { request.isLive && request.needsClearKey }
|
||||||
|
|
||||||
|
private var stateText: String { useClearKeyAV ? av.stateText : vlc.stateText }
|
||||||
|
private var isPlaying: Bool { useClearKeyAV ? av.isPlaying : vlc.isPlaying }
|
||||||
|
private var playerHasError: Bool { useClearKeyAV ? av.hasError : vlc.hasError }
|
||||||
|
private var playerErrorMessage: String? { useClearKeyAV ? av.errorMessage : vlc.errorMessage }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
VLCVideoView(controller: player)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
// VLC eet touches op — vang tikken altijd zelf af.
|
if useClearKeyAV {
|
||||||
|
AVLivePlayerView(controller: av)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
} else if isLive {
|
||||||
|
VLCVideoView(controller: vlc)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
} else {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let fitted = Self.fittedVideoSize(in: geo.size, aspect: 16.0 / 9.0)
|
||||||
|
VLCVideoView(controller: vlc)
|
||||||
|
.frame(width: fitted.width, height: fitted.height)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speler eet touches op — vang tikken altijd zelf af.
|
||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onTapGesture { toggleControls() }
|
.onTapGesture { toggleControls() }
|
||||||
.allowsHitTesting(!controlsVisible)
|
.allowsHitTesting(!controlsVisible)
|
||||||
|
|
||||||
if loading && !player.isPlaying {
|
if loading && !isPlaying {
|
||||||
ProgressView(player.stateText)
|
ProgressView(stateText)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
|
|
@ -105,8 +132,8 @@ struct PlayerScreen: View {
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
errorOverlay(error)
|
errorOverlay(error)
|
||||||
} else if player.hasError {
|
} else if playerHasError {
|
||||||
errorOverlay(player.errorMessage ?? "Afspeelfout")
|
errorOverlay(playerErrorMessage ?? "Afspeelfout")
|
||||||
}
|
}
|
||||||
|
|
||||||
if controlsVisible {
|
if controlsVisible {
|
||||||
|
|
@ -116,7 +143,11 @@ struct PlayerScreen: View {
|
||||||
}
|
}
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.persistentSystemOverlays(.hidden)
|
.persistentSystemOverlays(.hidden)
|
||||||
.onAppear { app.enterPlayback() }
|
.onAppear {
|
||||||
|
pendingStop?.cancel()
|
||||||
|
pendingStop = nil
|
||||||
|
app.enterPlayback()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await start()
|
await start()
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
|
|
@ -127,8 +158,19 @@ struct PlayerScreen: View {
|
||||||
hideTask?.cancel()
|
hideTask?.cancel()
|
||||||
progressTask?.cancel()
|
progressTask?.cancel()
|
||||||
heartbeatTask?.cancel()
|
heartbeatTask?.cancel()
|
||||||
Task { await saveProgress() }
|
if !isLive {
|
||||||
player.stop()
|
let pos = vlc.currentPositionMs()
|
||||||
|
let dur = vlc.durationMs()
|
||||||
|
Task { await saveProgress(positionMs: pos, durationMs: dur) }
|
||||||
|
}
|
||||||
|
pendingStop?.cancel()
|
||||||
|
pendingStop = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
if useClearKeyAV { av.stop() } else { vlc.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showTracks) {
|
.sheet(isPresented: $showTracks) {
|
||||||
tracksSheet
|
tracksSheet
|
||||||
|
|
@ -154,17 +196,17 @@ struct PlayerScreen: View {
|
||||||
.font(McFonts.headline)
|
.font(McFonts.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(player.stateText)
|
Text(stateText)
|
||||||
.font(McFonts.caption)
|
.font(McFonts.caption)
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if player.pipPossible {
|
if !useClearKeyAV && vlc.pipPossible {
|
||||||
Button {
|
Button {
|
||||||
player.togglePictureInPicture()
|
vlc.togglePictureInPicture()
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: player.pipActive
|
Image(systemName: vlc.pipActive
|
||||||
? "pip.exit"
|
? "pip.exit"
|
||||||
: "pip.enter")
|
: "pip.enter")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
|
@ -198,9 +240,9 @@ struct PlayerScreen: View {
|
||||||
if !request.isLive {
|
if !request.isLive {
|
||||||
Slider(
|
Slider(
|
||||||
value: Binding(
|
value: Binding(
|
||||||
get: { Double(player.position) },
|
get: { Double(vlc.position) },
|
||||||
set: {
|
set: {
|
||||||
player.seek(ratio: Float($0))
|
vlc.seek(ratio: Float($0))
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
@ -209,33 +251,33 @@ struct PlayerScreen: View {
|
||||||
.tint(McColors.accent)
|
.tint(McColors.accent)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(player.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
Text(vlc.lengthText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 36) {
|
HStack(spacing: 36) {
|
||||||
if !request.isLive {
|
if !request.isLive {
|
||||||
Button {
|
Button {
|
||||||
player.seek(toMs: max(player.currentPositionMs() - 10_000, 0))
|
vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0))
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
|
Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
player.togglePlay()
|
if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() }
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 64))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.shadow(radius: 8)
|
.shadow(radius: 8)
|
||||||
}
|
}
|
||||||
if !request.isLive {
|
if !request.isLive {
|
||||||
Button {
|
Button {
|
||||||
player.seek(toMs: player.currentPositionMs() + 10_000)
|
vlc.seek(toMs: vlc.currentPositionMs() + 10_000)
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
|
Image(systemName: "goforward.10").font(.title).foregroundStyle(.white)
|
||||||
|
|
@ -263,16 +305,16 @@ struct PlayerScreen: View {
|
||||||
|
|
||||||
if trackTab == 0 {
|
if trackTab == 0 {
|
||||||
List {
|
List {
|
||||||
ForEach(player.subtitleTracks) { track in
|
ForEach(vlc.subtitleTracks) { track in
|
||||||
Button {
|
Button {
|
||||||
player.selectSubtitle(id: track.id)
|
vlc.selectSubtitle(id: track.id)
|
||||||
showTracks = false
|
showTracks = false
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(track.name).foregroundStyle(McColors.text)
|
Text(track.name).foregroundStyle(McColors.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
if track.id == player.currentSubtitleId {
|
if track.id == vlc.currentSubtitleId {
|
||||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,16 +357,16 @@ struct PlayerScreen: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(player.audioTracks) { track in
|
ForEach(vlc.audioTracks) { track in
|
||||||
Button {
|
Button {
|
||||||
player.selectAudio(id: track.id)
|
vlc.selectAudio(id: track.id)
|
||||||
showTracks = false
|
showTracks = false
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(track.name).foregroundStyle(McColors.text)
|
Text(track.name).foregroundStyle(McColors.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
if track.id == player.currentAudioId {
|
if track.id == vlc.currentAudioId {
|
||||||
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
Image(systemName: "checkmark").foregroundStyle(McColors.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +385,7 @@ struct PlayerScreen: View {
|
||||||
Button("Sluiten") { showTracks = false }
|
Button("Sluiten") { showTracks = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { player.refreshTracks() }
|
.onAppear { vlc.refreshTracks() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,32 +441,51 @@ struct PlayerScreen: View {
|
||||||
private func start() async {
|
private func start() async {
|
||||||
loading = true
|
loading = true
|
||||||
error = nil
|
error = nil
|
||||||
if request.isLive {
|
if useClearKeyAV {
|
||||||
let keys = request.needsClearKey ? (request.drm?.keys ?? []) : []
|
let keys = request.drm?.keys ?? []
|
||||||
await player.playLive(
|
await av.play(
|
||||||
url: request.url,
|
url: request.url,
|
||||||
fallback: request.fallbackUrl,
|
fallback: request.fallbackUrl,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
clearKeys: keys
|
clearKeys: keys
|
||||||
)
|
)
|
||||||
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
|
let maxWaitNs: UInt64 = 18_000_000_000
|
||||||
let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000
|
let stepNs: UInt64 = 200_000_000
|
||||||
try? await Task.sleep(nanoseconds: waitNs)
|
var waited: UInt64 = 0
|
||||||
if request.needsClearKey && (player.hasError || !player.isPlaying) {
|
while waited < maxWaitNs && !av.hasError {
|
||||||
try? await Task.sleep(nanoseconds: 6_000_000_000)
|
if av.isPlaying { break }
|
||||||
if player.hasError || !player.isPlaying {
|
try? await Task.sleep(nanoseconds: stepNs)
|
||||||
error = player.errorMessage
|
waited += stepNs
|
||||||
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
|
}
|
||||||
}
|
if !av.isPlaying || av.hasError {
|
||||||
|
error = av.errorMessage
|
||||||
|
?? "Kon deze ClearKey-livestream niet starten."
|
||||||
|
}
|
||||||
|
} else if isLive {
|
||||||
|
await vlc.playLive(
|
||||||
|
url: request.url,
|
||||||
|
fallback: request.fallbackUrl,
|
||||||
|
format: request.format,
|
||||||
|
clearKeys: []
|
||||||
|
)
|
||||||
|
let maxWaitNs: UInt64 = 4_000_000_000
|
||||||
|
let stepNs: UInt64 = 200_000_000
|
||||||
|
var waited: UInt64 = 0
|
||||||
|
while waited < maxWaitNs && !vlc.isPlaying && !vlc.hasError {
|
||||||
|
try? await Task.sleep(nanoseconds: stepNs)
|
||||||
|
waited += stepNs
|
||||||
|
}
|
||||||
|
if vlc.hasError {
|
||||||
|
error = vlc.errorMessage ?? "Kon deze livestream niet starten."
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
vlc.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
|
||||||
progressTask = Task { await progressLoop() }
|
progressTask = Task { await progressLoop() }
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
loading = false
|
loading = false
|
||||||
if !player.hasError {
|
if !useClearKeyAV && !vlc.hasError {
|
||||||
player.refreshTracks()
|
vlc.refreshTracks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,10 +511,10 @@ struct PlayerScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveProgress() async {
|
private func saveProgress(positionMs: Int64? = nil, durationMs: Int64? = nil) async {
|
||||||
guard !request.isLive else { return }
|
guard !request.isLive else { return }
|
||||||
let pos = player.currentPositionMs()
|
let pos = positionMs ?? vlc.currentPositionMs()
|
||||||
let dur = player.durationMs()
|
let dur = durationMs ?? vlc.durationMs()
|
||||||
guard pos > 0 else { return }
|
guard pos > 0 else { return }
|
||||||
try? await app.api.postProgress(
|
try? await app.api.postProgress(
|
||||||
mediaType: request.mediaType,
|
mediaType: request.mediaType,
|
||||||
|
|
@ -492,13 +553,25 @@ struct PlayerScreen: View {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
|
.appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)")
|
||||||
try data.write(to: url, options: .atomic)
|
try data.write(to: url, options: .atomic)
|
||||||
player.addExternalSubtitle(url: url)
|
vlc.addExternalSubtitle(url: url)
|
||||||
showTracks = false
|
showTracks = false
|
||||||
scheduleHide()
|
scheduleHide()
|
||||||
} catch {
|
} catch {
|
||||||
subsError = error.localizedDescription
|
subsError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func fittedVideoSize(in container: CGSize, aspect: CGFloat) -> CGSize {
|
||||||
|
guard container.width > 0, container.height > 0, aspect > 0 else { return container }
|
||||||
|
let containerAspect = container.width / container.height
|
||||||
|
if containerAspect > aspect {
|
||||||
|
let h = container.height
|
||||||
|
return CGSize(width: h * aspect, height: h)
|
||||||
|
} else {
|
||||||
|
let w = container.width
|
||||||
|
return CGSize(width: w, height: w / aspect)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlayLauncher: View {
|
struct PlayLauncher: View {
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||||
stateText = "ClearKey laden…"
|
stateText = "ClearKey laden…"
|
||||||
// Async: blokkeer de main thread niet (watchdog-crash).
|
// Async: blokkeer de main thread niet (watchdog-crash).
|
||||||
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
|
// HLS (ESPN) én DASH (Prime) via dezelfde proxy.
|
||||||
playURL = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
let started = try await ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
|
||||||
|
playURL = started.url
|
||||||
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
|
playFormat = ClearKeyDashProxy.isHlsURL(url) ? "hls" : "dash"
|
||||||
clearKeyProxyActive = true
|
clearKeyProxyActive = true
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -820,7 +821,6 @@ struct VLCVideoView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {
|
func updateUIView(_ uiView: UIView, context: Context) {
|
||||||
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout).
|
// Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only).
|
||||||
controller.attach(to: uiView)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ struct BrowseScreen: View {
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
.task {
|
.task {
|
||||||
if catalogId.isEmpty { catalogId = defaultCatalog }
|
if catalogId.isEmpty { catalogId = defaultCatalog }
|
||||||
|
// NavigationStack re-runs .task after detail pop; don't wipe the grid
|
||||||
|
// or scroll position (same pattern as HomeScreen).
|
||||||
|
guard items.isEmpty else { return }
|
||||||
await bootstrap()
|
await bootstrap()
|
||||||
}
|
}
|
||||||
.refreshable { await reload(reset: true) }
|
.refreshable { await reload(reset: true) }
|
||||||
|
|
@ -271,6 +274,7 @@ struct BrowseScreen: View {
|
||||||
if reset {
|
if reset {
|
||||||
loading = true
|
loading = true
|
||||||
error = nil
|
error = nil
|
||||||
|
loadingMore = false
|
||||||
}
|
}
|
||||||
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||||
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue