From 85da6f59ad3fc0fe0b3568b5042327d099faeac6 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 22 Sep 2026 21:52:23 +0200 Subject: [PATCH] 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. --- .../mediacluster/tv/playback/PlayerFactory.kt | 23 +- .../mediacluster/tv/ui/EventPlayerScreen.kt | 432 +++++- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 204 ++- .../mediacluster/tv/playback/PlayerFactory.kt | 25 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 600 +++++--- .../mediacluster/tv/ui/EventPlayerScreen.kt | 509 ++++++- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 126 +- .../vonas/mediacluster/tv/ui/F1HubScreen.kt | 1165 +++++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 322 +++- apps/ios/VMC.xcodeproj/project.pbxproj | 11 +- .../VMC/Playback/AVLivePlayerController.swift | 417 ++++++ apps/ios/VMC/Playback/ClearKeyDashProxy.swift | 1324 ++++++++++++++++- apps/ios/VMC/Playback/PlayerScreen.swift | 175 ++- .../VMC/Playback/VLCPlayerController.swift | 6 +- apps/ios/VMC/UI/BrowseScreen.swift | 4 + 16 files changed, 4915 insertions(+), 432 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/F1HubScreen.kt create mode 100644 apps/ios/VMC/Playback/AVLivePlayerController.swift diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index 4406de4..d4b6bb4 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -299,11 +299,12 @@ private class InjectingClearKeyDrmSessionManager( } private fun formatWithClearKeyScheme(format: Format): Format { + if (keyIds.isEmpty()) return format val existing = format.drmInitData - val hasClearKey = existing != null && (0 until existing.schemeDataCount).any { - existing.get(it).matches(C.CLEARKEY_UUID) - } - if (hasClearKey || keyIds.isEmpty()) return format + val onlyClearKey = existing != null && + 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 pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null) @@ -312,7 +313,10 @@ private class InjectingClearKeyDrmSessionManager( MimeTypes.VIDEO_MP4, 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() .setDrmInitData(DrmInitData(schemeData)) .build() @@ -331,10 +335,11 @@ fun liveMediaItem( format: String, drm: DrmInfo? = null, ): MediaItem { - val isDash = format.equals("dash", ignoreCase = true) || - url.contains(".mpd", ignoreCase = true) - val isHls = !isDash && ( - format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) + // URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet. + val isHls = url.contains("m3u8", ignoreCase = true) || + format.equals("hls", ignoreCase = true) + val isDash = !isHls && ( + format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true) ) val mime = when { isDash -> MimeTypes.APPLICATION_MPD diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt index c36930d..3696f1e 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt @@ -3,14 +3,32 @@ package nl.vonas.mediacluster.tv.ui import android.view.ViewGroup import androidx.activity.compose.BackHandler 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.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -20,35 +38,51 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext 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.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Text import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.isActive 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.disableTrackType 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.selectTrack import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.theme.McColors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference 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) -@kotlin.OptIn(ExperimentalTvMaterial3Api::class) @Composable fun EventPlayerScreen( api: ApiClient, @@ -66,9 +100,30 @@ fun EventPlayerScreen( var error by remember { mutableStateOf(null) } var showChrome by remember { mutableStateOf(true) } var watchSessionId by remember { mutableStateOf(null) } + var audioTracks by remember { mutableStateOf>(emptyList()) } + var textTracks by remember { mutableStateOf>(emptyList()) } + var selectedAudioKey by remember { mutableStateOf(null) } + var selectedTextKey by remember { mutableStateOf(null) } + var textDisabled by remember { mutableStateOf(true) } + var menu by remember { mutableStateOf(null) } // audio | text + var chromeBump by remember { mutableStateOf(0) } 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() { runCatching { player.playWhenReady = false @@ -83,6 +138,9 @@ fun EventPlayerScreen( watchSessionId = null reachedReady.set(false) showChrome = true + menu = null + audioTracks = emptyList() + textTracks = emptyList() try { val play = api.scheduleEventPlay(eventId) if (!isActive) return@LaunchedEffect @@ -146,9 +204,9 @@ fun EventPlayerScreen( } } - LaunchedEffect(showChrome, error, loading) { - if (showChrome && error == null && !loading) { - delay(4_000) + LaunchedEffect(showChrome, menu, error, loading, chromeBump) { + if (showChrome && menu == null && error == null && !loading) { + delay(ChromeHideMs) showChrome = false } } @@ -159,12 +217,22 @@ fun EventPlayerScreen( when (state) { Player.STATE_READY -> { reachedReady.set(true) - runCatching { player.ensureAudioSelected() } + runCatching { + player.ensureAudioSelected() + refreshTracks() + } } else -> Unit } } + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + runCatching { + player.ensureAudioSelected() + refreshTracks() + } + } + override fun onPlayerError(e: PlaybackException) { val cause = generateSequence(e as Throwable?) { it.cause } .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( Modifier @@ -200,7 +274,13 @@ fun EventPlayerScreen( interactionSource = remember { MutableInteractionSource() }, 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) { @@ -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( - title, - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(20.dp), - ) - McButton( - "Terug", - compact = true, - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(16.dp), - onClick = onBack, + subtitle, + color = McColors.TextDim, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } } + +@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, + ) + } +} diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index cd42dce..d72ae6c 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 135 - versionName = "0.14.72" + versionCode = 143 + versionName = "0.14.80" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index f92bb37..72a5f00 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -636,37 +636,152 @@ class ApiClient( suspend fun scheduleEventPlay(eventId: String): IptvPlayInfo = withContext(Dispatchers.IO) { val enc = android.net.Uri.encode(eventId) 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 -> - val drmObj = o.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)) + fun eventsOf(key: String): List { + val arr = o.optJSONArray(key) ?: JSONArray() + return buildList { + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.toScheduleEvent()?.let { add(it) } } } - DrmInfo( - type = drmObj.optString("type").ifBlank { "clearkey" }, - keys = keys, - ).takeIf { it.keys.isNotEmpty() } - } else null - IptvPlayInfo( - streamId = o.optString("eventId").ifBlank { eventId }, - name = o.getString("name"), - logoUrl = o.optString("logoUrl").ifBlank { null }, - streamUrl = o.getString("streamUrl"), - format = o.optString("format").ifBlank { "dash" }, - fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null }, - fallbackFormat = o.optString("fallbackFormat").ifBlank { null }, - drm = drm, - watchSessionId = o.optString("watchSessionId").ifBlank { null }, + } + fun meetingsOf(key: String): List { + val arr = o.optJSONArray(key) ?: JSONArray() + return buildList { + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.toF1tvMeeting()?.let { add(it) } + } + } + } + fun replayGroupsOf(key: String): List { + val arr = o.optJSONArray(key) ?: JSONArray() + return buildList { + for (i in 0 until arr.length()) { + val g = arr.optJSONObject(i) ?: continue + 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 { + 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. * Best-effort: never throws to callers that ignore failures. @@ -1137,6 +1252,47 @@ data class ScheduleEvent( val audioLanguages: List = 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 = emptyList(), +) + +data class F1tvHomePayload( + val generatedAt: String? = null, + val live: List = emptyList(), + val upcoming: List = emptyList(), + val recentReplays: List = emptyList(), + val replayGroups: List = emptyList(), + val meetings: List = emptyList(), + val nextMeeting: F1tvMeeting? = null, +) + +data class F1tvMeetingDetail( + val meeting: F1tvMeeting? = null, + val sessions: List = emptyList(), + val replays: List = emptyList(), + val upcoming: List = emptyList(), + val live: List = emptyList(), +) + /** Canonical shelf/chip order (Overig last). Keep in sync with master-api. */ val SportBucketOrder = listOf( "football", "racing", "tennis", "golf", "darts", "snooker", diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index ae2a1d0..7898d44 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -309,11 +309,14 @@ private class InjectingClearKeyDrmSessionManager( } private fun formatWithClearKeyScheme(format: Format): Format { + if (keyIds.isEmpty()) return format val existing = format.drmInitData - val hasClearKey = existing != null && (0 until existing.schemeDataCount).any { - existing.get(it).matches(C.CLEARKEY_UUID) - } - if (hasClearKey || keyIds.isEmpty()) return format + // Alleen ClearKey → laten staan. Anders forceren (ESPN HLS heeft Widevine/PlayReady + // SESSION-KEY; wij hebben ClearKey-kids uit de feed). + val onlyClearKey = existing != null && + 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 pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null) @@ -322,7 +325,10 @@ private class InjectingClearKeyDrmSessionManager( MimeTypes.VIDEO_MP4, 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() .setDrmInitData(DrmInitData(schemeData)) .build() @@ -341,10 +347,11 @@ fun liveMediaItem( format: String, drm: DrmInfo? = null, ): MediaItem { - val isDash = format.equals("dash", ignoreCase = true) || - url.contains(".mpd", ignoreCase = true) - val isHls = !isDash && ( - format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true) + // URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet. + val isHls = url.contains("m3u8", ignoreCase = true) || + format.equals("hls", ignoreCase = true) + val isDash = !isHls && ( + format.equals("dash", ignoreCase = true) || url.contains(".mpd", ignoreCase = true) ) val mime = when { isDash -> MimeTypes.APPLICATION_MPD diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index 5cca077..14d8830 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -1,12 +1,20 @@ package nl.vonas.mediacluster.tv.ui -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri +import android.annotation.SuppressLint +import android.os.Handler +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.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.BorderStroke 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.Box 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.items 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.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,24 +49,23 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.window.Dialog -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.draw.shadow 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.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.platform.LocalContext 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.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.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -67,10 +76,15 @@ import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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.SelectedQualityLine 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.rememberClockTickMs +import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes import nl.vonas.mediacluster.tv.ui.theme.McColors import org.json.JSONArray import org.json.JSONObject @@ -101,36 +115,197 @@ private fun formatRuntime(minutes: Int?): String? { } } -private fun openYoutubeTrailer(context: android.content.Context, key: String) { - val appPackages = listOf( - "com.google.android.youtube.tv", - "com.google.android.youtube.googletv", - "com.google.android.youtube", - ) - val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun TrailerPlayerOverlay( + youtubeKey: String, + onDismiss: () -> Unit, +) { + var loading by remember { mutableStateOf(true) } + var paused by remember { mutableStateOf(false) } + var webView by remember { mutableStateOf(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 { - appIntent.setPackage(pkg) - context.startActivity(appIntent) - return - } catch (_: ActivityNotFoundException) { - // try next - } catch (_: Exception) { - // try next + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + 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 = """ + + + + + + + + + +
+ + + + """.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 @@ -142,25 +317,28 @@ private fun DetailBackdrop(url: String?) { contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, ) } + // Events-achtige cinematic scrims (VMC zwart) Box( Modifier.fillMaxSize().background( Brush.horizontalGradient( - 0f to Color(0xF20B0B0B), - 0.42f to Color(0xCC0B0B0B), - 0.72f to Color(0x660B0B0B), - 1f to Color(0x220B0B0B), + 0.0f to Color(0xF20B0B0B), + 0.40f to Color(0xCC0B0B0B), + 0.72f to Color(0x550B0B0B), + 1.0f to Color(0x180B0B0B), ), ), ) Box( Modifier.fillMaxSize().background( Brush.verticalGradient( - 0f to Color(0x660B0B0B), - 0.35f to Color.Transparent, - 0.7f to Color(0xAA0B0B0B), - 1f to Color(0xFF0B0B0B), + 0.0f to Color(0x66000000), + 0.12f to Color.Transparent, + 0.45f to Color.Transparent, + 0.70f to Color(0xAA0B0B0B), + 1.0f to Color(0xFF0B0B0B), ), ), ) @@ -168,15 +346,25 @@ private fun DetailBackdrop(url: String?) { } @Composable -private fun DetailLogoOrTitle(logoUrl: String?, title: String) { +private fun DetailLogoOrTitle( + logoUrl: String?, + title: String, + compact: Boolean = false, +) { if (!logoUrl.isNullOrBlank()) { AsyncImage( model = logoUrl, contentDescription = title, - modifier = Modifier - .widthIn(max = 420.dp) - .heightIn(max = 120.dp) - .fillMaxWidth(0.85f), + modifier = if (compact) { + Modifier + .heightIn(max = 56.dp) + .widthIn(max = 280.dp) + } else { + Modifier + .widthIn(max = 420.dp) + .heightIn(max = 100.dp) + .fillMaxWidth(0.85f) + }, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, ) @@ -184,15 +372,43 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) { Text( title, color = Color.White, - fontSize = 40.sp, + fontSize = if (compact) 32.sp else 40.sp, fontWeight = FontWeight.Bold, - maxLines = 3, + maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis, - lineHeight = 46.sp, + lineHeight = if (compact) 36.sp else 46.sp, ) } } +@Composable +private fun GenrePills(genres: List) { + 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 private fun MetaChips(parts: List) { if (parts.isEmpty()) return @@ -207,8 +423,8 @@ private fun MetaChips(parts: List) { } Text( part, - color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim, - fontSize = 14.sp, + color = if (part.startsWith("★")) Color(0xFFFFD54F) else Color(0xFFCFCFCF), + fontSize = 15.sp, fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, ) } @@ -231,7 +447,7 @@ private fun DetailBackButton(onClick: () -> Unit) { ), border = ClickableSurfaceDefaults.border( focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), + border = BorderStroke(1.dp, Color.White), shape = RoundedCornerShape(4.dp), ), ), @@ -254,38 +470,43 @@ private fun DetailActionButton( focusRequester: FocusRequester? = null, onClick: () -> Unit, ) { - val bg = Color(0xE6181818) - val bgFocus = Color.White - val fg = Color.White + val shape = RoundedCornerShape(6.dp) + val idleBg = if (primary) McColors.Accent else Color(0xE6181818) + val focusBg = if (primary) Color.White else Color.White + val idleFg = Color.White + val focusFg = if (primary) McColors.Accent else Color.Black Surface( onClick = onClick, modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + shape = ClickableSurfaceDefaults.shape(shape = shape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)), colors = ClickableSurfaceDefaults.colors( - containerColor = bg, - focusedContainerColor = bgFocus, - contentColor = fg, - focusedContentColor = Color.Black, + containerColor = idleBg, + focusedContainerColor = focusBg, + contentColor = idleFg, + focusedContentColor = focusFg, ), border = ClickableSurfaceDefaults.border( border = Border( - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)), - shape = RoundedCornerShape(4.dp), + border = BorderStroke( + if (primary) 0.dp else 1.dp, + if (primary) Color.Transparent else Color(0x55FFFFFF), + ), + shape = shape, ), focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), - shape = RoundedCornerShape(4.dp), + border = BorderStroke(2.dp, if (primary) McColors.Accent else Color.White), + shape = shape, ), ), ) { Text( text = label, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), color = Color.Unspecified, fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, maxLines = 1, ) } @@ -345,7 +566,6 @@ fun MovieDetailScreen( onBack: () -> Unit, onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit, ) { - val context = LocalContext.current var title by remember { mutableStateOf("…") } var overview by remember { mutableStateOf("") } var backdrop by remember { mutableStateOf(null) } @@ -358,13 +578,13 @@ fun MovieDetailScreen( var cast by remember { mutableStateOf>(emptyList()) } var director by remember { mutableStateOf>(emptyList()) } var trailerKey by remember { mutableStateOf(null) } + var showTrailer by remember { mutableStateOf(false) } var versions by remember { mutableStateOf>(emptyList()) } var selectedVersionId by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var onWatchlist by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val playFocus = remember { FocusRequester() } - val scroll = rememberScrollState() LaunchedEffect(movieId) { try { @@ -426,115 +646,130 @@ fun MovieDetailScreen( rating?.let { add("★ $it") } } 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) - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { DetailBackdrop(backdrop) - Column( + + // Fullscreen detail (geen topnav) → content start bovenaan, volle hoogte benutten + Row( Modifier - .fillMaxHeight() - .fillMaxWidth(0.58f) - .padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp) - .verticalScroll(scroll), - verticalArrangement = Arrangement.spacedBy(8.dp), + .fillMaxSize() + .padding( + start = HomeChrome.EdgeInset, + end = HomeChrome.EdgeInset, + top = 28.dp, + bottom = 32.dp, + ), + horizontalArrangement = Arrangement.spacedBy(36.dp), ) { - DetailBackButton(onClick = onBack) - Spacer(Modifier.height(4.dp)) - DetailLogoOrTitle(logoUrl, title) - MetaChips(metaParts) - if (endsAtLabel != null) { - Text( - endsAtLabel, - color = Color(0xFF9A9AA8), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - } - if (versions.size > 1) { - SelectedQualityLine(selected) - } - Spacer(Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - selected?.let { ver -> - DetailActionButton(playLabel, primary = true, focusRequester = playFocus) { - onPlay(ver.id, title, 0) - } + Column( + Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + DetailBackButton(onClick = onBack) + Spacer(Modifier.height(16.dp)) + DetailLogoOrTitle(logoUrl, title, compact = true) + Spacer(Modifier.height(10.dp)) + MetaChips(metaParts) + if (endsAtLabel != null) { + Spacer(Modifier.height(4.dp)) + Text( + endsAtLabel, + color = Color(0xFFB8B8B8), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) } - if (!trailerKey.isNullOrBlank()) { - DetailActionButton("Trailer") { - openYoutubeTrailer(context, trailerKey!!) - } + if (overview.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + Text( + overview, + color = Color(0xFFD6D6D6), + fontSize = 15.sp, + lineHeight = 22.sp, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) } - DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { - scope.launch { - runCatching { onWatchlist = api.toggleFavorite("movie", movieId) } - } + if (creditLine.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + creditLine, + color = Color(0xFF8A8A8A), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - DetailActionButton("Markeer als gezien") { - scope.launch { - runCatching { api.markWatched(movieId = movieId) } - onBack() - } + if (error != null) { + Spacer(Modifier.height(6.dp)) + Text(error!!, color = McColors.Danger, fontSize = 13.sp, maxLines = 1) } - } - if (overview.isNotBlank()) { - ExpandableOverview(overview) - } - if (director.isNotEmpty()) { - 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), + + Spacer(Modifier.height(18.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - items(versions, key = { it.id }) { ver -> - VersionPickChip( - version = ver, - selected = ver.id == selected?.id, - onClick = { selectedVersionId = ver.id }, - ) + selected?.let { ver -> + DetailActionButton(playLabel, primary = true, focusRequester = playFocus) { + onPlay(ver.id, title, 0) + } + } + 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()) { - AsyncImage( - model = poster, - contentDescription = null, - 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, + if (showTrailer && !trailerKey.isNullOrBlank()) { + TrailerPlayerOverlay( + youtubeKey = trailerKey!!, + onDismiss = { showTrailer = false }, ) } } @@ -585,7 +820,6 @@ fun SeriesDetailScreen( onBack: () -> Unit, onPlay: (mediaFileId: String, episodeId: String, title: String, startMs: Long) -> Unit, ) { - val context = LocalContext.current var title by remember { mutableStateOf("…") } var overview by remember { mutableStateOf("") } var backdrop by remember { mutableStateOf(null) } @@ -594,6 +828,7 @@ fun SeriesDetailScreen( var rating by remember { mutableStateOf(null) } var genres by remember { mutableStateOf>(emptyList()) } var trailerKey by remember { mutableStateOf(null) } + var showTrailer by remember { mutableStateOf(false) } var seasons by remember { mutableStateOf>(emptyList()) } var selectedSeason by rememberSaveable(seriesId) { mutableStateOf(null) } var error by remember { mutableStateOf(null) } @@ -698,7 +933,6 @@ fun SeriesDetailScreen( val metaParts = buildList { year?.let { add(it.toString()) } - genres.take(3).forEach(::add) rating?.let { add("★ $it") } } val playEpPreview = remember(episodes) { @@ -708,21 +942,22 @@ fun SeriesDetailScreen( } BackHandler(onBack = onBack) - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { DetailBackdrop(backdrop) LazyColumn( contentPadding = PaddingValues( start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, - top = 28.dp, + top = HomeChrome.BadgeTop, bottom = 40.dp, ), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { DetailBackButton(onClick = onBack) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(8.dp)) DetailLogoOrTitle(logoUrl, title) + GenrePills(genres) MetaChips(metaParts) if (overview.isNotBlank()) { ExpandableOverview( @@ -732,7 +967,7 @@ fun SeriesDetailScreen( } if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { playEpPreview?.let { ep -> val resume = !ep.completed && ep.positionMs > 30_000L DetailActionButton( @@ -749,7 +984,7 @@ fun SeriesDetailScreen( } } if (!trailerKey.isNullOrBlank()) { - DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } + DetailActionButton("Trailer") { showTrailer = true } } DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") { scope.launch { @@ -759,7 +994,7 @@ fun SeriesDetailScreen( } if (seasons.size > 1) { 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)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(seasons) { s -> @@ -791,6 +1026,13 @@ fun SeriesDetailScreen( } } + if (showTrailer && !trailerKey.isNullOrBlank()) { + TrailerPlayerOverlay( + youtubeKey = trailerKey!!, + onDismiss = { showTrailer = false }, + ) + } + val menuEp = episodeMenu if (menuEp != null) { BackHandler { episodeMenu = null } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt index 37af5a3..6aed4b2 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt @@ -3,12 +3,29 @@ package nl.vonas.mediacluster.tv.ui import android.view.ViewGroup import androidx.activity.compose.BackHandler 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.border import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.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.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -20,6 +37,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.input.key.Key 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.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Surface import androidx.tv.material3.Text import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.isActive 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.disableTrackType 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.selectTrack import nl.vonas.mediacluster.tv.ui.theme.McColors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference private const val ReadyTimeoutMs = 20_000L +private const val ChromeHideMs = 5_000L @OptIn(UnstableApi::class) @kotlin.OptIn(ExperimentalTvMaterial3Api::class) @@ -58,6 +86,8 @@ fun EventPlayerScreen( api: ApiClient, eventId: String, onBack: () -> Unit, + /** Hub-play (live + replay via /client/f1/content/…/play). */ + hubPlay: Boolean = false, ) { val context = LocalContext.current val playerRef = remember { AtomicReference(null) } @@ -65,12 +95,38 @@ fun EventPlayerScreen( buildLiveTvPlayer(context).also { playerRef.set(it) } } val rootFocus = remember { FocusRequester() } + val audioBtnFocus = remember { FocusRequester() } + val panelFocus = remember { FocusRequester() } val reachedReady = remember { AtomicBoolean(false) } var title by remember { mutableStateOf("Live event") } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } var showChrome by remember { mutableStateOf(true) } var watchSessionId by remember { mutableStateOf(null) } + var audioTracks by remember { mutableStateOf>(emptyList()) } + var textTracks by remember { mutableStateOf>(emptyList()) } + var selectedAudioKey by remember { mutableStateOf(null) } + var selectedTextKey by remember { mutableStateOf(null) } + var textDisabled by remember { mutableStateOf(true) } + var menu by remember { mutableStateOf(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() { runCatching { @@ -80,14 +136,24 @@ fun EventPlayerScreen( } } - LaunchedEffect(eventId) { + LaunchedEffect(eventId, hubPlay) { loading = true error = null watchSessionId = null reachedReady.set(false) showChrome = true + menu = null + audioTracks = emptyList() + textTracks = emptyList() 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 title = play.name watchSessionId = play.watchSessionId @@ -96,14 +162,11 @@ fun EventPlayerScreen( error = "Geen stream-URL beschikbaar" return@LaunchedEffect } - if (play.drm == null || play.drm.keys.isEmpty()) { - error = "Encryptiesleutels ontbreken — stream start binnenkort" - return@LaunchedEffect - } + // ClearKey optioneel: F1TV plain HLS heeft drm=null. val item = try { liveMediaItem(url, play.format, play.drm) } catch (e: Exception) { - error = e.message ?: "Ongeldige DRM-sleutels" + error = e.message ?: "Kon mediastream niet openen" return@LaunchedEffect } if (!isActive) return@LaunchedEffect @@ -151,13 +214,27 @@ fun EventPlayerScreen( } } - LaunchedEffect(showChrome, error, loading) { - if (showChrome && error == null && !loading) { - delay(4_000) + LaunchedEffect(showChrome, menu, error, loading, chromeBump) { + if (showChrome && menu == null && error == null && !loading) { + delay(ChromeHideMs) 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) { delay(80) runCatching { rootFocus.requestFocus() } @@ -169,12 +246,22 @@ fun EventPlayerScreen( when (state) { Player.STATE_READY -> { reachedReady.set(true) - runCatching { player.ensureAudioSelected() } + runCatching { + player.ensureAudioSelected() + refreshTracks() + } } else -> Unit } } + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + runCatching { + player.ensureAudioSelected() + refreshTracks() + } + } + override fun onPlayerError(e: PlaybackException) { val cause = generateSequence(e as Throwable?) { it.cause } .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( Modifier @@ -209,13 +302,37 @@ fun EventPlayerScreen( .focusRequester(rootFocus) .focusable() .onPreviewKeyEvent { ev -> - if (ev.type == KeyEventType.KeyDown && - (ev.key == Key.Back || ev.key == Key.Escape) - ) { - onBack() - true - } else { - false + if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when { + ev.key == Key.Back || ev.key == Key.Escape -> { + when { + menu != null -> { + menu = null + 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( - title, - color = Color.White, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .align(Alignment.TopStart) - .padding(28.dp), + label, + color = if (focused) EventsLook.Neon else Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + if (subtitle != null) { + 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, ) } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index 5039a51..eebbb29 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -119,6 +119,7 @@ fun EventsScreen( pendingFocusRestore: Boolean = false, onFocusRestoreConsumed: () -> Unit = {}, onOpenEvent: (String) -> Unit, + onOpenF1Hub: (() -> Unit)? = null, onContentFocused: () -> Unit = {}, ) { var events by remember { mutableStateOf>(emptyList()) } @@ -224,10 +225,13 @@ fun EventsScreen( 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 filterBarFocus = remember { FocusRequester() } val firstRailUpTarget = when { - showFilterBar && filterBarFocusReady -> contentFocusRequester + showFilterBar && filterBarFocusReady -> filterBarFocus + hasF1Entry -> contentFocusRequester else -> navFocusRequester } val filterFocusDown: FocusRequester? = when { @@ -235,6 +239,7 @@ fun EventsScreen( railFocusReady -> firstRailFocus else -> null } + val railUsesDedicatedFocus = hasF1Entry || showFilterBar fun claimRailFocus(owner: String, ready: Boolean) { if (ready) { @@ -265,6 +270,7 @@ fun EventsScreen( } val canRestoreCard = restoreInLive || restoreUpcomingIndex >= 0 val restoreListIndex = remember( + hasF1Entry, showFilterBar, live, upcomingRails, @@ -273,6 +279,7 @@ fun EventsScreen( ) { if (!canRestoreCard) return@remember -1 var idx = 0 + if (hasF1Entry) idx++ if (showFilterBar) idx++ if (restoreInLive) return@remember idx if (live.isNotEmpty()) idx++ @@ -295,7 +302,7 @@ fun EventsScreen( val activeRestoreFocus = when { !canRestoreCard -> null restoreIsFirstOfFirstRail -> when { - showFilterBar -> firstRailFocus + railUsesDedicatedFocus -> firstRailFocus else -> contentFocusRequester } else -> restoreCardFocus @@ -313,12 +320,14 @@ fun EventsScreen( } val entryReady = when { + hasF1Entry -> true showFilterBar -> filterBarFocusReady else -> railFocusReady } val needContentSink = when { loading && events.isEmpty() -> true error != null && events.isEmpty() -> true + hasF1Entry -> false showFilterBar -> !filterBarFocusReady !hasRails -> true else -> !railFocusReady @@ -343,6 +352,7 @@ fun EventsScreen( if (restoredSuccessfully) return val entryOk = awaitReady({ when { + hasF1Entry -> true showFilterBar -> filterBarFocusReady else -> railFocusReady } @@ -465,6 +475,16 @@ fun EventsScreen( modifier = Modifier.fillMaxSize(), 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. if (showFilterBar) { item(key = "filters") { @@ -478,8 +498,8 @@ fun EventsScreen( onProviderSelect = { providerFilter = it }, liveCount = live.size, totalCount = filtered.size, - firstFocusRequester = contentFocusRequester, - focusUp = navFocusRequester, + firstFocusRequester = if (hasF1Entry) filterBarFocus else contentFocusRequester, + focusUp = if (hasF1Entry) contentFocusRequester else navFocusRequester, focusDown = filterFocusDown, onFocusReady = { filterBarFocusReady = it }, onFocus = onContentFocused, @@ -511,7 +531,7 @@ fun EventsScreen( style = EventRailStyle.Live, onOpen = onOpenEvent, onFocus = onContentFocused, - firstItemFocusRequester = if (showFilterBar) { + firstItemFocusRequester = if (railUsesDedicatedFocus) { firstRailFocus } else { contentFocusRequester @@ -545,7 +565,7 @@ fun EventsScreen( onFocus = onContentFocused, firstItemFocusRequester = when { !isFirstUpcoming -> null - showFilterBar -> firstRailFocus + railUsesDedicatedFocus -> firstRailFocus else -> contentFocusRequester }, restoreEventId = bindRestoreCardId.takeIf { railHasRestore }, @@ -579,7 +599,7 @@ fun EventsScreen( onFocus = onContentFocused, firstItemFocusRequester = when { !isFirstUpcoming -> null - showFilterBar -> firstRailFocus + railUsesDedicatedFocus -> firstRailFocus else -> contentFocusRequester }, 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) @Composable private fun EventCard( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/F1HubScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/F1HubScreen.kt new file mode 100644 index 0000000..b3c7e24 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/F1HubScreen.kt @@ -0,0 +1,1165 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +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.layout.ContentScale +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.sp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Glow +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import kotlinx.coroutines.delay +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.F1tvHomePayload +import nl.vonas.mediacluster.tv.data.F1tvMeeting +import nl.vonas.mediacluster.tv.data.F1tvReplayGroup +import nl.vonas.mediacluster.tv.data.ScheduleEvent +import nl.vonas.mediacluster.tv.ui.components.HomeChrome +import nl.vonas.mediacluster.tv.ui.theme.McColors +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** Carbon + F1-rood — cinematic TV hub. */ +private object F1Look { + val Red = Color(0xFFE10600) + val RedDeep = Color(0xFF9B0000) + val RedSoft = Color(0x55E10600) + val Carbon = Color(0xFF070707) + val CarbonLift = Color(0xFF111111) + val Panel = Color(0xFF151515) + val PanelHot = Color(0xFF1F1F1F) + val Live = Color(0xFFFF1744) + val Steel = Color(0xFFB0B0B0) + val White = Color(0xFFF5F5F5) +} + +private val ZoneNl: ZoneId = ZoneId.of("Europe/Amsterdam") +private val DayTimeFmt: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale("nl", "NL")) +private val DayFmt: DateTimeFormatter = + DateTimeFormatter.ofPattern("d MMM", Locale("nl", "NL")) + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun F1HubScreen( + api: ApiClient, + onBack: () -> Unit, + onOpenMeeting: (String) -> Unit, + onPlay: (contentId: String) -> Unit, +) { + var home by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + val firstFocus = remember { FocusRequester() } + + BackHandler(onBack = onBack) + + suspend fun refresh() { + runCatching { api.getF1tvHome() } + .onSuccess { + home = it + error = null + } + .onFailure { error = it.message ?: "Kon F1-hub niet laden" } + loading = false + } + + LaunchedEffect(Unit) { refresh() } + LaunchedEffect(Unit) { + while (true) { + delay(75_000) + refresh() + } + } + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + nowMs = System.currentTimeMillis() + } + } + LaunchedEffect(home, loading, error) { + if (!loading && error == null && home != null) { + delay(80) + runCatching { firstFocus.requestFocus() } + } + } + + Box( + Modifier + .fillMaxSize() + .background(F1Look.Carbon), + ) { + // Atmosphere: red speed wash top-right + Box( + Modifier + .align(Alignment.TopEnd) + .fillMaxWidth(0.55f) + .fillMaxHeight(0.55f) + .background( + Brush.radialGradient( + colors = listOf(Color(0x44E10600), Color.Transparent), + ), + ), + ) + when { + loading && home == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("F1 HUB", color = F1Look.Red, fontSize = 28.sp, fontWeight = FontWeight.Black) + } + } + error != null && home == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(error!!, color = McColors.Danger, fontSize = 16.sp) + Spacer(Modifier.height(16.dp)) + F1Chip("Terug", focusRequester = firstFocus, onClick = onBack) + } + } + } + else -> { + val payload = home!! + val featured = payload.live.firstOrNull() + ?: payload.upcoming.firstOrNull() + ?: payload.recentReplays.firstOrNull() + val weekend = (payload.live + payload.upcoming).distinctBy { it.id } + val pastMeetings = payload.meetings.filter { + (it.endMs ?: 0L) > 0L && (it.endMs ?: 0L) < nowMs + }.sortedByDescending { it.startMs ?: 0L } + val futureMeetings = payload.meetings.filter { + (it.endMs ?: Long.MAX_VALUE) >= nowMs + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 64.dp), + ) { + if (featured != null) { + item(key = "hero") { + F1Hero( + event = featured, + nowMs = nowMs, + liveCount = payload.live.size, + focusRequester = firstFocus, + onBack = onBack, + onOpen = { + when { + f1CanPlayNow(featured) -> + f1ContentId(featured.id)?.let(onPlay) + else -> { + val mk = payload.nextMeeting?.meetingKey + if (!mk.isNullOrBlank()) onOpenMeeting(mk) + } + } + }, + ) + } + } else { + item(key = "topbar") { + F1TopBar( + liveCount = payload.live.size, + onBack = onBack, + backFocus = firstFocus, + ) + } + } + if (weekend.isNotEmpty()) { + item(key = "weekend") { + F1SectionHeader("DIT WEEKEND", "Sessies · live & aankomend") + F1SessionRail( + events = weekend, + nowMs = nowMs, + large = true, + onPlay = { ev -> if (f1CanPlayNow(ev)) f1ContentId(ev.id)?.let(onPlay) }, + onOpenMeeting = null, + ) + } + } + if (payload.recentReplays.isNotEmpty()) { + item(key = "replays_latest") { + F1SectionHeader("TERUGKIJKEN", "Nieuwste eerst · herbekijk races & sessies") + F1SessionRail( + events = payload.recentReplays, + nowMs = nowMs, + large = true, + emphasizeReplay = true, + onPlay = { ev -> f1ContentId(ev.id)?.let(onPlay) }, + onOpenMeeting = null, + ) + } + } + if (payload.replayGroups.isNotEmpty()) { + item(key = "replay_weekends") { + F1SectionHeader("RACEWEEKENDEN", "Per grand prix · archief") + F1ReplayWeekendRail( + groups = payload.replayGroups, + onOpenMeeting = onOpenMeeting, + ) + } + } + if (futureMeetings.isNotEmpty() || pastMeetings.isNotEmpty()) { + item(key = "season") { + F1SectionHeader("SEIZOEN", "Kalender · tik een raceweekend") + F1SeasonRail( + upcoming = futureMeetings, + past = pastMeetings, + nowMs = nowMs, + onOpen = onOpenMeeting, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun F1MeetingScreen( + api: ApiClient, + meetingKey: String, + onBack: () -> Unit, + onPlay: (contentId: String) -> Unit, +) { + var meeting by remember { mutableStateOf(null) } + var live by remember { mutableStateOf>(emptyList()) } + var upcoming by remember { mutableStateOf>(emptyList()) } + var replays by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + val firstFocus = remember { FocusRequester() } + + BackHandler(onBack = onBack) + + LaunchedEffect(meetingKey) { + loading = true + runCatching { api.getF1tvMeeting(meetingKey) } + .onSuccess { + meeting = it.meeting + live = it.live + upcoming = it.upcoming + replays = it.replays.ifEmpty { + it.sessions.filter { s -> s.phase == "ended" || s.eventType == "replay" } + } + error = null + } + .onFailure { error = it.message ?: "Meeting niet gevonden" } + loading = false + } + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + nowMs = System.currentTimeMillis() + } + } + LaunchedEffect(loading, error) { + if (!loading) { + delay(80) + runCatching { firstFocus.requestFocus() } + } + } + + Box(Modifier.fillMaxSize().background(F1Look.Carbon)) { + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Laden…", color = McColors.TextDim, fontSize = 18.sp) + } + error != null && meeting == null && live.isEmpty() && upcoming.isEmpty() && replays.isEmpty() -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(error!!, color = McColors.Danger) + Spacer(Modifier.height(12.dp)) + F1Chip("Terug", focusRequester = firstFocus, onClick = onBack) + } + } + } + else -> { + val art = meeting?.imageUrl + ?: replays.firstOrNull()?.imageLandscape + ?: live.firstOrNull()?.imageLandscape + LazyColumn(contentPadding = PaddingValues(bottom = 56.dp)) { + item(key = "mh") { + Box( + Modifier + .fillMaxWidth() + .height(300.dp), + ) { + if (art != null) { + AsyncImage( + model = art, + contentDescription = meeting?.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf(Color(0x99000000), Color(0x22000000), F1Look.Carbon), + ), + ), + ) + Row( + Modifier + .align(Alignment.TopStart) + .padding(horizontal = HomeChrome.EdgeInset) + .padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + F1Chip("← Terug", focusRequester = firstFocus, onClick = onBack) + F1BrandMark() + } + Column( + Modifier + .align(Alignment.BottomStart) + .padding(horizontal = HomeChrome.EdgeInset) + .padding(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + meeting?.meetingNumber?.let { + Text( + "RONDE $it", + color = F1Look.Red, + fontSize = 13.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + ) + } + Text( + meeting?.title ?: "Meeting", + color = F1Look.White, + fontSize = 30.sp, + fontWeight = FontWeight.Black, + maxLines = 2, + ) + val loc = listOfNotNull(meeting?.location, f1MeetingDateRange(meeting)).joinToString(" · ") + if (loc.isNotBlank()) { + Text(loc, color = F1Look.Steel, fontSize = 14.sp) + } + } + } + } + if (live.isNotEmpty()) { + item(key = "live") { + F1SectionHeader("NU LIVE", null) + F1SessionRail( + events = live, + nowMs = nowMs, + large = true, + onPlay = { ev -> f1ContentId(ev.id)?.let(onPlay) }, + onOpenMeeting = null, + ) + } + } + if (replays.isNotEmpty()) { + item(key = "rep") { + F1SectionHeader("TERUGKIJKEN", "Nieuwste sessies eerst") + F1SessionRail( + events = replays, + nowMs = nowMs, + large = true, + emphasizeReplay = true, + onPlay = { ev -> f1ContentId(ev.id)?.let(onPlay) }, + onOpenMeeting = null, + ) + } + } + if (upcoming.isNotEmpty()) { + item(key = "up") { + F1SectionHeader("PROGRAMMA", "Nog niet speelbaar") + F1SessionRail( + events = upcoming, + nowMs = nowMs, + large = false, + onPlay = { }, + onOpenMeeting = null, + ) + } + } + } + } + } + } +} + +@Composable +private fun F1TopBar( + liveCount: Int, + onBack: () -> Unit, + backFocus: FocusRequester?, +) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset) + .padding(top = 18.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + F1Chip("← Events", focusRequester = backFocus, onClick = onBack) + F1BrandMark() + if (liveCount > 0) { + F1LivePill(liveCount) + } + Spacer(Modifier.weight(1f)) + Text("FORMULA 1 · SEIZOEN", color = F1Look.Steel, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun F1BrandMark() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .clip(RoundedCornerShape(3.dp)) + .background(F1Look.Red) + .padding(horizontal = 9.dp, vertical = 3.dp), + ) { + Text("F1", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Black) + } + Text( + "HUB", + color = F1Look.White, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.5.sp, + ) + } +} + +@Composable +private fun F1LivePill(count: Int) { + val pulse = rememberInfiniteTransition(label = "live") + val a by pulse.animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(700, easing = LinearEasing), RepeatMode.Reverse), + label = "a", + ) + Row( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(F1Look.Live.copy(alpha = 0.2f)) + .padding(horizontal = 12.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(8.dp) + .alpha(a) + .clip(RoundedCornerShape(99.dp)) + .background(F1Look.Live), + ) + Text( + if (count > 1) "$count LIVE" else "LIVE", + color = F1Look.Live, + fontSize = 12.sp, + fontWeight = FontWeight.Black, + letterSpacing = 1.2.sp, + ) + } +} + +@Composable +private fun F1SectionHeader(title: String, subtitle: String?) { + Column( + Modifier + .padding(horizontal = HomeChrome.EdgeInset) + .padding(top = 16.dp, bottom = 10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.width(4.dp).height(22.dp).background(F1Look.Red)) + Text( + title, + color = F1Look.White, + fontSize = 18.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + color = F1Look.Steel, + fontSize = 13.sp, + modifier = Modifier.padding(start = 14.dp, top = 4.dp), + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun F1Hero( + event: ScheduleEvent, + nowMs: Long, + liveCount: Int, + focusRequester: FocusRequester, + onBack: () -> Unit, + onOpen: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val isLive = event.phase == "live" || event.isLive + val isReplay = event.eventType == "replay" || event.phase == "ended" + val accent = if (isLive) F1Look.Live else F1Look.Red + + // Full-bleed cinematic banner — chrome zit IN de hero (geen lege TopNav-balk). + Box( + Modifier + .fillMaxWidth() + .height(360.dp) + .background(F1Look.CarbonLift), + ) { + val art = event.imageLandscape ?: event.imageBackdrop ?: event.images.firstOrNull() + if (art != null) { + AsyncImage( + model = art, + contentDescription = event.name, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) + } + + // Top readability for chrome + Box( + Modifier + .fillMaxWidth() + .height(110.dp) + .background( + Brush.verticalGradient( + listOf(Color(0xCC000000), Color.Transparent), + ), + ), + ) + // Bottom cinematic wash + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.35f to Color.Transparent, + 0.72f to Color(0xCC050505), + 1f to F1Look.Carbon, + ), + ), + ) + Box( + Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color(0xE6000000), + 0.38f to Color(0x88000000), + 0.7f to Color(0x22000000), + 1f to Color.Transparent, + ), + ), + ) + // Red speed accent + Box( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = 0.dp) + .fillMaxWidth(0.42f) + .height(3.dp) + .background( + Brush.horizontalGradient(listOf(F1Look.Red, Color.Transparent)), + ), + ) + + // Chrome over hero + Row( + Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset) + .padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + F1Chip("← Events", onClick = onBack) + F1BrandMark() + if (liveCount > 0) F1LivePill(liveCount) + Spacer(Modifier.weight(1f)) + Text( + "FORMULA 1 · SEIZOEN", + color = Color(0xFFC8C8C8), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + + // Playable hero surface (focus target) + Surface( + onClick = onOpen, + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(horizontal = HomeChrome.EdgeInset) + .padding(bottom = 22.dp) + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = accent, elevation = 18.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.5.dp, accent), + inset = 3.dp, + shape = RoundedCornerShape(8.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color(0x22000000), + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(6.dp)), + ) { + Column( + Modifier + .fillMaxWidth(0.78f) + .padding(horizontal = 6.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + F1StatusChip(event, nowMs) + event.description?.takeIf { it.isNotBlank() }?.let { + Text( + it.uppercase(Locale.getDefault()), + color = Color(0xFFCFCFCF), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + event.name, + color = F1Look.White, + fontSize = 36.sp, + fontWeight = FontWeight.Black, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 40.sp, + ) + f1TimeLabel(event)?.let { + Text(it, color = Color(0xFFBDBDBD), fontSize = 14.sp) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(accent) + .padding(horizontal = 16.dp, vertical = 9.dp), + ) { + Text( + when { + isLive -> "▶ NU KIJKEN" + isReplay -> "▶ HERBEKIJKEN" + else -> "BEKIJK WEEKEND" + }, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + } + if (focused) { + Text( + "OK om te openen", + color = Color(0xFFAAAAAA), + fontSize = 12.sp, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun F1SessionRail( + events: List, + nowMs: Long, + large: Boolean, + emphasizeReplay: Boolean = false, + onPlay: (ScheduleEvent) -> Unit, + onOpenMeeting: ((String) -> Unit)?, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(bottom = 8.dp), + ) { + itemsIndexed(events, key = { _, e -> e.id }) { index, event -> + F1SessionCard( + event = event, + nowMs = nowMs, + large = large, + emphasizeReplay = emphasizeReplay, + blockLeftExit = index == 0, + onOpen = { + if (f1CanPlayNow(event)) onPlay(event) + else onOpenMeeting?.invoke(event.description.orEmpty()) + }, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun F1SessionCard( + event: ScheduleEvent, + nowMs: Long, + large: Boolean, + emphasizeReplay: Boolean, + blockLeftExit: Boolean, + onOpen: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val isLive = event.phase == "live" || event.isLive + val canPlay = f1CanPlayNow(event) + val cardW = if (large) 320.dp else 260.dp + val artH = if (large) 168.dp else 138.dp + Surface( + onClick = onOpen, + modifier = Modifier + .width(cardW) + .then( + if (blockLeftExit) Modifier.focusProperties { left = FocusRequester.Cancel } + else Modifier, + ) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = when { + isLive -> F1Look.Live + else -> F1Look.Red + }, + elevation = 14.dp, + ), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.5.dp, if (isLive) F1Look.Live else F1Look.Red), + inset = 4.dp, + shape = RoundedCornerShape(10.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = F1Look.Panel, + focusedContainerColor = F1Look.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(6.dp)), + ) { + Column { + Box( + Modifier + .fillMaxWidth() + .height(artH) + .background(F1Look.CarbonLift), + ) { + val art = event.imageLandscape ?: event.imageBackdrop ?: event.images.firstOrNull() + if (art != null) { + AsyncImage( + model = art, + contentDescription = event.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().alpha(if (canPlay || !emphasizeReplay) 1f else 0.75f), + ) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("F1", color = F1Look.Red, fontSize = 32.sp, fontWeight = FontWeight.Black) + } + } + Box(Modifier.padding(10.dp)) { F1StatusChip(event, nowMs) } + if (canPlay && (emphasizeReplay || isLive)) { + Box( + Modifier + .align(Alignment.BottomEnd) + .padding(10.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (isLive) F1Look.Live else F1Look.Red) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + if (isLive) "KIJK" else "HERBEKIJK", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + ) + } + } + } + Column( + Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + event.description?.takeIf { it.isNotBlank() }?.let { + Text( + it.uppercase(Locale.getDefault()), + color = F1Look.Red, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = 0.6.sp, + ) + } + Text( + event.name, + color = F1Look.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.height(40.dp), + ) + Text( + f1TimeLabel(event) ?: "—", + color = F1Look.Steel, + fontSize = 12.sp, + maxLines = 1, + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun F1ReplayWeekendRail( + groups: List, + onOpenMeeting: (String) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(bottom = 8.dp), + ) { + items(groups, key = { it.meetingKey }) { group -> + var focused by remember { mutableStateOf(false) } + Surface( + onClick = { onOpenMeeting(group.meetingKey) }, + modifier = Modifier + .width(300.dp) + .height(170.dp) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = F1Look.Red, elevation = 16.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.5.dp, F1Look.Red), + inset = 4.dp, + shape = RoundedCornerShape(10.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = F1Look.Panel, + focusedContainerColor = F1Look.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(6.dp)), + ) { + Box(Modifier.fillMaxSize()) { + if (group.imageUrl != null) { + AsyncImage( + model = group.imageUrl, + contentDescription = group.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf(Color(0x33000000), Color(0xEE000000)), + ), + ), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + group.meetingNumber?.let { + Text("R$it", color = F1Look.Red, fontSize = 12.sp, fontWeight = FontWeight.Black) + } + Text( + group.title, + color = F1Look.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull( + group.location, + "${group.sessions.size} sessies", + "open →", + ).joinToString(" · "), + color = F1Look.Steel, + fontSize = 12.sp, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun F1SeasonRail( + upcoming: List, + past: List, + nowMs: Long, + onOpen: (String) -> Unit, +) { + val ordered = upcoming + past + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(bottom = 12.dp), + ) { + items(ordered, key = { it.meetingKey }) { meeting -> + val ended = (meeting.endMs ?: 0L) in 1 until nowMs + var focused by remember { mutableStateOf(false) } + Surface( + onClick = { onOpen(meeting.meetingKey) }, + modifier = Modifier + .width(220.dp) + .height(108.dp) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = F1Look.Red, elevation = 12.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, F1Look.Red), + inset = 3.dp, + shape = RoundedCornerShape(10.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (ended) Color(0xFF101010) else F1Look.Panel, + focusedContainerColor = F1Look.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(6.dp)), + ) { + Row( + Modifier + .fillMaxSize() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .width(44.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (ended) Color(0xFF2A2A2A) else F1Look.Red) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + meeting.meetingNumber?.let { "%02d".format(it) } ?: "—", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Black, + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + if (ended) "HERBEKIJK" else "RACEWEEKEND", + color = if (ended) F1Look.Steel else F1Look.Red, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + meeting.location ?: shortMeetingTitle(meeting.title), + color = F1Look.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + f1MeetingDateRange(meeting) ?: "", + color = F1Look.Steel, + fontSize = 11.sp, + maxLines = 1, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun F1Chip( + label: String, + focusRequester: FocusRequester? = null, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + modifier = Modifier.then( + if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A1A), + focusedContainerColor = F1Look.Red, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(6.dp)), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border(border = BorderStroke(0.dp, Color.Transparent)), + ), + ) { + Text( + label, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } +} + +@Composable +private fun F1StatusChip(event: ScheduleEvent, nowMs: Long) { + val (label, bg) = when { + event.phase == "live" || event.isLive -> "LIVE" to F1Look.Live + event.eventType == "replay" || event.phase == "ended" -> "HERBEKIJK" to Color(0xFF2B2B2B) + else -> "STRAKS" to Color(0xFF222222) + } + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(bg) + .padding(horizontal = 9.dp, vertical = 4.dp), + ) { + Text(label, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Black, letterSpacing = 0.8.sp) + } +} + +private fun f1ContentId(eventId: String): String? { + val m = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim()) + return m?.groupValues?.getOrNull(1) + ?: eventId.trim().takeIf { it.all(Char::isDigit) && it.isNotEmpty() } +} + +private fun f1CanPlayNow(event: ScheduleEvent): Boolean { + if (event.phase == "live" || event.isLive || event.playable) return true + if (event.eventType.equals("replay", ignoreCase = true)) return true + if (event.phase == "ended") return true + return false +} + +private fun f1TimeLabel(event: ScheduleEvent): String? { + val start = event.start.takeIf { it.isNotBlank() } ?: return null + return runCatching { + Instant.parse(start).atZone(ZoneNl).format(DayTimeFmt) + }.getOrNull() +} + +private fun f1MeetingDateRange(meeting: F1tvMeeting?): String? { + if (meeting == null) return null + val start = meeting.startMs?.takeIf { it > 0 } ?: return null + val end = meeting.endMs?.takeIf { it > 0 } ?: start + val a = Instant.ofEpochMilli(start).atZone(ZoneNl).format(DayFmt) + val b = Instant.ofEpochMilli(end).atZone(ZoneNl).format(DayFmt) + return if (a == b) a else "$a – $b" +} + +private fun shortMeetingTitle(title: String): String = + title + .replace(Regex("""(?i)FORMULA\s*1"""), "") + .replace(Regex("""(?i)GRAND PRIX"""), "GP") + .trim() + .ifBlank { title } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 4648648..eeef685 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -131,6 +131,10 @@ fun McNav() { var eventsReturnNonce by remember { mutableIntStateOf(0) } var lastOpenedEventId by rememberSaveable { mutableStateOf(null) } var pendingEventsFocusRestore by remember { mutableStateOf(false) } + var homeReturnNonce by remember { mutableIntStateOf(0) } + var lastOpenedHomeKey by rememberSaveable { mutableStateOf(null) } + var pendingHomeFocusRestore by remember { mutableStateOf(false) } + var homeDetailFromHome by remember { mutableStateOf(false) } val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet()) val hubTab = remember(hubTabName) { MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home @@ -328,12 +332,21 @@ fun McNav() { restoreEventId = lastOpenedEventId, pendingEventsFocusRestore = pendingEventsFocusRestore, onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false }, + homeReturnNonce = homeReturnNonce, + restoreHomeKey = lastOpenedHomeKey, + pendingHomeFocusRestore = pendingHomeFocusRestore, + onHomeFocusRestoreConsumed = { pendingHomeFocusRestore = false }, + onHomeItemOpened = { key -> + lastOpenedHomeKey = key + homeDetailFromHome = true + }, profileAvatarId = profileAvatarId, profileShowAvatar = profileShowAvatar, profileAvatarUrl = profileAvatarUrl, profileName = profileName, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, + onLeaveHomeDetailOrigin = { homeDetailFromHome = false }, onBrowse = { catalogId, _title -> nav.navigate("browse/$catalogId") }, @@ -352,6 +365,11 @@ fun McNav() { lastOpenedEventId = eventId nav.navigate("event/${android.net.Uri.encode(eventId)}") }, + onOpenF1Hub = { + nav.navigate("f1_hub") { + launchSingleTop = true + } + }, onSwitchProfile = { scope.launch { runCatching { api.clearProfile() } @@ -389,8 +407,14 @@ fun McNav() { api = api, catalogId = catalogId, onBack = { nav.popBackStack() }, - onOpenMovie = { nav.navigate("movie/$it") }, - onOpenSeries = { nav.navigate("series/$it") }, + onOpenMovie = { + homeDetailFromHome = false + nav.navigate("movie/$it") + }, + onOpenSeries = { + homeDetailFromHome = false + nav.navigate("series/$it") + }, onPlayContinue = if (catalogId == "continue-watching") { { mediaFileId, movieId, episodeId, seriesId, type, startMs -> val m = movieId?.ifBlank { null } ?: "-" @@ -405,6 +429,14 @@ fun McNav() { } composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> val id = entry.arguments?.getString("id") ?: return@composable + DisposableEffect(Unit) { + onDispose { + if (homeDetailFromHome) { + pendingHomeFocusRestore = true + homeReturnNonce++ + } + } + } MovieDetailScreen( api = api, movieId = id, @@ -416,6 +448,14 @@ fun McNav() { } composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> val id = entry.arguments?.getString("id") ?: return@composable + DisposableEffect(Unit) { + onDispose { + if (homeDetailFromHome) { + pendingHomeFocusRestore = true + homeReturnNonce++ + } + } + } SeriesDetailScreen( api = api, seriesId = id, @@ -518,6 +558,72 @@ fun McNav() { 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, pendingEventsFocusRestore: Boolean = false, onEventsFocusRestoreConsumed: () -> Unit = {}, + homeReturnNonce: Int = 0, + restoreHomeKey: String? = null, + pendingHomeFocusRestore: Boolean = false, + onHomeFocusRestoreConsumed: () -> Unit = {}, + onHomeItemOpened: (String) -> Unit = {}, + onLeaveHomeDetailOrigin: () -> Unit = {}, profileAvatarId: String?, profileShowAvatar: Boolean = true, profileAvatarUrl: String? = null, @@ -553,6 +665,7 @@ private fun MainHub( onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlayLive: (String) -> Unit, onOpenEvent: (String) -> Unit, + onOpenF1Hub: () -> Unit = {}, onSwitchProfile: () -> Unit, onManageProfiles: () -> Unit, onLogout: () -> Unit, @@ -719,6 +832,15 @@ private fun MainHub( 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) LaunchedEffect(tab) { val name = tab.name @@ -862,10 +984,20 @@ private fun MainHub( listState = homeListState, contentFocusRequester = homeContentFocus, navFocusRequester = navFocus, + focusRestoreNonce = homeReturnNonce, + restoreHomeKey = restoreHomeKey, + pendingFocusRestore = pendingHomeFocusRestore, + onFocusRestoreConsumed = onHomeFocusRestoreConsumed, onContentFocused = { navFocused = false }, onChromeCompact = { homeChromeCompact = it }, - onOpenMovie = onOpenMovie, - onOpenSeries = onOpenSeries, + onOpenMovie = { id -> + onHomeItemOpened("movie:$id") + onOpenMovie(id) + }, + onOpenSeries = { id -> + onHomeItemOpened("series:$id") + onOpenSeries(id) + }, onSeeAll = onBrowse, onPlay = onPlay, ) @@ -873,8 +1005,8 @@ private fun MainHub( api = api, gridState = watchlistGridState, onAtTopBack = { focusTopNav() }, - onOpenMovie = onOpenMovie, - onOpenSeries = onOpenSeries, + onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, + onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, ) MainTab.Movies -> BrowseScreen( api = api, @@ -882,8 +1014,8 @@ private fun MainHub( titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films", gridState = moviesGridState, onAtTopBack = { focusTopNav() }, - onOpenMovie = onOpenMovie, - onOpenSeries = onOpenSeries, + onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, + onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, ) MainTab.Series -> BrowseScreen( api = api, @@ -891,8 +1023,8 @@ private fun MainHub( titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series", gridState = seriesGridState, onAtTopBack = { focusTopNav() }, - onOpenMovie = onOpenMovie, - onOpenSeries = onOpenSeries, + onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, + onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, ) MainTab.Tv -> TvScreen( api = api, @@ -931,12 +1063,13 @@ private fun MainHub( pendingFocusRestore = pendingEventsFocusRestore, onFocusRestoreConsumed = onEventsFocusRestoreConsumed, onOpenEvent = onOpenEvent, + onOpenF1Hub = onOpenF1Hub, onContentFocused = { navFocused = false }, ) MainTab.Search -> SearchScreen( api = api, - onOpenMovie = onOpenMovie, - onOpenSeries = onOpenSeries, + onOpenMovie = { onLeaveHomeDetailOrigin(); onOpenMovie(it) }, + onOpenSeries = { onLeaveHomeDetailOrigin(); onOpenSeries(it) }, onAtTopBack = { focusTopNav() }, ) MainTab.Settings -> SettingsScreen( @@ -1163,6 +1296,10 @@ private fun HomeScreen( listState: androidx.compose.foundation.lazy.LazyListState, contentFocusRequester: FocusRequester, navFocusRequester: FocusRequester, + focusRestoreNonce: Int = 0, + restoreHomeKey: String? = null, + pendingFocusRestore: Boolean = false, + onFocusRestoreConsumed: () -> Unit = {}, onContentFocused: () -> Unit, onChromeCompact: (Boolean) -> Unit = {}, onOpenMovie: (String) -> Unit, @@ -1177,12 +1314,14 @@ private fun HomeScreen( var menuReady by remember { mutableStateOf(false) } var shelfFocusReady by remember { mutableStateOf(false) } var playFocusReady by remember { mutableStateOf(false) } + var restoreCardReady by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val menuFocus = remember { FocusRequester() } val context = LocalContext.current val store = remember { SessionStore(context) } val density = LocalDensity.current val firstShelfFocus = remember { FocusRequester() } + val restoreCardFocus = remember { FocusRequester() } suspend fun reloadHome() { 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. - LaunchedEffect(Unit) { - delay(80) - val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40 - if (scrolled) { - onContentFocused() - val deadline = System.currentTimeMillis() + 700 - while (!shelfFocusReady && System.currentTimeMillis() < deadline) { + val continueItems = home?.continueWatching.orEmpty() + val visibleShelves = remember(home?.shelves) { + home?.shelves.orEmpty().filter { it.items.size >= 3 } + } + + data class HomeRestoreTarget( + val listIndex: Int, + 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) - 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 { home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty() } - val continueItems = home?.continueWatching.orEmpty() val screenH = LocalConfiguration.current.screenHeightDp.dp // Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade @@ -1387,6 +1603,9 @@ private fun HomeScreen( firstItemFocusRequester = firstShelfFocus, focusUpTo = contentFocusRequester.takeIf { playFocusReady }, onFirstFocusTargetReady = { shelfFocusReady = it }, + restoreItemKey = bindRestoreKey.takeIf { restoreTarget?.shelfId == "__continue__" }, + restoreFocusRequester = restoreCardFocus.takeIf { restoreTarget?.shelfId == "__continue__" }, + onRestoreFocusReady = { restoreCardReady = it }, onRowFocused = { onContentFocused() expandHeroFully() @@ -1417,16 +1636,16 @@ private fun HomeScreen( } } items( - home?.shelves.orEmpty().filter { it.items.size >= 3 }, + visibleShelves, key = { it.id }, ) { shelf -> val catalogId = when (shelf.slug) { "recent-movies", "recent-series", "4k-movies" -> shelf.slug else -> shelf.slug.ifBlank { shelf.id } } - val firstVisibleShelfId = home?.shelves.orEmpty() - .firstOrNull { it.items.size >= 3 }?.id + val firstVisibleShelfId = visibleShelves.firstOrNull()?.id val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId + val shelfHasRestore = restoreTarget?.shelfId == shelf.id ShelfBlock( title = shelf.title, items = shelf.items, @@ -1437,6 +1656,13 @@ private fun HomeScreen( } else { null }, + restoreItemKey = bindRestoreKey.takeIf { shelfHasRestore }, + restoreFocusRequester = restoreCardFocus.takeIf { shelfHasRestore }, + onRestoreFocusReady = if (shelfHasRestore) { + { restoreCardReady = it } + } else { + null + }, onRowFocused = { onContentFocused() if (isFirstShelf) expandHeroFully() @@ -1708,9 +1934,29 @@ private fun ShelfBlock( firstItemFocusRequester: FocusRequester? = null, focusUpTo: FocusRequester? = null, onFirstFocusTargetReady: ((Boolean) -> Unit)? = null, + restoreItemKey: String? = null, + restoreFocusRequester: FocusRequester? = null, + onRestoreFocusReady: ((Boolean) -> Unit)? = null, modifier: Modifier = Modifier, ) { 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( modifier .onFocusChanged { @@ -1757,6 +2003,7 @@ private fun ShelfBlock( } } LazyRow( + state = rowState, horizontalArrangement = Arrangement.spacedBy(12.dp), // Extra ruimte zodat focusedScale (1.10) posters niet afknipt contentPadding = PaddingValues( @@ -1767,23 +2014,40 @@ private fun ShelfBlock( ), ) { 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) { onFirstFocusTargetReady?.invoke(true) onDispose { onFirstFocusTargetReady?.invoke(false) } } } + if (isRestore && restoreFocusRequester != null && cardRequester === restoreFocusRequester) { + DisposableEffect(restoreFocusRequester) { + onRestoreFocusReady?.invoke(true) + onDispose { onRestoreFocusReady?.invoke(false) } + } + } PosterCard( item = item, onClick = { onClick(item) }, onLongClick = onLongClick?.let { handler -> { handler(item) } }, showCaption = showCaptions, modifier = Modifier.then( - if (index == 0 && firstItemFocusRequester != null) { + if (cardRequester != null) { Modifier - .focusRequester(firstItemFocusRequester) + .focusRequester(cardRequester) .then( - if (focusUpTo != null) { + if (focusUpTo != null && (index == 0 || isRestore)) { Modifier.focusProperties { up = focusUpTo } } else { Modifier diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index b1de9de..50d449b 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ A11C1EAF0000000000000002 /* AesCtr.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000002 /* AesCtr.swift */; }; A11C1EAF0000000000000003 /* CencMp4Decryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11C1EAF0000000000000003 /* CencMp4Decryptor.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 */; }; D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.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 = ""; }; B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CencMp4Decryptor.swift; sourceTree = ""; }; B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearKeyDashProxy.swift; sourceTree = ""; }; + B11C1EAF0000000000000005 /* AVLivePlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVLivePlayerController.swift; sourceTree = ""; }; 522137D80C53480E95FF600F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = ""; }; 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; @@ -163,6 +165,7 @@ B11C1EAF0000000000000002 /* AesCtr.swift */, B11C1EAF0000000000000003 /* CencMp4Decryptor.swift */, B11C1EAF0000000000000004 /* ClearKeyDashProxy.swift */, + B11C1EAF0000000000000005 /* AVLivePlayerController.swift */, ); path = Playback; sourceTree = ""; @@ -338,7 +341,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -353,7 +356,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.36; + MARKETING_VERSION = 0.5.39; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -371,7 +374,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -386,7 +389,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.36; + MARKETING_VERSION = 0.5.39; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/AVLivePlayerController.swift b/apps/ios/VMC/Playback/AVLivePlayerController.swift new file mode 100644 index 0000000..c91f471 --- /dev/null +++ b/apps/ios/VMC/Playback/AVLivePlayerController.swift @@ -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 + } + } +} diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index 017015b..d652bd2 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -21,43 +21,149 @@ private final class ResumeOnce: @unchecked Sendable { final class ClearKeyDashProxy { static let shared = ClearKeyDashProxy() + /// CMAF: video- én audio-init hebben vaak dezelfde track_ID (1). + /// Gescheiden state voorkomt dat audio-crypto video-decrypt overschrijft → geluid zonder beeld. + private enum CryptoLane: String { + case video = "v" + case audio = "a" + case shared = "s" + } + private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy") + /// Beschermt alle gedeelde session-state. payload() draait concurrent via Task{} — + /// zonder deze lock → data races / EXC_BAD_ACCESS. + private let stateLock = NSLock() + private let cryptoLock = NSLock() private var listener: NWListener? private var port: UInt16 = 0 private var remoteManifest: URL? + /// Auth-query van het manifest (bijv. Akamai hdnts) — plakken op relatieve segment-URLs. + private var manifestQuery: String? private var keyMap: ClearKeyKeyMap? - private var cryptoState = CencMp4Decryptor.SessionState() + private var cryptoVideo = CencMp4Decryptor.SessionState() + private var cryptoAudio = CencMp4Decryptor.SessionState() + private var cryptoShared = CencMp4Decryptor.SessionState() private var generation = 0 private var isHlsMode = false + /// AVPlayer kan geen DASH: serve clear HLS (native of vanuit MPD gegenereerd). + private var preferHlsOutput = false + private var dashVideoRep: DashHlsRep? + private var dashAudioRep: DashHlsRep? + /// Beschikbare audio-reps (UI); master playlist heeft er maar één actief. + private var dashAudioReps: [DashHlsRep] = [] + /// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312). + private var segmentSeqByKey: [String: Int] = [:] + private var nextStableSeq = 1 + /// Laatste niet-lege media-playlist per pad (lege refresh → -12312). + private var lastMediaPlaylistByPath: [String: String] = [:] + /// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien. + private var cachedMpdXML: String? + private var cachedMpdAt: Date? + private let mpdCacheTTL: TimeInterval = 1.0 private let urlSession: URLSession = { let cfg = URLSessionConfiguration.ephemeral - cfg.timeoutIntervalForRequest = 25 - cfg.timeoutIntervalForResource = 60 - cfg.httpMaximumConnectionsPerHost = 8 + cfg.timeoutIntervalForRequest = 15 + cfg.timeoutIntervalForResource = 45 + cfg.httpMaximumConnectionsPerHost = 10 cfg.requestCachePolicy = .reloadIgnoringLocalCacheData return URLSession(configuration: cfg) }() + /// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt). + private let playlistGate = PayloadGate() + private init() {} - var isRunning: Bool { listener != nil && port > 0 } + private actor PayloadGate { + func run(_ operation: () async throws -> T) async throws -> T { + try await operation() + } + } + + private func withState(_ body: () throws -> T) rethrows -> T { + stateLock.lock() + defer { stateLock.unlock() } + return try body() + } + + var isRunning: Bool { + withState { listener != nil && port > 0 } + } + + struct ProxyAudioTrack: Identifiable, Hashable { + let id: String + let name: String + } + + func availableAudioTracks() -> [ProxyAudioTrack] { + withState { + dashAudioReps.enumerated().map { i, a in + ProxyAudioTrack(id: a.id, name: Self.audioDisplayName(a, index: i)) + } + } + } + + var preferredAudioId: String? { + withState { dashAudioRep?.id } + } + + @discardableResult + func setPreferredAudio(id: String) -> Bool { + withState { + guard let match = dashAudioReps.first(where: { $0.id == id }) else { return false } + if dashAudioRep?.id == id { return true } + dashAudioRep = match + cryptoLock.lock() + cryptoAudio = CencMp4Decryptor.SessionState() + cryptoLock.unlock() + lastMediaPlaylistByPath["/hls/a.m3u8"] = nil + proxyLog.info("preferred audio → \(match.id, privacy: .public)") + return true + } + } + + /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale URL + sessie-id terug. + struct StartResult { + let url: URL + let session: Int + } /// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug. - func start(manifestURL: URL, keys: [ClearKeyEntry]) async throws -> URL { + /// - Parameter preferHlsForAVPlayer: forceer altijd een lokale `.m3u8` (DASH wordt omgezet). + func start( + manifestURL: URL, + keys: [ClearKeyEntry], + preferHlsForAVPlayer: Bool = false + ) async throws -> StartResult { let map = ClearKeyKeyMap(entries: keys) guard !map.isEmpty else { throw ProxyError.noKeys } let hls = Self.isHlsURL(manifestURL) - let boundPort: UInt16 = try await withCheckedThrowingContinuation { cont in + let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in queue.async { self.stopLocked() self.remoteManifest = manifestURL + self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery self.keyMap = map self.isHlsMode = hls - self.cryptoState = CencMp4Decryptor.SessionState() + self.preferHlsOutput = preferHlsForAVPlayer + self.dashVideoRep = nil + self.dashAudioRep = nil + self.dashAudioReps = [] + self.segmentSeqByKey = [:] + self.nextStableSeq = 1 + self.lastMediaPlaylistByPath = [:] + self.cachedMpdXML = nil + self.cachedMpdAt = nil + self.cryptoLock.lock() + self.cryptoVideo = CencMp4Decryptor.SessionState() + self.cryptoAudio = CencMp4Decryptor.SessionState() + self.cryptoShared = CencMp4Decryptor.SessionState() + self.cryptoLock.unlock() self.generation += 1 + let session = self.generation let once = ResumeOnce() @@ -73,10 +179,10 @@ final class ClearKeyDashProxy { case .ready: let p = listener.port?.rawValue ?? 0 self.port = p - proxyLog.info("listener ready on port \(p, privacy: .public)") + proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)") once.run { if p > 0 { - cont.resume(returning: p) + cont.resume(returning: (p, session)) } else { cont.resume(throwing: ProxyError.bindFailed) } @@ -103,23 +209,95 @@ final class ClearKeyDashProxy { } } - guard boundPort > 0 else { throw ProxyError.bindFailed } - let gen = generation - let entryPath = hls ? "/manifest.m3u8" : "/manifest.mpd" + guard bound.port > 0 else { throw ProxyError.bindFailed } + let gen = bound.session + let entryPath = (hls || preferHlsForAVPlayer) ? "/manifest.m3u8" : "/manifest.mpd" let (body, _) = try await payload(for: entryPath) - guard !body.isEmpty, gen == generation else { + guard !body.isEmpty else { + throw ProxyError.emptyManifest + } + let stillActive = withState { gen == self.generation } + guard stillActive else { throw ProxyError.emptyManifest } let preview = String(data: body.prefix(200), encoding: .utf8) ?? "" + let kind = (hls || preferHlsForAVPlayer) ? "HLS" : "MPD" proxyLog.info( - "warmup \(hls ? "HLS" : "MPD", privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)" + "warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)" ) - guard let local = URL(string: "http://127.0.0.1:\(boundPort)\(entryPath)") else { + // Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt. + if preferHlsForAVPlayer { + await prefetchInitsForAVPlayer() + } + + guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else { throw ProxyError.bindFailed } - return local + return StartResult(url: local, session: gen) + } + + private func prefetchInitsForAVPlayer() async { + let (keyMap, videoRep, audioRep): (ClearKeyKeyMap?, DashHlsRep?, DashHlsRep?) = withState { + (self.keyMap, self.dashVideoRep, self.dashAudioRep) + } + guard let keyMap else { return } + + async let videoDone: Void = { + guard let rep = videoRep, !rep.initialization.isEmpty else { return } + let initURL = self.resolveTemplateURL( + rep.initialization, + base: rep.baseURL, + repId: rep.id, + bandwidth: rep.bandwidth, + number: nil, + time: nil + ) + do { + _ = try await self.fetchAndDecrypt(initURL, keys: keyMap, lane: .video) + let info = self.cryptoSummary(.video) + proxyLog.info("prefetch VIDEO init ok rep=\(rep.id, privacy: .public) \(info, privacy: .public)") + } catch { + proxyLog.error("prefetch VIDEO init failed: \(error.localizedDescription, privacy: .public)") + } + }() + + async let audioDone: Void = { + guard let rep = audioRep, !rep.initialization.isEmpty else { return } + let initURL = self.resolveTemplateURL( + rep.initialization, + base: rep.baseURL, + repId: rep.id, + bandwidth: rep.bandwidth, + number: nil, + time: nil + ) + do { + _ = try await self.fetchAndDecrypt(initURL, keys: keyMap, lane: .audio) + let info = self.cryptoSummary(.audio) + proxyLog.info("prefetch AUDIO init ok rep=\(rep.id, privacy: .public) \(info, privacy: .public)") + } catch { + proxyLog.error("prefetch AUDIO init failed: \(error.localizedDescription, privacy: .public)") + } + }() + + _ = await (videoDone, audioDone) + } + + private func cryptoSummary(_ lane: CryptoLane) -> String { + cryptoLock.lock() + defer { cryptoLock.unlock() } + let state: CencMp4Decryptor.SessionState + switch lane { + case .video: state = cryptoVideo + case .audio: state = cryptoAudio + case .shared: state = cryptoShared + } + guard let c = state.byTrackId.values.first ?? state.fallback else { + return "crypto=none" + } + return "scheme=\(c.scheme) ivSize=\(c.perSampleIvSize) pattern=\(c.cryptByteBlock):\(c.skipByteBlock) tracks=\(state.byTrackId.count)" } static func isHlsURL(_ url: URL) -> Bool { @@ -128,7 +306,18 @@ final class ClearKeyDashProxy { } func stop() { - queue.sync { stopLocked() } + withState { stopLocked() } + } + + /// Stop alleen als dit nog de actieve sessie is (voorkomt race met nieuwe play). + func stop(session: Int) { + withState { + guard session == generation else { + proxyLog.info("skip stop session=\(session, privacy: .public) current=\(self.generation, privacy: .public)") + return + } + stopLocked() + } } private func stopLocked() { @@ -137,9 +326,23 @@ final class ClearKeyDashProxy { listener = nil port = 0 remoteManifest = nil + manifestQuery = nil keyMap = nil isHlsMode = false - cryptoState = CencMp4Decryptor.SessionState() + preferHlsOutput = false + dashVideoRep = nil + dashAudioRep = nil + dashAudioReps = [] + segmentSeqByKey = [:] + nextStableSeq = 1 + lastMediaPlaylistByPath = [:] + cachedMpdXML = nil + cachedMpdAt = nil + cryptoLock.lock() + cryptoVideo = CencMp4Decryptor.SessionState() + cryptoAudio = CencMp4Decryptor.SessionState() + cryptoShared = CencMp4Decryptor.SessionState() + cryptoLock.unlock() proxyLog.info("proxy stopped") } @@ -258,7 +461,27 @@ final class ClearKeyDashProxy { } private func payload(for pathAndQuery: String) async throws -> (Data, String) { - guard let keyMap, let remoteManifest else { + let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery + let isPlaylist = + pathOnly == "/" + || pathOnly.hasSuffix(".m3u8") + || pathOnly.hasSuffix(".mpd") + || pathOnly.hasPrefix("/hls/") + if isPlaylist { + return try await playlistGate.run { + try await self.payloadBody(for: pathAndQuery) + } + } + // Segmenten parallel — cryptoLock beschermt decrypt-state. + return try await payloadBody(for: pathAndQuery) + } + + private func payloadBody(for pathAndQuery: String) async throws -> (Data, String) { + let snap = withState { () -> (ClearKeyKeyMap, URL, Bool, Bool)? in + guard let keyMap, let remoteManifest else { return nil } + return (keyMap, remoteManifest, isHlsMode, preferHlsOutput) + } + guard let (keyMap, remoteManifest, isHlsMode, preferHlsOutput) = snap else { throw ProxyError.notStarted } let split = pathAndQuery.split(separator: "?", maxSplits: 1).map(String.init) @@ -275,14 +498,17 @@ final class ClearKeyDashProxy { let rewritten = rewriteHls(text, playlistURL: remoteManifest) return (Data(rewritten.utf8), "application/vnd.apple.mpegurl") } - guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else { - // Sommige CDN's sturen UTF-16 BOM; probeer lossy. - let lossy = String(decoding: raw, as: UTF8.self) - guard !lossy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw ProxyError.emptyManifest - } - let rewritten = rewriteMpd(lossy, mpdURL: remoteManifest) - return (Data(rewritten.utf8), "application/dash+xml") + let xml: String = { + if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s } + return String(decoding: raw, as: UTF8.self) + }() + guard !xml.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ProxyError.emptyManifest + } + // AVPlayer: DASH → HLS master (+ media playlists via /hls/*). + if preferHlsOutput || pathOnly == "/manifest.m3u8" { + let hls = try withState { try dashToHlsMaster(xml, mpdURL: remoteManifest) } + return (Data(hls.utf8), "application/vnd.apple.mpegurl") } let rewritten = rewriteMpd(xml, mpdURL: remoteManifest) if !rewritten.localizedCaseInsensitiveContains(" (Data, String) { + /// Haal `ck=v|a` uit proxy-query; stuur die NIET door naar de CDN. + private static func splitLaneQuery(_ query: String?) -> (CryptoLane, String?) { + guard let query, !query.isEmpty else { return (.shared, nil) } + var lane: CryptoLane = .shared + var kept: [String] = [] + for part in query.split(separator: "&").map(String.init) { + let lower = part.lowercased() + if lower == "ck=v" || lower.hasPrefix("ck=v&") { + lane = .video + continue + } + if lower == "ck=a" { + lane = .audio + continue + } + if lower.hasPrefix("ck=") { + let v = String(lower.dropFirst(3)) + if v == "v" { lane = .video; continue } + if v == "a" { lane = .audio; continue } + } + kept.append(part) + } + return (lane, kept.isEmpty ? nil : kept.joined(separator: "&")) + } + + private func fetchAndDecrypt( + _ url: URL, + keys: ClearKeyKeyMap, + lane: CryptoLane = .shared + ) async throws -> (Data, String) { var data = try await fetch(url) let lower = url.absoluteString.lowercased() if Self.isHlsURL(url) || Self.looksLikeHls(data) { @@ -331,18 +609,18 @@ final class ClearKeyDashProxy { let rewritten = rewriteMpd(xml, mpdURL: url) return (Data(rewritten.utf8), "application/dash+xml") } - queue.sync { - _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState) - } - let ct: String - if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") - || lower.contains(".cmfv") || lower.contains(".cmfa") || lower.contains("map.mp4") - || lower.contains("cmaf") - { - ct = "video/mp4" - } else { - ct = "application/octet-stream" + cryptoLock.lock() + defer { cryptoLock.unlock() } + switch lane { + case .video: + _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoVideo) + case .audio: + _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoAudio) + case .shared: + _ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoShared) } + // fMP4: generieke mp4 content-type (AVPlayer accepteert dit voor audio+video CMAF). + let ct = "application/mp4" return (data, ct) } @@ -352,29 +630,183 @@ final class ClearKeyDashProxy { return head.hasPrefix("#EXTM3U") } - /// Strip Widevine/PlayReady KEY-tags; herschrijf alle media-URI's naar de proxy. + /// Strip Widevine/PlayReady KEY-tags; herschrijf media-URI's naar de proxy. + /// ESPN EVENT-playlists met duizenden segmenten → trim naar live-edge (anders audio-only / geen beeld). private func rewriteHls(_ text: String, playlistURL: URL) -> String { let lines = text.components(separatedBy: .newlines) + let isMaster = lines.contains { $0.hasPrefix("#EXT-X-STREAM-INF") } var out: [String] = [] - out.reserveCapacity(lines.count) + out.reserveCapacity(min(lines.count, 64)) + + if isMaster { + return rewriteHlsMaster(lines, playlistURL: playlistURL) + } + + // Gescheiden crypto-lanes: CMAF audio+video delen vaak track_ID=1. + // Shared state → audio-init overschrijft video → geluid zonder beeld (ESPN). + let lane = Self.hlsMediaLane(for: playlistURL) + + // Media playlist: verzamel tags + segmenten, trim live-edge. + var header: [String] = [] + var mapLine: String? + var segments: [(inf: String, uri: String)] = [] + var pendingInf: String? + var mediaSequence: Int? + var targetDuration: Int = 6 + var hasEndList = false + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("#EXT-X-KEY:") || trimmed.hasPrefix("#EXT-X-SESSION-KEY:") { continue } - if trimmed.hasPrefix("#") { - out.append(rewriteHlsTagURIs(line, base: playlistURL)) - } else if trimmed.isEmpty { - out.append(line) - } else { - let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: trimmed) - out.append(proxyAbsoluteURL(resolved)) + if trimmed.hasPrefix("#EXT-X-PLAYLIST-TYPE:") { + // EVENT met uren history → AVPlayer start verkeerd / audio-only. Behandel als live. + continue } + if trimmed.hasPrefix("#EXT-X-MEDIA-SEQUENCE:") { + if let v = Int(trimmed.split(separator: ":").last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "") { + mediaSequence = v + } + continue + } + if trimmed.hasPrefix("#EXT-X-TARGETDURATION:") { + if let v = Int(trimmed.split(separator: ":").last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "") { + targetDuration = max(1, v) + } + header.append(line) + continue + } + if trimmed.hasPrefix("#EXT-X-MAP:") { + mapLine = rewriteHlsTagURIs(line, base: playlistURL, lane: lane) + continue + } + if trimmed.hasPrefix("#EXT-X-ENDLIST") { + hasEndList = true + continue + } + if trimmed.hasPrefix("#EXTINF:") { + pendingInf = line + continue + } + if trimmed.hasPrefix("#") { + if trimmed.hasPrefix("#EXTM3U") || trimmed.hasPrefix("#EXT-X-VERSION") + || trimmed.hasPrefix("#EXT-X-INDEPENDENT") || trimmed.hasPrefix("#EXT-X-PROGRAM-DATE-TIME") + || trimmed.hasPrefix("#EXT-X-DISCONTINUITY") || trimmed.hasPrefix("#EXT-X-BYTERANGE") + { + header.append(trimmed.hasPrefix("#EXT-X-PROGRAM-DATE-TIME") || trimmed.hasPrefix("#EXT-X-DISCONTINUITY") || trimmed.hasPrefix("#EXT-X-BYTERANGE") + ? line + : rewriteHlsTagURIs(line, base: playlistURL, lane: lane)) + } + continue + } + if trimmed.isEmpty { continue } + if let inf = pendingInf { + segments.append((inf, trimmed)) + pendingInf = nil + } + } + + // Live-edge window: ~12–16 segmenten (±60–90s bij 5–6s ESPN, ±40–60s bij 3.8s). + let keep = 14 + let startIdx: Int + let seqBase = mediaSequence ?? 0 + if !hasEndList, segments.count > keep { + startIdx = segments.count - keep + } else if segments.count > 40 { + // VOD/EVENT-achtig met enorme history: toch trimmen naar einde. + startIdx = segments.count - keep + } else { + startIdx = 0 + } + let window = Array(segments.suffix(from: startIdx)) + let seq = seqBase + startIdx + + out.append("#EXTM3U") + out.append("#EXT-X-VERSION:7") + out.append("#EXT-X-TARGETDURATION:\(targetDuration)") + out.append("#EXT-X-MEDIA-SEQUENCE:\(seq)") + out.append("#EXT-X-INDEPENDENT-SEGMENTS") + // Live-achtig: start dicht bij einde. + if startIdx > 0 { + out.append("#EXT-X-START:TIME-OFFSET=-12.0,PRECISE=YES") + } + if let mapLine { + out.append(mapLine) + } else { + for h in header where h.hasPrefix("#EXT-X-MAP") { + out.append(h) + } + } + for seg in window { + out.append(seg.inf) + let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri) + out.append(proxyAbsoluteURL(resolved, lane: lane)) + } + if hasEndList && startIdx == 0 { + out.append("#EXT-X-ENDLIST") } return out.joined(separator: "\n") } - private func rewriteHlsTagURIs(_ line: String, base: URL) -> String { + /// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`). + private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane { + let s = playlistURL.path.lowercased() + if s.contains("/audio") || s.contains("audio-") || s.contains("aac") + || s.contains(".mp4a") || s.contains("mp4a") + { + return .audio + } + return .video + } + + /// Master: strip DRM keys, proxy alle variant/audio URI's — audio→ck=a, video→ck=v. + private func rewriteHlsMaster(_ lines: [String], playlistURL: URL) -> String { + var out: [String] = [] + var i = 0 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#EXT-X-KEY:") || trimmed.hasPrefix("#EXT-X-SESSION-KEY:") { + i += 1 + continue + } + if trimmed.hasPrefix("#EXT-X-I-FRAME-STREAM-INF") { + i += 1 + continue + } + if trimmed.hasPrefix("#EXT-X-STREAM-INF") { + let inf = rewriteHlsTagURIs(line, base: playlistURL, lane: .video) + i += 1 + while i < lines.count && lines[i].trimmingCharacters(in: .whitespaces).isEmpty { i += 1 } + guard i < lines.count else { break } + let uri = lines[i].trimmingCharacters(in: .whitespaces) + let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: uri) + out.append(inf) + out.append(proxyAbsoluteURL(resolved, lane: .video)) + i += 1 + continue + } + if trimmed.hasPrefix("#EXT-X-MEDIA:") { + let lane: CryptoLane = trimmed.contains("TYPE=AUDIO") ? .audio : .shared + out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: lane)) + i += 1 + continue + } + if trimmed.hasPrefix("#") { + out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: .shared)) + } else if !trimmed.isEmpty { + let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: trimmed) + out.append(proxyAbsoluteURL(resolved, lane: .video)) + } else { + out.append(line) + } + i += 1 + } + return out.joined(separator: "\n") + } + + private func rewriteHlsTagURIs(_ line: String, base: URL, lane: CryptoLane = .shared) -> String { guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else { return line } @@ -392,20 +824,26 @@ final class ClearKeyDashProxy { } else { resolved = join(base: asDirectory(base.deletingLastPathComponent()), relative: uri) } - let replacement = "URI=\"\(proxyAbsoluteURL(resolved))\"" + let replacement = "URI=\"\(proxyAbsoluteURL(resolved, lane: lane))\"" let full = match.range result = (result as NSString).replacingCharacters(in: full, with: replacement) } return result } - private func proxyAbsoluteURL(_ url: URL) -> String { - "http://127.0.0.1:\(port)/a/\(encodeURL(url))" + private func proxyAbsoluteURL(_ url: URL, lane: CryptoLane = .shared) -> String { + var s = "http://127.0.0.1:\(port)/a/\(encodeURL(url))" + switch lane { + case .video: s += "?ck=v" + case .audio: s += "?ck=a" + case .shared: break + } + return s } private func fetch(_ url: URL) async throws -> Data { var req = URLRequest(url: url) - req.timeoutInterval = 25 + req.timeoutInterval = 15 req.cachePolicy = .reloadIgnoringLocalCacheData req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent") req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges") @@ -480,6 +918,771 @@ final class ClearKeyDashProxy { return s } + // MARK: - DASH → HLS (voor AVPlayer) + + private struct DashHlsRep { + let id: String + let bandwidth: Int + let codecs: String? + let width: Int? + let height: Int? + let language: String? + let label: String? + let baseURL: URL + let initialization: String + let media: String + /// SegmentList media-URLs (relatief of absoluut); leeg bij SegmentTemplate. + let segmentURLs: [String] + let timescale: Int + let duration: Int + let startNumber: Int + let isLive: Bool + let availabilityStart: Date? + /// Compact SegmentTimeline runs: count = aantal segmenten in de run (ISO r+1). + let timelineRuns: [(t: Int, d: Int, count: Int)] + + var isAac: Bool { + let c = (codecs ?? "").lowercased() + return c.contains("mp4a") || c.contains("aac") + } + + var usesNumberTemplate: Bool { + media.contains("$Number") + } + + var usesTimeTemplate: Bool { + media.contains("$Time") + } + } + + private func dashToHlsMaster(_ xml: String, mpdURL: URL) throws -> String { + let reps = parseDashReps(xml, mpdURL: mpdURL) + let videos = reps.filter { !$0.isAudio }.map(\.rep) + let audios = reps.filter(\.isAudio).map(\.rep) + // Live: één vaste ladder (geen ABR). + // Liever ~1080p binnen cap — niet de max CDN-rung (dat gaf decrypt-stutter). + // Cap ≈5.5–6 Mbit; mid ≈8 Mbit als er geen 1080 onder de cap is. + let numberHeavy = videos.contains { $0.usesNumberTemplate } + let video: DashHlsRep? = { + let sorted = videos.sorted { $0.bandwidth < $1.bandwidth } + .filter { $0.bandwidth >= 200_000 } + let pool = sorted.isEmpty ? videos.sorted { $0.bandwidth < $1.bandwidth } : sorted + guard !pool.isEmpty else { return nil } + let cap = numberHeavy ? 5_500_000 : 6_000_000 + let midCap = numberHeavy ? 7_500_000 : 8_000_000 + let underCap = pool.filter { $0.bandwidth <= cap } + let underMid = pool.filter { $0.bandwidth <= midCap } + // 1) 1080p (of hoger) onder cap + if let uhd = underCap.last(where: { ($0.height ?? 0) >= 1080 }) { return uhd } + // 2) hoogste under cap (vaak 720/900 als height ontbreekt) + if let best = underCap.last { return best } + // 3) mid-cap, opnieuw 1080 eerst + if let uhd = underMid.last(where: { ($0.height ?? 0) >= 1080 }) { return uhd } + if let mid = underMid.last { return mid } + return pool.first + }() + guard let video = video ?? audios.max(by: { $0.bandwidth < $1.bandwidth }) + else { + throw ProxyError.emptyManifest + } + let videoIsAudioOnly = videos.isEmpty + if videoIsAudioOnly { + dashVideoRep = video + dashAudioRep = nil + dashAudioReps = [] + return mediaPlaylist(for: video, lane: .audio) + } + dashVideoRep = video + // AAC/mp4a eerst — EC-3/Atmos faalt vaker stil (geen geluid) op iOS HLS. + let audioPool = Self.preferredAudioOrder(audios) + dashAudioReps = Array(audioPool.prefix(8)) + // Behoud gekozen spoor bij master-refresh; anders default. + if let cur = dashAudioRep, let keep = dashAudioReps.first(where: { $0.id == cur.id }) { + dashAudioRep = keep + } else { + dashAudioRep = audioPool.first + } + let audio = dashAudioRep + + var lines: [String] = [ + "#EXTM3U", + "#EXT-X-VERSION:7", + "#EXT-X-INDEPENDENT-SEGMENTS", + ] + // Eén audio-rendition in de master. Meerdere EXT-X-MEDIA + live refresh → AVPlayer -12312. + // Extra sporen wisselen via setPreferredAudio + item-reload. + if let audio { + let name = Self.audioDisplayName(audio, index: 0) + let lang = (audio.language?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? "und" + lines.append( + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"\(name)\",LANGUAGE=\"\(lang)\",DEFAULT=YES,AUTOSELECT=YES,URI=\"hls/a.m3u8\"" + ) + } + var streamInf = "#EXT-X-STREAM-INF:BANDWIDTH=\(max(video.bandwidth, 1))" + if let w = video.width, let h = video.height, w > 0, h > 0 { + streamInf += ",RESOLUTION=\(w)x\(h)" + } + var codecParts: [String] = [] + if let c = video.codecs, !c.isEmpty { codecParts.append(c) } + if let a = audio?.codecs, !a.isEmpty { codecParts.append(a) } + if !codecParts.isEmpty { + streamInf += ",CODECS=\"\(codecParts.joined(separator: ","))\"" + } + if audio != nil { + streamInf += ",AUDIO=\"aud\"" + } + lines.append(streamInf) + lines.append("hls/v.m3u8") + proxyLog.info( + "DASH→HLS video bw=\(video.bandwidth, privacy: .public) audioCount=\(self.dashAudioReps.count, privacy: .public) audioId=\(audio?.id ?? "-", privacy: .public) live=\(video.isLive, privacy: .public)" + ) + return lines.joined(separator: "\n") + } + + private static func preferredAudioOrder(_ audios: [DashHlsRep]) -> [DashHlsRep] { + let aac = audios.filter(\.isAac) + let pool = aac.isEmpty ? audios : aac + let preferred = Locale.current.language.languageCode?.identifier.lowercased() ?? "nl" + return pool.sorted { a, b in + let aScore = Self.audioLocaleScore(a, preferred: preferred) + let bScore = Self.audioLocaleScore(b, preferred: preferred) + if aScore != bScore { return aScore > bScore } + // Prefer "main"/omschrijving zonder commentary. + let aMain = Self.looksPrimary(a) + let bMain = Self.looksPrimary(b) + if aMain != bMain { return aMain && !bMain } + return a.bandwidth < b.bandwidth + } + } + + private static func audioLocaleScore(_ a: DashHlsRep, preferred: String) -> Int { + let lang = Self.normalizeLang(a.language) + let label = (a.label ?? "").lowercased() + if lang == preferred { return 100 } + if preferred == "nl", lang == "dut" || lang == "nld" { return 100 } + if preferred == "nl", label.contains("nederlands") || label.contains("dutch") || label.contains("holland") { + return 90 + } + if label.contains(preferred) { return 80 } + if lang == "en" || lang == "eng" { return 20 } + if lang.isEmpty { return 10 } + return 0 + } + + private static func looksPrimary(_ a: DashHlsRep) -> Bool { + let label = (a.label ?? "").lowercased() + if label.contains("comment") || label.contains("descr") || label.contains("ad ") { return false } + return true + } + + private static func normalizeLang(_ raw: String?) -> String { + guard let raw else { return "" } + let s = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if s.hasPrefix("nl") { return "nl" } + if s.hasPrefix("dut") || s == "nld" { return "nl" } + if s.hasPrefix("en") { return "en" } + if s.hasPrefix("sv") || s.hasPrefix("swe") { return "sv" } + if s.count >= 2 { return String(s.prefix(2)) } + return s + } + + private static func audioDisplayName(_ a: DashHlsRep, index: Int) -> String { + if let label = a.label?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { + // Viaplay e.d.: Label is betrouwbaarder dan lang= (vaak "swe" terwijl audio NL is). + return label + } + let lang = normalizeLang(a.language) + if !lang.isEmpty, let name = Locale.current.localizedString(forLanguageCode: lang) { + return name.capitalized + } + if a.isAac { return "AAC \(index + 1)" } + return "Audio \(index + 1)" + } + + private static func firstLabel(in xml: String) -> String? { + guard let r = xml.range( + of: #"]*>([^<]+)"#, + options: [.regularExpression, .caseInsensitive] + ) else { return nil } + let block = String(xml[r]) + guard let start = block.firstIndex(of: ">"), + let end = block.lastIndex(of: "<"), + start < end else { return nil } + let text = String(block[block.index(after: start).. [ParsedRep] { + let isLive = xml.range(of: #"type\s*=\s*"dynamic""#, options: [.regularExpression, .caseInsensitive]) != nil + || xml.range(of: #"type\s*=\s*'dynamic'"#, options: [.regularExpression, .caseInsensitive]) != nil + var availabilityStart: Date? + if let m = xml.range(of: #"availabilityStartTime\s*=\s*"([^"]+)""#, options: [.regularExpression, .caseInsensitive]) { + let attr = String(xml[m]) + if let q1 = attr.firstIndex(of: "\""), let q2 = attr.lastIndex(of: "\""), q1 < q2 { + let raw = String(attr[attr.index(after: q1)..]*)>([\s\S]*?)"#, + options: [.caseInsensitive] + ) { + let ns = xml as NSString + let matches = periodRegex.matches(in: xml, options: [], range: NSRange(location: 0, length: ns.length)) + if let last = matches.last, last.numberOfRanges >= 3 { + let attrs = ns.substring(with: last.range(at: 1)) + let body = ns.substring(with: last.range(at: 2)) + scopeXML = attrs + body + } + } + + let mpdBase = Self.firstBaseURL(in: xml).map { resolveBaseURL($0, mpdURL: mpdURL) } + ?? asDirectory(mpdURL.deletingLastPathComponent()) + let periodBase = Self.firstBaseURL(in: scopeXML).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? mpdBase + + var results: [ParsedRep] = [] + guard let asRegex = try? NSRegularExpression( + pattern: #"]*)>([\s\S]*?)"#, + options: [.caseInsensitive] + ) else { return [] } + let ns = scopeXML as NSString + let asMatches = asRegex.matches(in: scopeXML, options: [], range: NSRange(location: 0, length: ns.length)) + for asMatch in asMatches { + guard asMatch.numberOfRanges >= 3 else { continue } + let asAttrs = ns.substring(with: asMatch.range(at: 1)) + let asBody = ns.substring(with: asMatch.range(at: 2)) + let contentType = Self.xmlAttr(asAttrs, "contentType")?.lowercased() + ?? Self.xmlAttr(asAttrs, "mimeType")?.lowercased() + ?? "" + let asMime = Self.xmlAttr(asAttrs, "mimeType")?.lowercased() ?? "" + let isAudio = contentType.contains("audio") || asMime.hasPrefix("audio/") + let isVideo = contentType.contains("video") || asMime.hasPrefix("video/") || (!isAudio && asBody.contains("width=")) + guard isAudio || isVideo else { continue } + + let asBase = Self.firstBaseURL(in: asBody).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? periodBase + let asTemplate = Self.parseSegmentTemplate(asAttrs + asBody) + let lang = Self.xmlAttr(asAttrs, "lang") + let label = Self.firstLabel(in: asBody) ?? Self.xmlAttr(asAttrs, "label") + + guard let repRegex = try? NSRegularExpression( + // Self-closing EERST — anders matcht `>(…)` over sibling `/>` reps heen + // (Ziggo/Viaplay) en pakt de audio-SegmentTimeline bij video → 404 segmenten. + pattern: #"]*)/>|]*)>([\s\S]*?)"#, + options: [.caseInsensitive] + ) else { continue } + let repMatches = repRegex.matches(in: asBody, options: [], range: NSRange(location: 0, length: (asBody as NSString).length)) + let asNS = asBody as NSString + for rm in repMatches { + let attrs: String + let body: String + let g1 = rm.range(at: 1) + let g2 = rm.numberOfRanges > 2 ? rm.range(at: 2) : NSRange(location: NSNotFound, length: 0) + let g3 = rm.numberOfRanges > 3 ? rm.range(at: 3) : NSRange(location: NSNotFound, length: 0) + if g1.location != NSNotFound, g2.location == NSNotFound { + // Self-closing + attrs = asNS.substring(with: g1) + body = "" + } else if g2.location != NSNotFound { + attrs = asNS.substring(with: g2) + body = g3.location != NSNotFound ? asNS.substring(with: g3) : "" + } else { + continue + } + let id = Self.xmlAttr(attrs, "id") ?? "0" + let bw = Int(Self.xmlAttr(attrs, "bandwidth") ?? "0") ?? 0 + let codecs = Self.xmlAttr(attrs, "codecs") ?? Self.xmlAttr(asAttrs, "codecs") + let width = Int(Self.xmlAttr(attrs, "width") ?? Self.xmlAttr(asAttrs, "width") ?? "") + let height = Int(Self.xmlAttr(attrs, "height") ?? Self.xmlAttr(asAttrs, "height") ?? "") + let repLang = Self.xmlAttr(attrs, "lang") ?? lang + let repLabel = Self.firstLabel(in: body) ?? Self.xmlAttr(attrs, "label") ?? label + let repBase = Self.firstBaseURL(in: body).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? asBase + var template = Self.parseSegmentTemplate(attrs + body) + if template.media.isEmpty && template.segmentListMedia.isEmpty { template = asTemplate } + // SegmentTemplate op AdaptationSet-niveau (alleen attrs, body leeg) al meegenomen. + if template.media.isEmpty && template.segmentListMedia.isEmpty { + template = Self.parseSegmentTemplate(asAttrs) + } + if template.media.isEmpty && template.segmentListMedia.isEmpty { continue } + let timescale = template.timescale > 0 ? template.timescale : 1 + // Zonder @duration (Prime/HBO): default 2s segmenten. + let duration = template.duration > 0 ? template.duration : timescale * 2 + let rep = DashHlsRep( + id: id, + bandwidth: bw, + codecs: codecs, + width: width, + height: height, + language: repLang, + label: repLabel, + baseURL: repBase, + initialization: template.initialization, + media: template.media, + segmentURLs: template.segmentListMedia, + timescale: timescale, + duration: duration, + startNumber: template.startNumber, + isLive: isLive, + availabilityStart: availabilityStart, + timelineRuns: template.timelineRuns + ) + let audioFlag = isAudio || (!isVideo && (codecs?.contains("mp4a") == true || codecs?.contains("ec-3") == true || codecs?.contains("ac-3") == true)) + results.append(ParsedRep(rep: rep, isAudio: audioFlag)) + } + } + return results + } + + private struct SegTemplateParsed { + var timescale: Int = 1 + var duration: Int = 0 + var startNumber: Int = 1 + var media: String = "" + var initialization: String = "" + /// Compact runs — nooit 1800 entries materialiseren bij parse. + var timelineRuns: [(t: Int, d: Int, count: Int)] = [] + var segmentListMedia: [String] = [] + } + + private static func parseSegmentTemplate(_ chunk: String) -> SegTemplateParsed { + var out = SegTemplateParsed() + if let m = chunk.range( + of: #"]*)>([\s\S]*?)|]*)/>"#, + options: [.regularExpression, .caseInsensitive] + ) { + let block = String(chunk[m]) + let attrs: String + if let openEnd = block.firstIndex(of: ">") { + attrs = String(block[block.index(block.startIndex, offsetBy: "([\s\S]*?)"#, options: [.regularExpression, .caseInsensitive]) { + let body = String(block[tl]) + if let sRegex = try? NSRegularExpression(pattern: #"]*)/?>"#, options: [.caseInsensitive]) { + let ns = body as NSString + var cursor = 0 + for sm in sRegex.matches(in: body, options: [], range: NSRange(location: 0, length: ns.length)) { + let a = ns.substring(with: sm.range(at: 1)) + let t = Int(xmlAttr(a, "t") ?? "") ?? cursor + let d = Int(xmlAttr(a, "d") ?? "0") ?? 0 + let r = Int(xmlAttr(a, "r") ?? "0") ?? 0 + guard d > 0 else { continue } + let count = r + 1 + guard count > 0, count < 1_000_000 else { continue } + out.timelineRuns.append((t: t, d: d, count: count)) + cursor = t + count * d + } + } + } + return out + } + + // SegmentList (Prime/HBO e.d.): alle SegmentURL's bewaren — niet alleen de eerste. + if let listR = chunk.range( + of: #"]*>([\s\S]*?)"#, + options: [.regularExpression, .caseInsensitive] + ) { + let block = String(chunk[listR]) + let open = block.split(separator: ">", maxSplits: 1).first.map(String.init) ?? "" + out.timescale = Int(xmlAttr(open, "timescale") ?? "1") ?? 1 + out.duration = Int(xmlAttr(open, "duration") ?? "0") ?? 0 + out.startNumber = Int(xmlAttr(open, "startNumber") ?? "1") ?? 1 + if let initMatch = block.range( + of: #"]*sourceURL="([^"]+)""#, + options: [.regularExpression, .caseInsensitive] + ) { + let s = String(block[initMatch]) + if let q1 = s.range(of: "sourceURL=\"")?.upperBound, + let q2 = s[q1...].firstIndex(of: "\"") { + out.initialization = String(s[q1..]*media="([^"]+)""#, + options: [.caseInsensitive] + ) { + let ns = block as NSString + for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) { + guard m.numberOfRanges >= 2 else { continue } + let media = ns.substring(with: m.range(at: 1)) + if !media.isEmpty { out.segmentListMedia.append(media) } + } + } + } + return out + } + + private static func firstBaseURL(in xml: String) -> String? { + guard let r = xml.range(of: #"([\s\S]*?)"#, options: [.regularExpression, .caseInsensitive]) else { + return nil + } + let block = String(xml[r]) + guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil } + return String(block[block.index(after: start).. String? { + let patterns = [ + "\(name)\\s*=\\s*\"([^\"]*)\"", + "\(name)\\s*=\\s*'([^']*)'", + ] + for p in patterns { + guard let re = try? NSRegularExpression(pattern: p, options: [.caseInsensitive]) else { continue } + let ns = attrs as NSString + if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)), + m.numberOfRanges >= 2 { + return ns.substring(with: m.range(at: 1)) + } + } + return nil + } + + private static func parseISO8601(_ s: String) -> Date? { + let f1 = ISO8601DateFormatter() + f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = f1.date(from: s) { return d } + let f2 = ISO8601DateFormatter() + f2.formatOptions = [.withInternetDateTime] + return f2.date(from: s) + } + + private func stableSeq(for url: URL) -> Int { + let key = url.absoluteString + if let s = segmentSeqByKey[key] { return s } + let s = nextStableSeq + nextStableSeq += 1 + segmentSeqByKey[key] = s + return s + } + + private func mediaPlaylist(for rep: DashHlsRep, lane: CryptoLane) -> String { + let scale = max(rep.timescale, 1) + // duration==0 zou niet meer moeten — parse zet default 2s — maar blijf defensief. + let segTicks = rep.duration > 0 ? rep.duration : scale * 2 + let defaultDur = Double(segTicks) / Double(scale) + var maxDur = defaultDur + var urlsInOrder: [(url: URL, dur: Double)] = [] + /// Absoluut segment-index van eerste URL (voor MEDIA-SEQUENCE bij $Number$). + var windowStartIndex = 0 + /// Eerste segment $Time$ + tick-duur (voor MEDIA-SEQUENCE bij $Time$ — Apple: moet meestijgen als edge schuift). + var windowStartTime: Int? + var windowStartDurTicks: Int? + let preferNumber = rep.usesNumberTemplate || !rep.usesTimeTemplate + + if !rep.segmentURLs.isEmpty { + var urls = rep.segmentURLs + if rep.isLive, urls.count > 12 { + urls = Array(urls.suffix(12)) + } else if urls.count > 20 { + urls = Array(urls.suffix(20)) + } + windowStartIndex = max(0, rep.segmentURLs.count - urls.count) + for path in urls { + let url: URL + if path.lowercased().hasPrefix("http://") || path.lowercased().hasPrefix("https://") { + url = withManifestQuery(URL(string: path) ?? join(base: rep.baseURL, relative: path)) + } else { + url = withManifestQuery(join(base: asDirectory(rep.baseURL), relative: path)) + } + maxDur = max(maxDur, defaultDur) + urlsInOrder.append((url, defaultDur)) + } + } else if !rep.timelineRuns.isEmpty { + let total = rep.timelineRuns.reduce(0) { $0 + max(0, $1.count) } + // Apple live HLS: blijf achter de live-edge (≈2–3 target durations). + // edgePad=0 + schuivende $Time$ → AVPlayer vraagt verlopen URLs → "resource niet beschikbaar". + // $Number$ (Prime/HBO): korter venster + iets minder pad → snellere start (2s segs). + var edgePad = 0 + if rep.isLive { + if preferNumber { + if total > 100 { + edgePad = 3 + } else if total > 20 { + edgePad = 2 + } else { + edgePad = 1 + } + } else if total > 100 { + edgePad = 6 + } else if total > 20 { + edgePad = 3 + } else if total > 8 { + edgePad = 2 + } else { + edgePad = 1 + } + } + if total <= 3 { + edgePad = 0 + } else if total - edgePad < 3 { + edgePad = max(0, total - 3) + } + let end = max(0, total - edgePad) + // $Time$ (Viaplay/Ziggo): 10 segs. $Number$ (Prime/HBO): 5 segs ≈10s — sneller bufferen. + let windowLen: Int + if !rep.isLive { + windowLen = 12 + } else if preferNumber { + windowLen = 5 + } else { + windowLen = 10 + } + let start = max(0, end - windowLen) + windowStartIndex = start + // Alleen het venster materialiseren (Prime/HBO: r≈1800). + var absIndex = 0 + for run in rep.timelineRuns { + let runCount = max(0, run.count) + let runEnd = absIndex + runCount + let overlapStart = max(start, absIndex) + let overlapEnd = min(end, runEnd) + if overlapStart < overlapEnd { + for i in overlapStart..= end { break } + } + } else { + // Alleen $Number$ zonder SegmentTimeline (Prime/HBO fallback). + let count = (rep.isLive && preferNumber) ? 5 : 10 + var edgePad = rep.isLive ? (preferNumber ? 1 : 2) : 0 + var endNum = rep.startNumber + count + if rep.isLive { + let startDate = rep.availabilityStart ?? Date().addingTimeInterval(-Double(count + edgePad) * defaultDur) + let elapsed = max(0, Date().timeIntervalSince(startDate)) + let current = rep.startNumber + max(0, Int(elapsed / max(defaultDur, 0.1))) + if current - rep.startNumber < 3 { edgePad = 0 } + endNum = max(rep.startNumber + 2, current - edgePad) + } + let startNum = max(rep.startNumber, endNum - count + 1) + windowStartIndex = startNum - rep.startNumber + for n in startNum...endNum { + let url = resolveTemplateURL( + rep.media, + base: rep.baseURL, + repId: rep.id, + bandwidth: rep.bandwidth, + number: n, + time: nil + ) + urlsInOrder.append((url, defaultDur)) + } + } + + // MEDIA-SEQUENCE (RFC 8216 / Apple live playlist): + // - $Number$: absoluut DASH-segmentnummer (startNumber+index) — schuift met Amazon live edge. + // - $Time$: NIET relative index (blijft 0 terwijl t0 schuift → stutter/404). Wel t/d zodat + // sequence meestijgt wanneer SegmentTimeline opschuift (Viaplay/Ziggo). + let mediaSequence: Int + if preferNumber, (!rep.timelineRuns.isEmpty || rep.segmentURLs.isEmpty) { + mediaSequence = rep.startNumber + windowStartIndex + for item in urlsInOrder { + _ = stableSeq(for: item.url) + } + } else if let t0 = windowStartTime, let dTicks = windowStartDurTicks, dTicks > 0 { + mediaSequence = t0 / dTicks + for item in urlsInOrder { + _ = stableSeq(for: item.url) + } + } else if let first = urlsInOrder.first { + mediaSequence = stableSeq(for: first.url) + for item in urlsInOrder.dropFirst() { + _ = stableSeq(for: item.url) + } + } else { + mediaSequence = rep.startNumber + } + + let target = max(1, Int(ceil(maxDur))) + + var lines: [String] = [ + "#EXTM3U", + "#EXT-X-VERSION:7", + "#EXT-X-TARGETDURATION:\(target)", + "#EXT-X-MEDIA-SEQUENCE:\(mediaSequence)", + "#EXT-X-INDEPENDENT-SEGMENTS", + ] + if !rep.initialization.isEmpty { + let initURL = resolveTemplateURL( + rep.initialization, + base: rep.baseURL, + repId: rep.id, + bandwidth: rep.bandwidth, + number: nil, + time: nil + ) + lines.append("#EXT-X-MAP:URI=\"\(proxyAbsoluteURL(initURL, lane: lane))\"") + } else { + proxyLog.error("DASH rep \(rep.id, privacy: .public) mist initialization — AVPlayer faalt zonder EXT-X-MAP") + } + + for seg in urlsInOrder { + lines.append(String(format: "#EXTINF:%.3f,", seg.dur)) + lines.append(proxyAbsoluteURL(seg.url, lane: lane)) + } + if !rep.isLive { + lines.append("#EXT-X-ENDLIST") + } + return lines.joined(separator: "\n") + } + + /// Live: MPD opnieuw ophalen zodat video+audio uit dezelfde snapshot komen. + /// Korte TTL-cache: v.m3u8 + a.m3u8 delen één fetch (voorkomt A/V-desync). + private func refreshDashRepsFromRemote() async throws { + let remote = withState { remoteManifest } + guard let remoteManifest = remote else { throw ProxyError.notStarted } + + let cached: String? = withState { + if let xml = cachedMpdXML, let at = cachedMpdAt, + Date().timeIntervalSince(at) < mpdCacheTTL { + return xml + } + return nil + } + + let xml: String + if let cached { + xml = cached + } else { + let raw = try await fetch(remoteManifest) + let fetched: String = { + if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s } + return String(decoding: raw, as: UTF8.self) + }() + withState { + cachedMpdXML = fetched + cachedMpdAt = Date() + } + xml = fetched + } + + let reps = parseDashReps(xml, mpdURL: remoteManifest) + let videos = reps.filter { !$0.isAudio }.map(\.rep) + let audios = reps.filter(\.isAudio).map(\.rep) + + withState { + if let cached = dashVideoRep { + dashVideoRep = videos.first(where: { $0.id == cached.id }) + ?? videos.sorted { $0.bandwidth < $1.bandwidth }.last(where: { $0.bandwidth <= 8_000_000 }) + ?? videos.max(by: { $0.bandwidth < $1.bandwidth }) + ?? cached + } + if let cached = dashAudioRep { + let refreshed = audios.first(where: { $0.id == cached.id }) + ?? Self.preferredAudioOrder(audios).first + ?? cached + dashAudioRep = refreshed + } + if !dashAudioReps.isEmpty { + let byId = Dictionary(uniqueKeysWithValues: audios.map { ($0.id, $0) }) + dashAudioReps = dashAudioReps.map { byId[$0.id] ?? $0 } + if dashAudioReps.allSatisfy({ byId[$0.id] == nil }) { + dashAudioReps = Array(Self.preferredAudioOrder(audios).prefix(8)) + dashAudioRep = dashAudioReps.first + } + } + } + } + + private func resolveTemplateURL( + _ template: String, + base: URL, + repId: String, + bandwidth: Int, + number: Int?, + time: Int? + ) -> URL { + var s = template + s = s.replacingOccurrences(of: "$RepresentationID$", with: repId) + s = s.replacingOccurrences(of: "$Bandwidth$", with: String(bandwidth)) + if let number { + if let re = try? NSRegularExpression(pattern: #"\$Number(%0(\d+)d)?\$"#, options: []) { + let ns = s as NSString + let matches = re.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) + for m in matches.reversed() { + let width: Int + if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound { + width = Int(ns.substring(with: m.range(at: 2))) ?? 0 + } else { + width = 0 + } + let repl = width > 0 + ? String(format: "%0\(width)d", number) + : String(number) + s = (s as NSString).replacingCharacters(in: m.range, with: repl) + } + } + } + if let time { + if let re = try? NSRegularExpression(pattern: #"\$Time(%0(\d+)d)?\$"#, options: []) { + let ns = s as NSString + let matches = re.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length)) + for m in matches.reversed() { + let width: Int + if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound { + width = Int(ns.substring(with: m.range(at: 2))) ?? 0 + } else { + width = 0 + } + let repl = width > 0 + ? String(format: "%0\(width)d", time) + : String(time) + s = (s as NSString).replacingCharacters(in: m.range, with: repl) + } + } + } + return withManifestQuery(join(base: asDirectory(base), relative: s)) + } + + /// Zet manifest-auth query op CDN-URLs die die zelf niet hebben (Ziggo/Akamai hdnts). + /// Geen nested withState — wordt vanuit mediaPlaylist aangeroepen onder stateLock. + private func withManifestQuery(_ url: URL) -> URL { + let q = manifestQuery + guard let q, !q.isEmpty else { return url } + if let existing = url.query, !existing.isEmpty { return url } + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) + comps?.percentEncodedQuery = q + return comps?.url ?? url + } + /// Resolve BaseURL tekst t.o.v. MPD-URL; altijd als directory-URL. private func resolveBaseURL(_ raw: String, mpdURL: URL) -> URL { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) @@ -624,3 +1827,4 @@ final class ClearKeyDashProxy { } } } + diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 6d9207c..347e326 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -69,7 +69,9 @@ struct PlayerScreen: View { 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 loading = true @State private var error: String? @@ -81,23 +83,48 @@ struct PlayerScreen: View { @State private var subtitleHits: [SubtitleHit] = [] @State private var subsLoading = false @State private var subsError: String? + @State private var pendingStop: Task? + + 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 { ZStack { 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 .contentShape(Rectangle()) .ignoresSafeArea() .onTapGesture { toggleControls() } .allowsHitTesting(!controlsVisible) - if loading && !player.isPlaying { - ProgressView(player.stateText) + if loading && !isPlaying { + ProgressView(stateText) .tint(.white) .foregroundStyle(.white) .allowsHitTesting(false) @@ -105,8 +132,8 @@ struct PlayerScreen: View { if let error { errorOverlay(error) - } else if player.hasError { - errorOverlay(player.errorMessage ?? "Afspeelfout") + } else if playerHasError { + errorOverlay(playerErrorMessage ?? "Afspeelfout") } if controlsVisible { @@ -116,7 +143,11 @@ struct PlayerScreen: View { } .statusBarHidden(true) .persistentSystemOverlays(.hidden) - .onAppear { app.enterPlayback() } + .onAppear { + pendingStop?.cancel() + pendingStop = nil + app.enterPlayback() + } .task { await start() scheduleHide() @@ -127,8 +158,19 @@ struct PlayerScreen: View { hideTask?.cancel() progressTask?.cancel() heartbeatTask?.cancel() - Task { await saveProgress() } - player.stop() + if !isLive { + 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) { tracksSheet @@ -154,17 +196,17 @@ struct PlayerScreen: View { .font(McFonts.headline) .foregroundStyle(.white) .lineLimit(1) - Text(player.stateText) + Text(stateText) .font(McFonts.caption) .foregroundStyle(.white.opacity(0.7)) } Spacer() - if player.pipPossible { + if !useClearKeyAV && vlc.pipPossible { Button { - player.togglePictureInPicture() + vlc.togglePictureInPicture() scheduleHide() } label: { - Image(systemName: player.pipActive + Image(systemName: vlc.pipActive ? "pip.exit" : "pip.enter") .font(.title3) @@ -198,9 +240,9 @@ struct PlayerScreen: View { if !request.isLive { Slider( value: Binding( - get: { Double(player.position) }, + get: { Double(vlc.position) }, set: { - player.seek(ratio: Float($0)) + vlc.seek(ratio: Float($0)) scheduleHide() } ), @@ -209,33 +251,33 @@ struct PlayerScreen: View { .tint(McColors.accent) HStack { - Text(player.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) + Text(vlc.timeText).font(McFonts.caption).foregroundStyle(.white.opacity(0.85)) 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) { if !request.isLive { Button { - player.seek(toMs: max(player.currentPositionMs() - 10_000, 0)) + vlc.seek(toMs: max(vlc.currentPositionMs() - 10_000, 0)) scheduleHide() } label: { Image(systemName: "gobackward.10").font(.title).foregroundStyle(.white) } } Button { - player.togglePlay() + if useClearKeyAV { av.togglePlay() } else { vlc.togglePlay() } scheduleHide() } label: { - Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") + Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 64)) .foregroundStyle(.white) .shadow(radius: 8) } if !request.isLive { Button { - player.seek(toMs: player.currentPositionMs() + 10_000) + vlc.seek(toMs: vlc.currentPositionMs() + 10_000) scheduleHide() } label: { Image(systemName: "goforward.10").font(.title).foregroundStyle(.white) @@ -263,16 +305,16 @@ struct PlayerScreen: View { if trackTab == 0 { List { - ForEach(player.subtitleTracks) { track in + ForEach(vlc.subtitleTracks) { track in Button { - player.selectSubtitle(id: track.id) + vlc.selectSubtitle(id: track.id) showTracks = false scheduleHide() } label: { HStack { Text(track.name).foregroundStyle(McColors.text) Spacer() - if track.id == player.currentSubtitleId { + if track.id == vlc.currentSubtitleId { Image(systemName: "checkmark").foregroundStyle(McColors.accent) } } @@ -315,16 +357,16 @@ struct PlayerScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { - ForEach(player.audioTracks) { track in + ForEach(vlc.audioTracks) { track in Button { - player.selectAudio(id: track.id) + vlc.selectAudio(id: track.id) showTracks = false scheduleHide() } label: { HStack { Text(track.name).foregroundStyle(McColors.text) Spacer() - if track.id == player.currentAudioId { + if track.id == vlc.currentAudioId { Image(systemName: "checkmark").foregroundStyle(McColors.accent) } } @@ -343,7 +385,7 @@ struct PlayerScreen: View { Button("Sluiten") { showTracks = false } } } - .onAppear { player.refreshTracks() } + .onAppear { vlc.refreshTracks() } } } @@ -399,32 +441,51 @@ struct PlayerScreen: View { private func start() async { loading = true error = nil - if request.isLive { - let keys = request.needsClearKey ? (request.drm?.keys ?? []) : [] - await player.playLive( + if useClearKeyAV { + let keys = request.drm?.keys ?? [] + await av.play( url: request.url, fallback: request.fallbackUrl, format: request.format, clearKeys: keys ) - // ClearKey/DASH: proxy-warmup + init/media + eventuele retry. - let waitNs: UInt64 = request.needsClearKey ? 12_000_000_000 : 2_500_000_000 - try? await Task.sleep(nanoseconds: waitNs) - if request.needsClearKey && (player.hasError || !player.isPlaying) { - try? await Task.sleep(nanoseconds: 6_000_000_000) - if player.hasError || !player.isPlaying { - error = player.errorMessage - ?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig." - } + let maxWaitNs: UInt64 = 18_000_000_000 + let stepNs: UInt64 = 200_000_000 + var waited: UInt64 = 0 + while waited < maxWaitNs && !av.hasError { + if av.isPlaying { break } + try? await Task.sleep(nanoseconds: stepNs) + waited += stepNs + } + 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 { - 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() } } - try? await Task.sleep(nanoseconds: 400_000_000) + try? await Task.sleep(nanoseconds: 250_000_000) loading = false - if !player.hasError { - player.refreshTracks() + if !useClearKeyAV && !vlc.hasError { + 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 } - let pos = player.currentPositionMs() - let dur = player.durationMs() + let pos = positionMs ?? vlc.currentPositionMs() + let dur = durationMs ?? vlc.durationMs() guard pos > 0 else { return } try? await app.api.postProgress( mediaType: request.mediaType, @@ -492,13 +553,25 @@ struct PlayerScreen: View { let url = FileManager.default.temporaryDirectory .appendingPathComponent("vmc-sub-\(hit.fileId).\(ext)") try data.write(to: url, options: .atomic) - player.addExternalSubtitle(url: url) + vlc.addExternalSubtitle(url: url) showTracks = false scheduleHide() } catch { 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 { diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index 3d5afa6..332419a 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -216,7 +216,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg stateText = "ClearKey laden…" // Async: blokkeer de main thread niet (watchdog-crash). // 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" clearKeyProxyActive = true } catch { @@ -820,7 +821,6 @@ struct VLCVideoView: UIViewRepresentable { } func updateUIView(_ uiView: UIView, context: Context) { - // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout). - controller.attach(to: uiView) + // Geen herhaalde drawable-bind bij SwiftUI ticks (breekt video-vout / audio-only). } } diff --git a/apps/ios/VMC/UI/BrowseScreen.swift b/apps/ios/VMC/UI/BrowseScreen.swift index d86bede..a26a494 100644 --- a/apps/ios/VMC/UI/BrowseScreen.swift +++ b/apps/ios/VMC/UI/BrowseScreen.swift @@ -96,6 +96,9 @@ struct BrowseScreen: View { .toolbarColorScheme(.dark, for: .navigationBar) .task { 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() } .refreshable { await reload(reset: true) } @@ -271,6 +274,7 @@ struct BrowseScreen: View { if reset { loading = true error = nil + loadingMore = false } let id = catalogId.isEmpty ? defaultCatalog : catalogId let sortParam: String? = showSortTools ? sort.rawValue : nil