From 276467f09b284130fcd0042df882f6c084ae8285 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 16 Sep 2026 00:52:56 +0200 Subject: [PATCH] Add Google Cast and VMC phone-to-TV remote, plus TV guide focus fixes. --- apps/android-tv/app-mobile/build.gradle.kts | 8 +- .../app-mobile/src/main/AndroidManifest.xml | 3 + .../java/nl/vonas/mediacluster/tv/McApp.kt | 8 +- .../tv/cast/CastOptionsProvider.kt | 18 + .../mediacluster/tv/cast/CastTargetPicker.kt | 281 ++++++++++++++ .../mediacluster/tv/cast/GoogleCastHelper.kt | 109 ++++++ .../vonas/mediacluster/tv/data/ApiClient.kt | 79 ++++ .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 38 ++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 23 ++ .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 45 ++- .../mediacluster/tv/ui/VmcRemoteScreen.kt | 195 ++++++++++ apps/android-tv/app/build.gradle.kts | 4 +- .../tv/cast/BindCastPlayerRemote.kt | 71 ++++ .../mediacluster/tv/cast/CastCommandBus.kt | 33 ++ .../mediacluster/tv/cast/CastReceiverHost.kt | 76 ++++ .../vonas/mediacluster/tv/data/ApiClient.kt | 63 ++++ .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 10 + .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 29 ++ .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 10 + .../vonas/mediacluster/tv/ui/TvGuideScreen.kt | 347 ++++++++++++++---- apps/master-api/src/viewer/castRemote.ts | 249 +++++++++++++ apps/master-api/src/viewer/routes.ts | 65 ++++ 22 files changed, 1686 insertions(+), 78 deletions(-) create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastOptionsProvider.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/GoogleCastHelper.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/VmcRemoteScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/BindCastPlayerRemote.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastCommandBus.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastReceiverHost.kt create mode 100644 apps/master-api/src/viewer/castRemote.ts diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index 90b5019..c236a10 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 21 - versionName = "1.0.20" + versionCode = 22 + versionName = "1.0.21" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") @@ -86,6 +86,10 @@ dependencies { implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0") + // Google Cast (Chromecast / Google TV) + implementation("com.google.android.gms:play-services-cast-framework:21.5.0") + implementation("androidx.mediarouter:mediarouter:1.7.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("androidx.datastore:datastore-preferences:1.1.1") diff --git a/apps/android-tv/app-mobile/src/main/AndroidManifest.xml b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml index 2512769..7707988 100644 --- a/apps/android-tv/app-mobile/src/main/AndroidManifest.xml +++ b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml @@ -18,6 +18,9 @@ android:supportsRtl="true" android:theme="@style/Theme.MediaClusterTv" android:usesCleartextTraffic="false"> + ? = null +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt new file mode 100644 index 0000000..e1bd4de --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/CastTargetPicker.kt @@ -0,0 +1,281 @@ +package nl.vonas.mediacluster.tv.cast + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.mediarouter.media.MediaControlIntent +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import com.google.android.gms.cast.CastMediaControlIntent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.CastReceiver +import nl.vonas.mediacluster.tv.ui.theme.McColors +import org.json.JSONObject + +sealed class CastTarget { + data class Chromecast(val routeId: String, val name: String) : CastTarget() + data class VmcTv(val device: CastReceiver) : CastTarget() +} + +data class CastPlayRequest( + val mediaFileId: String? = null, + val streamId: Int? = null, + val mediaType: String = "MOVIE", + val movieId: String? = null, + val episodeId: String? = null, + val seriesId: String? = null, + val startMs: Long = 0L, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val streamUrl: String? = null, + val live: Boolean = false, +) + +@Composable +fun CastTargetPickerDialog( + api: ApiClient, + play: CastPlayRequest, + onDismiss: () -> Unit, + onVmcRemoteStarted: (deviceId: String, deviceName: String) -> Unit, + onChromecastStarted: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var chromecasts by remember { mutableStateOf>(emptyList()) } + var vmcTvs by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + GoogleCastHelper.init(context) + val router = MediaRouter.getInstance(context) + val selector = MediaRouteSelector.Builder() + .addControlCategory( + CastMediaControlIntent.categoryForCast( + CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID, + ), + ) + .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) + .build() + fun refresh() { + chromecasts = router.routes + .filter { it.isEnabled && !it.isDefault && it.matchesSelector(selector) } + .map { CastTarget.Chromecast(it.id, it.name) } + .distinctBy { it.routeId } + } + val callback = object : MediaRouter.Callback() { + override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + } + router.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + refresh() + onDispose { + router.removeCallback(callback) + } + } + + LaunchedEffect(Unit) { + loading = true + vmcTvs = runCatching { api.castListReceivers() }.getOrDefault(emptyList()) + loading = false + } + + fun selectChromecast(target: CastTarget.Chromecast) { + if (busy) return + busy = true + error = null + scope.launch { + try { + val router = MediaRouter.getInstance(context) + val route = router.routes.firstOrNull { it.id == target.routeId } + ?: error("Apparaat niet gevonden") + router.selectRoute(route) + val session = withTimeoutOrNull(12_000) { + GoogleCastHelper.awaitSession(context) + } + if (session == null) error("Cast-verbinding mislukt") + val url = play.streamUrl + ?: resolveStreamUrl(api, play) + ?: error("Geen stream-URL") + val ok = GoogleCastHelper.loadMedia( + context, + CastMediaPayload( + streamUrl = url, + title = play.title, + subtitle = play.subtitle, + imageUrl = play.imageUrl, + startMs = play.startMs, + live = play.live, + ), + ) + if (!ok) error("Kon media niet laden op Cast") + onChromecastStarted() + onDismiss() + } catch (e: Exception) { + error = e.message ?: "Cast mislukt" + busy = false + } + } + } + + fun selectVmc(device: CastReceiver) { + if (busy) return + busy = true + error = null + scope.launch { + try { + val payload = JSONObject() + when { + play.live && play.streamId != null -> { + payload.put("streamId", play.streamId) + api.castSendCommand(device.id, "play_live", payload) + } + !play.mediaFileId.isNullOrBlank() -> { + payload + .put("mediaFileId", play.mediaFileId) + .put("mediaType", play.mediaType) + .put("movieId", play.movieId) + .put("episodeId", play.episodeId) + .put("seriesId", play.seriesId) + .put("startMs", play.startMs) + .put("title", play.title) + api.castSendCommand(device.id, "play_vod", payload) + } + else -> error("Niets om af te spelen") + } + onVmcRemoteStarted(device.id, device.name) + onDismiss() + } catch (e: Exception) { + error = e.message ?: "Kon niet naar VMC TV sturen" + busy = false + } + } + } + + Dialog(onDismissRequest = { if (!busy) onDismiss() }) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF1A1A1A)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Casten / afspelen op TV", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text( + "Kies Chromecast/Google TV of een VMC Android TV-app.", + color = McColors.TextDim, + fontSize = 13.sp, + ) + if (loading) { + Text("Zoeken…", color = Color(0xFF888888), fontSize = 13.sp) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (chromecasts.isNotEmpty()) { + item { + Text("Chromecast / Google TV", color = Color(0xFF888888), fontSize = 12.sp) + } + items(chromecasts, key = { it.routeId }) { c -> + CastRow(name = c.name, hint = "Google Cast", enabled = !busy) { + selectChromecast(c) + } + } + } + item { + Spacer(Modifier.height(4.dp)) + Text("VMC Android TV", color = Color(0xFF888888), fontSize = 12.sp) + } + if (vmcTvs.isEmpty()) { + item { + Text( + "Geen online VMC TV gevonden. Open de VMC-app op je Shield/TV.", + color = McColors.TextDim, + fontSize = 13.sp, + ) + } + } else { + items(vmcTvs, key = { it.id }) { d -> + CastRow(name = d.name, hint = "VMC remote", enabled = !busy) { + selectVmc(d) + } + } + } + } + if (error != null) { + Text(error!!, color = McColors.Danger, fontSize = 13.sp) + } + Text( + "Annuleren", + color = Color.White, + fontSize = 14.sp, + modifier = Modifier + .align(Alignment.End) + .clickable(enabled = !busy) { onDismiss() } + .padding(8.dp), + ) + } + } +} + +@Composable +private fun CastRow(name: String, hint: String, enabled: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF252525)) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(name, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text(hint, color = Color(0xFF888888), fontSize = 12.sp) + } + Text("→", color = McColors.Brand, fontSize = 18.sp) + } +} + +private suspend fun resolveStreamUrl(api: ApiClient, play: CastPlayRequest): String? = + withContext(Dispatchers.IO) { + when { + play.live && play.streamId != null -> api.iptvPlayUrl(play.streamId).streamUrl + !play.mediaFileId.isNullOrBlank() -> api.play(play.mediaFileId).streamUrl + else -> null + } + } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/GoogleCastHelper.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/GoogleCastHelper.kt new file mode 100644 index 0000000..e0ebb2b --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/cast/GoogleCastHelper.kt @@ -0,0 +1,109 @@ +package nl.vonas.mediacluster.tv.cast + +import android.content.Context +import android.net.Uri +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.MediaMetadata +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.common.images.WebImage +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +data class CastMediaPayload( + val streamUrl: String, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val startMs: Long = 0L, + val live: Boolean = false, +) + +object GoogleCastHelper { + fun init(context: Context) { + runCatching { CastContext.getSharedInstance(context.applicationContext) } + } + + fun castContext(context: Context): CastContext? = + runCatching { CastContext.getSharedInstance(context.applicationContext) }.getOrNull() + + fun currentSession(context: Context): CastSession? = + castContext(context)?.sessionManager?.currentCastSession + + fun isConnected(context: Context): Boolean = + currentSession(context)?.isConnected == true + + fun loadMedia(context: Context, payload: CastMediaPayload): Boolean { + val client = currentSession(context)?.remoteMediaClient ?: return false + val metaType = if (payload.live) MediaMetadata.MEDIA_TYPE_GENERIC else MediaMetadata.MEDIA_TYPE_MOVIE + val meta = MediaMetadata(metaType).apply { + putString(MediaMetadata.KEY_TITLE, payload.title.ifBlank { "VMC" }) + payload.subtitle?.takeIf { it.isNotBlank() }?.let { + putString(MediaMetadata.KEY_SUBTITLE, it) + } + payload.imageUrl?.takeIf { it.startsWith("http") }?.let { + addImage(WebImage(Uri.parse(it))) + } + } + val contentType = guessContentType(payload.streamUrl, payload.live) + val streamType = if (payload.live) MediaInfo.STREAM_TYPE_LIVE else MediaInfo.STREAM_TYPE_BUFFERED + val info = MediaInfo.Builder(payload.streamUrl) + .setStreamType(streamType) + .setContentType(contentType) + .setMetadata(meta) + .build() + val request = MediaLoadRequestData.Builder() + .setMediaInfo(info) + .setAutoplay(true) + .setCurrentTime(payload.startMs.coerceAtLeast(0L)) + .build() + client.load(request) + return true + } + + suspend fun awaitSession(context: Context, timeoutMs: Long = 12_000L): CastSession? { + val existing = currentSession(context) + if (existing?.isConnected == true) return existing + val sm = castContext(context)?.sessionManager ?: return null + return suspendCancellableCoroutine { cont -> + val listener = object : SessionManagerListener { + override fun onSessionStarted(session: CastSession, sessionId: String) { + sm.removeSessionManagerListener(this, CastSession::class.java) + if (cont.isActive) cont.resume(session) + } + + override fun onSessionStartFailed(session: CastSession, error: Int) { + sm.removeSessionManagerListener(this, CastSession::class.java) + if (cont.isActive) cont.resume(null) + } + + override fun onSessionEnded(session: CastSession, error: Int) = Unit + override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) = Unit + override fun onSessionResumeFailed(session: CastSession, error: Int) = Unit + override fun onSessionSuspended(session: CastSession, reason: Int) = Unit + override fun onSessionStarting(session: CastSession) = Unit + override fun onSessionEnding(session: CastSession) = Unit + override fun onSessionResuming(session: CastSession, sessionId: String) = Unit + } + sm.addSessionManagerListener(listener, CastSession::class.java) + cont.invokeOnCancellation { + sm.removeSessionManagerListener(listener, CastSession::class.java) + } + // Timeout via caller coroutine withTimeoutOrNull wrapping this. + } + } + + private fun guessContentType(url: String, live: Boolean): String { + val u = url.lowercase() + return when { + u.contains(".m3u8") || u.contains("format=hls") || u.contains("/hls/") -> + "application/x-mpegURL" + u.contains(".mpd") -> "application/dash+xml" + u.endsWith(".mp4") || u.contains(".mp4?") -> "video/mp4" + live -> "video/mp2t" + else -> "video/mp4" + } + } +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 004f7e1..6858a63 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -602,6 +602,65 @@ class ApiClient( return result } + suspend fun castListReceivers(): List = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/cast/receivers").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("receivers") ?: JSONArray() + buildList { + for (i in 0 until arr.length()) { + val r = arr.getJSONObject(i) + add( + CastReceiver( + id = r.getString("id"), + name = r.optString("name").ifBlank { "Android TV" }, + platform = r.optClean("platform"), + online = r.optBoolean("online", true), + ) + ) + } + } + } + } + + suspend fun castSendCommand( + targetDeviceId: String, + type: String, + payload: JSONObject = JSONObject(), + ) = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("targetDeviceId", targetDeviceId) + .put("type", type) + .put("payload", payload) + .toString() + .toRequestBody(jsonMedia) + val req = authed("$baseUrl/api/v1/client/cast/command").post(body).build() + execute(req) + Unit + } + + suspend fun castGetStatus(deviceId: String): CastDeviceStatus = withContext(Dispatchers.IO) { + val encoded = java.net.URLEncoder.encode(deviceId, "UTF-8") + val req = authed("$baseUrl/api/v1/client/cast/status/$encoded").get().build() + parseObject(execute(req)) { o -> + val s = o.optJSONObject("status") + CastDeviceStatus( + deviceId = o.optString("deviceId"), + name = o.optString("name"), + online = o.optBoolean("online"), + state = s?.optString("state") ?: "idle", + title = s?.optClean("title"), + positionMs = s?.optLong("positionMs") ?: 0L, + durationMs = s?.optLong("durationMs") ?: 0L, + mediaFileId = s?.optClean("mediaFileId"), + streamId = when { + s == null || !s.has("streamId") || s.isNull("streamId") -> null + else -> s.optInt("streamId").takeIf { it > 0 } + }, + mediaType = s?.optClean("mediaType"), + ) + } + } + private fun JSONObject.toEpgProgram(): EpgProgram? { val title = optClean("title") ?: return null return EpgProgram( @@ -701,6 +760,26 @@ data class PlayResult( val embeddedTracks: List = emptyList(), ) +data class CastReceiver( + val id: String, + val name: String, + val platform: String?, + val online: Boolean, +) + +data class CastDeviceStatus( + val deviceId: String, + val name: String, + val online: Boolean, + val state: String, + val title: String?, + val positionMs: Long, + val durationMs: Long, + val mediaFileId: String?, + val streamId: Int?, + val mediaType: String?, +) + data class EmbeddedTrackInfo( val type: String, val language: String? = null, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index e2e2491..1c90578 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -69,6 +69,8 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.cast.CastPlayRequest +import nl.vonas.mediacluster.tv.cast.CastTargetPickerDialog import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel @@ -95,6 +97,7 @@ fun LiveTvPlayerScreen( onCategoryChange: (String) -> Unit, onStreamChange: (Int) -> Unit, onBack: () -> Unit, + onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val player = remember { buildLiveTvPlayer(context) } @@ -108,6 +111,8 @@ fun LiveTvPlayerScreen( var error by remember { mutableStateOf(null) } var channelName by remember { mutableStateOf("") } var logoUrl by remember { mutableStateOf(null) } + var streamUrl by remember { mutableStateOf(null) } + var showCastPicker by remember { mutableStateOf(false) } var epg by remember { mutableStateOf(null) } var infoOverlay by remember { mutableStateOf(true) } var channelMenuOpen by remember { mutableStateOf(false) } @@ -141,6 +146,7 @@ fun LiveTvPlayerScreen( val play = api.iptvPlayUrl(streamIdToPlay) channelName = play.name logoUrl = play.logoUrl + streamUrl = play.streamUrl val tsUrl = when { play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl @@ -464,6 +470,15 @@ fun LiveTvPlayerScreen( ) { Text("Zenders", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) } + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0x55FFFFFF)) + .clickable { showCastPicker = true } + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text("Cast", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } } Row( @@ -581,6 +596,29 @@ fun LiveTvPlayerScreen( } } } + + if (showCastPicker) { + CastTargetPickerDialog( + api = api, + play = CastPlayRequest( + streamId = currentStreamId, + title = channelName.ifBlank { "Live TV" }, + imageUrl = logoUrl, + streamUrl = streamUrl, + live = true, + ), + onDismiss = { showCastPicker = false }, + onVmcRemoteStarted = { id, name -> + showCastPicker = false + player.pause() + onOpenVmcRemote(id, name) + }, + onChromecastStarted = { + showCastPicker = false + player.pause() + }, + ) + } } @OptIn(ExperimentalTvMaterial3Api::class) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index a29b890..e0d0b52 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -382,6 +382,9 @@ fun McNav( seriesId = dash(entry.arguments?.getString("seriesId")), startMs = entry.arguments?.getLong("startMs") ?: 0L, onBack = { nav.popBackStack() }, + onOpenVmcRemote = { deviceId, deviceName -> + nav.navigate("vmc_remote/$deviceId/${android.net.Uri.encode(deviceName)}") + }, ) } composable( @@ -399,6 +402,26 @@ fun McNav( onCategoryChange = { tvCategoryId = it }, onStreamChange = { tvLastStreamId = it }, onBack = { nav.popBackStack() }, + onOpenVmcRemote = { deviceId, deviceName -> + nav.navigate("vmc_remote/$deviceId/${android.net.Uri.encode(deviceName)}") + }, + ) + } + composable( + "vmc_remote/{deviceId}/{deviceName}", + arguments = listOf( + navArgument("deviceId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { entry -> + val deviceId = entry.arguments?.getString("deviceId").orEmpty() + val deviceName = android.net.Uri.decode(entry.arguments?.getString("deviceName").orEmpty()) + .ifBlank { "Android TV" } + VmcRemoteScreen( + api = api, + deviceId = deviceId, + deviceName = deviceName, + onBack = { nav.popBackStack() }, ) } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index f877e3b..9d306be 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -70,6 +70,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.cast.CastPlayRequest +import nl.vonas.mediacluster.tv.cast.CastTargetPickerDialog import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.playback.PlayerTrack @@ -93,6 +95,7 @@ fun PlayerScreen( seriesId: String?, startMs: Long, onBack: () -> Unit, + onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val player = remember { buildTvPlayer(context) } @@ -107,6 +110,8 @@ fun PlayerScreen( var playing by remember { mutableStateOf(true) } var positionMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) } + var streamUrl by remember { mutableStateOf(null) } + var showCastPicker by remember { mutableStateOf(false) } var audioTracks by remember { mutableStateOf>(emptyList()) } var textTracks by remember { mutableStateOf>(emptyList()) } var embeddedHints by remember { mutableStateOf>(emptyList()) } @@ -213,6 +218,7 @@ fun PlayerScreen( try { val play = api.play(mediaFileId) embeddedHints = play.embeddedTracks + streamUrl = play.streamUrl player.volume = 1f player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.prepare() @@ -466,14 +472,20 @@ fun PlayerScreen( ), ) - // Terug linksboven + // Terug linksboven + cast rechtsboven Row( Modifier .align(Alignment.TopStart) + .fillMaxWidth() .padding(28.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { NetflixTextButton("← Terug", onClick = onBack) + NetflixTextButton("Cast") { + showCastPicker = true + showControls() + } } // Grote play/pause in het midden als gepauzeerd @@ -545,6 +557,10 @@ fun PlayerScreen( showControls() } Spacer(Modifier.weight(1f)) + NetflixTextButton("Cast") { + showCastPicker = true + showControls() + } NetflixTextButton("Audio") { menu = "audio" showControls() @@ -558,6 +574,33 @@ fun PlayerScreen( } } + if (showCastPicker) { + CastTargetPickerDialog( + api = api, + play = CastPlayRequest( + mediaFileId = mediaFileId, + mediaType = mediaType, + movieId = movieId, + episodeId = episodeId, + seriesId = seriesId, + startMs = positionMs.takeIf { it > 0 } ?: startMs, + title = "VMC", + streamUrl = streamUrl, + live = false, + ), + onDismiss = { showCastPicker = false }, + onVmcRemoteStarted = { id, name -> + showCastPicker = false + player.pause() + onOpenVmcRemote(id, name) + }, + onChromecastStarted = { + showCastPicker = false + player.pause() + }, + ) + } + if (menu != null) { val isAudio = menu == "audio" val isOs = menu == "os" diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/VmcRemoteScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/VmcRemoteScreen.kt new file mode 100644 index 0000000..a3b50c7 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/VmcRemoteScreen.kt @@ -0,0 +1,195 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.CastDeviceStatus +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors +import org.json.JSONObject + +@Composable +fun VmcRemoteScreen( + api: ApiClient, + deviceId: String, + deviceName: String, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var status by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var scrubbing by remember { mutableStateOf(false) } + var scrubPos by remember { mutableFloatStateOf(0f) } + + BackHandler(onBack = onBack) + + LaunchedEffect(deviceId) { + while (isActive) { + runCatching { api.castGetStatus(deviceId) } + .onSuccess { + status = it + error = null + if (!scrubbing) scrubPos = it.positionMs.toFloat() + } + .onFailure { error = it.message } + delay(1_500) + } + } + + fun send(type: String, payload: JSONObject = JSONObject()) { + scope.launch { + runCatching { api.castSendCommand(deviceId, type, payload) } + .onFailure { error = it.message } + } + } + + val st = status + val playing = st?.state == "playing" + val duration = (st?.durationMs ?: 0L).coerceAtLeast(0L) + val title = st?.title?.takeIf { it.isNotBlank() } ?: "Afspelen op TV" + + Column( + Modifier + .fillMaxSize() + .background(Color(0xFF0B0B0B)) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("VMC remote", color = McColors.TextDim, fontSize = 13.sp) + Text(deviceName, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) + Text( + if (st?.online == false) "TV offline" else title, + color = if (st?.online == false) McColors.Danger else Color(0xFFCCCCCC), + fontSize = 16.sp, + ) + Text( + when (st?.state) { + "playing" -> "Speelt af" + "paused" -> "Gepauzeerd" + "buffering" -> "Bufferen…" + else -> "Idle" + }, + color = McColors.Brand, + fontSize = 13.sp, + ) + + Spacer(Modifier.height(12.dp)) + + if (duration > 0) { + Slider( + value = scrubPos.coerceIn(0f, duration.toFloat().coerceAtLeast(1f)), + onValueChange = { + scrubbing = true + scrubPos = it + }, + onValueChangeFinished = { + scrubbing = false + send("seek", JSONObject().put("positionMs", scrubPos.toLong())) + }, + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = McColors.Brand, + inactiveTrackColor = Color(0xFF333333), + ), + modifier = Modifier.fillMaxWidth(), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatMs(scrubPos.toLong()), color = McColors.TextDim, fontSize = 12.sp) + Text(formatMs(duration), color = McColors.TextDim, fontSize = 12.sp) + } + } + + Spacer(Modifier.weight(1f)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteCircle("-10s") { send("skip", JSONObject().put("deltaMs", -10_000)) } + RemoteCircle(if (playing) "❚❚" else "▶", large = true) { + send(if (playing) "pause" else "resume") + } + RemoteCircle("+30s") { send("skip", JSONObject().put("deltaMs", 30_000)) } + } + + Spacer(Modifier.height(8.dp)) + McButton("Stop op TV", primary = false, onClick = { + send("stop") + onBack() + }) + McButton("Terug", primary = false, onClick = onBack) + + if (error != null) { + Text( + error!!, + color = McColors.Danger, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun RemoteCircle(label: String, large: Boolean = false, onClick: () -> Unit) { + Box( + Modifier + .size(if (large) 84.dp else 64.dp) + .clip(CircleShape) + .background(if (large) McColors.Brand else Color(0xFF222222)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = Color.White, + fontSize = if (large) 22.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +private fun formatMs(ms: Long): String { + val totalSec = (ms / 1000).coerceAtLeast(0) + val h = totalSec / 3600 + val m = (totalSec % 3600) / 60 + val s = totalSec % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) +} diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index bae0fa4..52a9c84 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 = 107 - versionName = "0.14.44" + versionCode = 109 + versionName = "0.14.46" 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/cast/BindCastPlayerRemote.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/BindCastPlayerRemote.kt new file mode 100644 index 0000000..ec2ff89 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/BindCastPlayerRemote.kt @@ -0,0 +1,71 @@ +package nl.vonas.mediacluster.tv.cast + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.media3.common.Player +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import nl.vonas.mediacluster.tv.data.ApiClient + +/** Luister naar telefoon-remote + publiceer status naar master. */ +@Composable +fun BindCastPlayerRemote( + api: ApiClient, + player: Player, + title: String?, + mediaFileId: String? = null, + streamId: Int? = null, + mediaType: String? = null, + onStop: () -> Unit, +) { + LaunchedEffect(player) { + CastCommandBus.commands.collect { cmd -> + when (cmd) { + CastPlayerCommand.Pause -> player.pause() + CastPlayerCommand.Resume -> player.play() + CastPlayerCommand.Stop -> { + player.stop() + onStop() + } + is CastPlayerCommand.Seek -> player.seekTo(cmd.positionMs.coerceAtLeast(0L)) + is CastPlayerCommand.Skip -> { + val next = (player.currentPosition + cmd.deltaMs).coerceAtLeast(0L) + val dur = player.duration.takeIf { it > 0 } ?: Long.MAX_VALUE + player.seekTo(next.coerceAtMost(dur)) + } + } + } + } + + LaunchedEffect(player, title, mediaFileId, streamId) { + while (isActive) { + val state = when { + player.playbackState == Player.STATE_BUFFERING -> "buffering" + player.isPlaying -> "playing" + player.playbackState == Player.STATE_READY -> "paused" + else -> "idle" + } + runCatching { + api.castPublishStatus( + state = state, + title = title, + positionMs = player.currentPosition.coerceAtLeast(0L), + durationMs = player.duration.takeIf { it > 0 } ?: 0L, + mediaFileId = mediaFileId, + streamId = streamId, + mediaType = mediaType, + ) + } + delay(2_500) + } + } + + DisposableEffect(Unit) { + onDispose { + runCatching { + // best-effort idle; may fail if scope cancelled + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastCommandBus.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastCommandBus.kt new file mode 100644 index 0000000..5ee09c6 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastCommandBus.kt @@ -0,0 +1,33 @@ +package nl.vonas.mediacluster.tv.cast + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.json.JSONObject + +/** Commando's van de telefoon naar de actieve TV-speler. */ +sealed class CastPlayerCommand { + data object Pause : CastPlayerCommand() + data object Resume : CastPlayerCommand() + data object Stop : CastPlayerCommand() + data class Seek(val positionMs: Long) : CastPlayerCommand() + data class Skip(val deltaMs: Long) : CastPlayerCommand() +} + +object CastCommandBus { + private val _commands = MutableSharedFlow(extraBufferCapacity = 8) + val commands: SharedFlow = _commands.asSharedFlow() + + fun emit(cmd: CastPlayerCommand) { + _commands.tryEmit(cmd) + } + + fun fromRemote(type: String, payload: JSONObject): CastPlayerCommand? = when (type) { + "pause" -> CastPlayerCommand.Pause + "resume" -> CastPlayerCommand.Resume + "stop" -> CastPlayerCommand.Stop + "seek" -> CastPlayerCommand.Seek(payload.optLong("positionMs", 0L)) + "skip" -> CastPlayerCommand.Skip(payload.optLong("deltaMs", 0L)) + else -> null + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastReceiverHost.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastReceiverHost.kt new file mode 100644 index 0000000..b8918e1 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/cast/CastReceiverHost.kt @@ -0,0 +1,76 @@ +package nl.vonas.mediacluster.tv.cast + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import nl.vonas.mediacluster.tv.data.ApiClient +import org.json.JSONObject + +/** + * Heartbeat + command-poll zodat de telefoon deze TV als cast-target ziet + * en play/pause/seek kan sturen. + */ +@Composable +fun CastReceiverHost( + api: ApiClient, + enabled: Boolean, + onPlayVod: ( + mediaFileId: String, + type: String, + movieId: String?, + episodeId: String?, + seriesId: String?, + startMs: Long, + title: String?, + ) -> Unit, + onPlayLive: (streamId: Int) -> Unit, +) { + LaunchedEffect(enabled) { + if (!enabled) return@LaunchedEffect + val deviceName = Build.MODEL?.takeIf { it.isNotBlank() } ?: "Android TV" + while (isActive) { + runCatching { + api.castPresence(displayName = deviceName, platform = "android_tv") + } + delay(15_000) + } + } + + LaunchedEffect(enabled) { + if (!enabled) return@LaunchedEffect + while (isActive) { + val commands = runCatching { api.castPollCommands() }.getOrDefault(emptyList()) + for (cmd in commands) { + when (cmd.type) { + "play_vod" -> { + val mediaFileId = cmd.payload.optString("mediaFileId").trim() + if (mediaFileId.isNotEmpty()) { + onPlayVod( + mediaFileId, + cmd.payload.optString("mediaType").ifBlank { "MOVIE" }, + cmd.payload.optString("movieId").takeIf { it.isNotBlank() && it != "-" }, + cmd.payload.optString("episodeId").takeIf { it.isNotBlank() && it != "-" }, + cmd.payload.optString("seriesId").takeIf { it.isNotBlank() && it != "-" }, + cmd.payload.optLong("startMs", 0L), + cmd.payload.optString("title").takeIf { it.isNotBlank() }, + ) + } + } + "play_live" -> { + val streamId = cmd.payload.optInt("streamId", -1) + if (streamId > 0) onPlayLive(streamId) + } + else -> { + CastCommandBus.fromRemote(cmd.type, cmd.payload)?.let(CastCommandBus::emit) + } + } + } + delay(1_500) + } + } +} + +fun JSONObject.optPayloadString(key: String): String? = + optString(key).takeIf { it.isNotBlank() && it != "null" } 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 170edeb..888f8c8 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 @@ -653,6 +653,62 @@ class ApiClient( return result } + suspend fun castPresence(displayName: String? = null, platform: String = "android_tv") = + withContext(Dispatchers.IO) { + val body = JSONObject() + .put("displayName", displayName) + .put("platform", platform) + .toString() + .toRequestBody(jsonMedia) + val req = authed("$baseUrl/api/v1/client/cast/presence").post(body).build() + execute(req) + Unit + } + + suspend fun castPollCommands(): List = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/cast/poll").get().build() + parseObject(execute(req)) { o -> + val arr = o.optJSONArray("commands") ?: JSONArray() + buildList { + for (i in 0 until arr.length()) { + val c = arr.getJSONObject(i) + add( + CastRemoteCommand( + id = c.optString("id"), + type = c.optString("type"), + payload = c.optJSONObject("payload") ?: JSONObject(), + fromDeviceId = c.optClean("fromDeviceId"), + ) + ) + } + } + } + } + + suspend fun castPublishStatus( + state: String, + title: String? = null, + positionMs: Long = 0, + durationMs: Long = 0, + mediaFileId: String? = null, + streamId: Int? = null, + mediaType: String? = null, + ) = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("state", state) + .put("title", title) + .put("positionMs", positionMs) + .put("durationMs", durationMs) + .put("mediaFileId", mediaFileId) + .put("streamId", streamId) + .put("mediaType", mediaType) + .toString() + .toRequestBody(jsonMedia) + val req = authed("$baseUrl/api/v1/client/cast/status").post(body).build() + execute(req) + Unit + } + private fun JSONObject.toEpgProgram(): EpgProgram? { val title = optClean("title") ?: return null return EpgProgram( @@ -779,6 +835,13 @@ data class PlayResult( val embeddedTracks: List = emptyList(), ) +data class CastRemoteCommand( + val id: String, + val type: String, + val payload: org.json.JSONObject, + val fromDeviceId: String?, +) + data class EmbeddedTrackInfo( val type: String, val language: String? = null, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 5e0b52c..c1cff87 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -69,6 +69,7 @@ import androidx.tv.material3.Text import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel @@ -128,6 +129,15 @@ fun LiveTvPlayerScreen( val menuListState = rememberLazyListState() val menuChannelFocus = remember { mutableStateMapOf() } + BindCastPlayerRemote( + api = api, + player = player, + title = channelName.ifBlank { null }, + streamId = currentStreamId, + mediaType = "LIVE", + onStop = onBack, + ) + fun startStream(url: String, format: String) { player.setMediaItem(liveMediaItem(url, format)) player.prepare() 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 ed39549..55459e5 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 @@ -86,6 +86,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.BuildConfig +import nl.vonas.mediacluster.tv.cast.CastReceiverHost import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.HomePayload import nl.vonas.mediacluster.tv.data.MediaItem @@ -137,6 +138,34 @@ fun McNav() { val seriesGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() val watchlistGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + fun navigatePlayVod( + mediaFileId: String, + type: String, + movieId: String?, + episodeId: String?, + seriesId: String?, + startMs: Long, + ) { + val m = movieId?.ifBlank { null } ?: "-" + val e = episodeId?.ifBlank { null } ?: "-" + val s = seriesId?.ifBlank { null } ?: "-" + nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs") + } + + CastReceiverHost( + api = api, + enabled = ready && authed && hasProfile, + onPlayVod = { mediaFileId, type, movieId, episodeId, seriesId, startMs, _ -> + navigatePlayVod(mediaFileId, type, movieId, episodeId, seriesId, startMs) + }, + onPlayLive = { streamId -> + tvLastStreamId = streamId + hubTabName = MainTab.Tv.name + nav.navigate("live/$streamId") { + launchSingleTop = true + } + }, + ) LaunchedEffect(Unit) { val base = store.apiBase.first() val token = store.refreshToken.first() diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 462b230..6e8af39 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.playback.PlayerTrack @@ -123,6 +124,15 @@ fun PlayerScreen( var nextCountdown by remember { mutableIntStateOf(10) } var nextOverlayConsumed by remember { mutableStateOf(false) } + BindCastPlayerRemote( + api = api, + player = player, + title = null, + mediaFileId = mediaFileId, + mediaType = mediaType, + onStop = onBack, + ) + fun showControls() { controls = true } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt index f627697..9293db7 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvGuideScreen.kt @@ -2,8 +2,11 @@ package nl.vonas.mediacluster.tv.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,6 +28,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf @@ -32,9 +36,11 @@ 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.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.Color @@ -44,8 +50,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.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 @@ -65,10 +73,35 @@ private val GuideBg = Color(0xFF0B0B0B) private val GuidePanel = Color(0xFF121212) private val GuideCell = Color(0xFF1A1A1A) private val GuideCellLive = Color(0xFF2A1515) +private val GuideCellFocused = Color(0xFF333333) +private val GuideCellFocusedLive = Color(0xFF4A1E1E) private val GuideDivider = Color(0xFF2A2A2A) private const val PX_PER_MIN = 4f private const val GUIDE_HOURS = 6 +/** + * EPG-cellen zijn vaak breder dan de viewport. De standaard BringIntoViewSpec + * kiest dan soms "align leading edge" → hele rij springt naar links. + * Wij scrollen alleen het minimum zodat de cel in beeld blijft. + */ +@OptIn(ExperimentalFoundationApi::class) +private val GuideBringIntoViewSpec = object : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float { + val trailing = offset + size + return when { + offset >= 0f && trailing <= containerSize -> 0f + offset < 0f && trailing > containerSize -> 0f + offset < 0f -> offset + trailing > containerSize -> trailing - containerSize + else -> 0f + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvGuideScreen( @@ -96,6 +129,8 @@ fun TvGuideScreen( val categoryRowState = rememberLazyListState() val channelListState = rememberLazyListState() val categoryFocus = remember { mutableStateMapOf() } + val detailPrimaryFocus = remember { FocusRequester() } + val firstGridFocus = remember { FocusRequester() } val displayCategories = remember(categories, favoriteIds) { listOf(IptvCategory(TV_FAV_CATEGORY_ID, "Favorieten", favoriteIds.size)) + categories @@ -112,6 +147,12 @@ fun TvGuideScreen( } } + fun requestCategoryFocus() { + val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id ?: return + categoryFocus[catId]?.let { runCatching { it.requestFocus() } } + focusInGrid = false + } + LaunchedEffect(Unit) { while (true) { delay(30_000) @@ -176,11 +217,7 @@ fun TvGuideScreen( } BackHandler(enabled = focusInGrid) { - val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id - if (catId != null) { - categoryFocus[catId]?.let { runCatching { it.requestFocus() } } - focusInGrid = false - } + requestCategoryFocus() } BackHandler(enabled = !focusInGrid) { onAtTopBack() } @@ -206,6 +243,7 @@ fun TvGuideScreen( selectedId = selectedCategoryId, listState = categoryRowState, categoryFocus = categoryFocus, + downFocus = firstGridFocus, onSelect = { onCategoryChange(it) focusInGrid = true @@ -227,14 +265,22 @@ fun TvGuideScreen( color = McColors.TextDim, fontSize = 12.sp, ) - GuideChip(label = "Nu", onClick = { jumpToNowToken++ }) + GuideChip( + label = "Nu", + downFocus = firstGridFocus, + onClick = { jumpToNowToken++ }, + ) } Spacer(Modifier.height(6.dp)) Box(Modifier.fillMaxWidth().height(1.dp).background(GuideDivider)) Spacer(Modifier.height(6.dp)) Row(Modifier.weight(1f).fillMaxWidth()) { - Column(Modifier.weight(0.62f).fillMaxHeight()) { + Column( + Modifier + .weight(0.62f) + .fillMaxHeight(), + ) { GuideTimeRuler(clock = clock) Spacer(Modifier.height(4.dp)) when { @@ -263,15 +309,20 @@ fun TvGuideScreen( verticalArrangement = Arrangement.spacedBy(3.dp), contentPadding = PaddingValues(bottom = 12.dp), ) { - items(visibleChannels, key = { it.streamId }) { ch -> + itemsIndexed(visibleChannels, key = { _, ch -> ch.streamId }) { rowIndex, ch -> GuideChannelRow( channel = ch, clock = clock, jumpToNowToken = jumpToNowToken, isFavorite = favoriteIds.contains(ch.streamId), + isFirstRow = rowIndex == 0, + entryFocusRequester = firstGridFocus.takeIf { rowIndex == 0 }, selectedProgram = selected ?.takeIf { it.channel.streamId == ch.streamId } ?.program, + rightExitFocus = detailPrimaryFocus, + canExitRight = selected != null, + categoryUpFocus = selectedCategoryId?.let { categoryFocus[it] }, onProgramFocused = { prog -> focusInGrid = true onContentFocused() @@ -302,9 +353,14 @@ fun TvGuideScreen( selection = selected, clock = clock, isFavorite = selected?.channel?.streamId?.let { favoriteIds.contains(it) } == true, + primaryFocusRequester = detailPrimaryFocus, onToggleFavorite = { selected?.channel?.streamId?.let(onToggleFavorite) }, onWatch = { selected?.channel?.streamId?.let(onPlayChannel) }, onOpenChannel = { selected?.channel?.streamId?.let(onOpenLiveTv) }, + onFocused = { + focusInGrid = true + onContentFocused() + }, modifier = Modifier .weight(0.38f) .fillMaxHeight() @@ -329,6 +385,7 @@ private fun GuideCategoryRow( selectedId: String?, listState: LazyListState, categoryFocus: MutableMap, + downFocus: FocusRequester, onSelect: (String) -> Unit, onFocused: () -> Unit, ) { @@ -340,20 +397,35 @@ private fun GuideCategoryRow( items(categories, key = { it.id }) { cat -> val active = cat.id == selectedId val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() } + var focused by remember { mutableStateOf(false) } val textColor by animateColorAsState( - if (active) Color.White else Color(0xFF888888), + when { + focused -> Color.White + active -> Color.White + else -> Color(0xFF888888) + }, label = "guideTab", ) Surface( onClick = { onSelect(cat.id) }, modifier = Modifier .focusRequester(fr) - .onFocusChanged { if (it.isFocused) onFocused() }, + .focusProperties { down = downFocus } + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocused() + }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = Color(0x18FFFFFF), + containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x33FFFFFF), contentColor = textColor, focusedContentColor = Color.White, ), @@ -366,16 +438,22 @@ private fun GuideCategoryRow( cat.name, color = Color.Unspecified, fontSize = 13.sp, - fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + fontWeight = if (active || focused) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, ) Spacer(Modifier.height(4.dp)) Box( Modifier .height(2.dp) - .width(if (active) 24.dp else 0.dp) + .width(if (active || focused) 24.dp else 0.dp) .clip(RoundedCornerShape(1.dp)) - .background(if (active) McColors.Brand else Color.Transparent), + .background( + when { + focused -> Color.White + active -> McColors.Brand + else -> Color.Transparent + }, + ), ) } } @@ -385,11 +463,24 @@ private fun GuideCategoryRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun GuideChip(label: String, onClick: () -> Unit) { +private fun GuideChip( + label: String, + downFocus: FocusRequester? = null, + onClick: () -> Unit, +) { Surface( onClick = onClick, + modifier = Modifier.then( + if (downFocus != null) Modifier.focusProperties { down = downFocus } else Modifier, + ), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), colors = ClickableSurfaceDefaults.colors( containerColor = Color(0xFF1A1A1A), focusedContainerColor = Color(0xFF2A2A2A), @@ -430,13 +521,19 @@ private fun GuideTimeRuler(clock: Long) { } } +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable private fun GuideChannelRow( channel: IptvGuideChannel, clock: Long, jumpToNowToken: Int, isFavorite: Boolean, + isFirstRow: Boolean, + entryFocusRequester: FocusRequester?, selectedProgram: EpgProgram?, + rightExitFocus: FocusRequester, + canExitRight: Boolean, + categoryUpFocus: FocusRequester?, onProgramFocused: (EpgProgram) -> Unit, onProgramClick: (EpgProgram) -> Unit, ) { @@ -451,14 +548,30 @@ private fun GuideChannelRow( raw.filter { overlapsWindow(it, windowStart, windowEnd) } .sortedBy { parseMs(it.start) ?: 0L } } + val entryIndex = remember(programs, clock) { + programs.indexOfFirst { isProgramLive(it, clock) }.takeIf { it >= 0 } ?: 0 + } - LaunchedEffect(jumpToNowToken, programs) { + // Eenmalig naar live; niet opnieuw bij clock/program-recompose (dat veroorzaakte jumps). + var didInitialScroll by remember(channel.streamId) { mutableStateOf(false) } + LaunchedEffect(channel.streamId, programs) { + if (didInitialScroll || programs.isEmpty()) return@LaunchedEffect + val liveIdx = programs.indexOfFirst { isProgramLive(it, clock) } + if (liveIdx >= 0) { + rowState.scrollToItem(liveIdx) + didInitialScroll = true + } else { + didInitialScroll = true + } + } + LaunchedEffect(jumpToNowToken) { + if (jumpToNowToken == 0) return@LaunchedEffect val liveIdx = programs.indexOfFirst { isProgramLive(it, clock) } if (liveIdx >= 0) rowState.animateScrollToItem(liveIdx) } Row( - Modifier.fillMaxWidth().height(52.dp), + Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( @@ -509,46 +622,60 @@ private fun GuideChannelRow( } } Spacer(Modifier.width(6.dp)) - LazyRow( - state = rowState, - modifier = Modifier.weight(1f).fillMaxHeight(), - horizontalArrangement = Arrangement.spacedBy(3.dp), - ) { - if (programs.isEmpty()) { - item { - GuideProgramCell( - title = "Geen EPG", - subtitle = null, - width = 160.dp, - live = false, - selected = false, - onFocused = {}, - onClick = {}, - ) - } - } else { - itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { _, prog -> - val live = isProgramLive(prog, clock) - GuideProgramCell( - title = prog.title, - subtitle = listOfNotNull( - prog.episode?.takeUnless { it.equals("null", true) }, - formatShortTime(prog.start), - ).joinToString(" · ").ifBlank { null }, - width = programWidthDp(prog, windowStart, windowEnd), - live = live, - selected = selectedProgram?.start == prog.start && - selectedProgram?.title == prog.title, - onFocused = { onProgramFocused(prog) }, - onClick = { onProgramClick(prog) }, - ) + CompositionLocalProvider(LocalBringIntoViewSpec provides GuideBringIntoViewSpec) { + LazyRow( + state = rowState, + modifier = Modifier.weight(1f).fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (programs.isEmpty()) { + item { + GuideProgramCell( + title = "Geen EPG", + subtitle = null, + width = 160.dp, + live = false, + selected = false, + isFirst = true, + isLast = true, + focusRequester = entryFocusRequester, + rightExitFocus = rightExitFocus, + canExitRight = canExitRight, + upFocus = if (isFirstRow) categoryUpFocus else null, + onFocused = {}, + onClick = {}, + ) + } + } else { + itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { index, prog -> + val live = isProgramLive(prog, clock) + GuideProgramCell( + title = prog.title, + subtitle = listOfNotNull( + prog.episode?.takeUnless { it.equals("null", true) }, + formatShortTime(prog.start), + ).joinToString(" · ").ifBlank { null }, + width = programWidthDp(prog, windowStart, windowEnd), + live = live, + selected = selectedProgram?.start == prog.start && + selectedProgram?.title == prog.title, + isFirst = index == 0, + isLast = index == programs.lastIndex, + focusRequester = entryFocusRequester.takeIf { index == entryIndex }, + rightExitFocus = rightExitFocus, + canExitRight = canExitRight, + upFocus = if (isFirstRow) categoryUpFocus else null, + onFocused = { onProgramFocused(prog) }, + onClick = { onProgramClick(prog) }, + ) + } } } } } } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun GuideProgramCell( title: String, @@ -556,13 +683,20 @@ private fun GuideProgramCell( width: Dp, live: Boolean, selected: Boolean, + isFirst: Boolean, + isLast: Boolean, + focusRequester: FocusRequester? = null, + rightExitFocus: FocusRequester, + canExitRight: Boolean, + upFocus: FocusRequester?, onFocused: () -> Unit, onClick: () -> Unit, ) { var focused by remember { mutableStateOf(false) } val bg by animateColorAsState( when { - focused || selected -> if (live) Color(0xFF3A1A1A) else Color(0xFF2A2A2A) + focused -> if (live) GuideCellFocusedLive else GuideCellFocused + selected -> if (live) Color(0xFF321818) else Color(0xFF242424) live -> GuideCellLive else -> GuideCell }, @@ -573,17 +707,49 @@ private fun GuideProgramCell( modifier = Modifier .width(width.coerceAtLeast(72.dp)) .fillMaxHeight() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .focusProperties { + // Einde rij: detailpaneel, nooit TopNav (zoek/settings rechtsboven). + if (isLast) { + right = if (canExitRight) rightExitFocus else FocusRequester.Cancel + } + if (isFirst) left = FocusRequester.Cancel + if (upFocus != null) up = upFocus + } .onFocusChanged { focused = it.isFocused if (it.isFocused) onFocused() - } - .then( - if (live) { - Modifier.border(1.dp, McColors.Brand.copy(alpha = 0.55f), RoundedCornerShape(4.dp)) - } else Modifier, - ), + }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = if (live) McColors.Brand.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f), + elevation = 8.dp, + ), + ), + border = ClickableSurfaceDefaults.border( + border = Border( + border = BorderStroke( + width = when { + focused -> 0.dp + selected -> 2.dp + live -> 1.dp + else -> 0.dp + }, + color = when { + selected -> Color.White.copy(alpha = 0.35f) + live -> McColors.Brand.copy(alpha = 0.45f) + else -> Color.Transparent + }, + ), + shape = RoundedCornerShape(4.dp), + ), + focusedBorder = Border( + border = BorderStroke(3.dp, if (live) McColors.Brand else Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), colors = ClickableSurfaceDefaults.colors( containerColor = bg, focusedContainerColor = bg, @@ -592,7 +758,9 @@ private fun GuideProgramCell( ), ) { Column( - Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp), + Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp), verticalArrangement = Arrangement.Center, ) { Row( @@ -606,7 +774,7 @@ private fun GuideProgramCell( title, color = Color.White, fontSize = 12.sp, - fontWeight = FontWeight.Medium, + fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -614,7 +782,7 @@ private fun GuideProgramCell( if (!subtitle.isNullOrBlank()) { Text( subtitle, - color = Color(0xFF888888), + color = if (focused) Color(0xFFBBBBBB) else Color(0xFF888888), fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -630,9 +798,11 @@ private fun GuideDetailPanel( selection: GuideSelection?, clock: Long, isFavorite: Boolean, + primaryFocusRequester: FocusRequester, onToggleFavorite: () -> Unit, onWatch: () -> Unit, onOpenChannel: () -> Unit, + onFocused: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -768,7 +938,13 @@ private fun GuideDetailPanel( Spacer(Modifier.weight(1f)) if (live) { - GuideActionButton("▶ Kijken", primary = true, onClick = onWatch) + GuideActionButton( + "▶ Kijken", + primary = true, + focusRequester = primaryFocusRequester, + onFocused = onFocused, + onClick = onWatch, + ) } else { Text( "Dit programma is nog niet begonnen — je kunt wel naar de zender.", @@ -776,33 +952,60 @@ private fun GuideDetailPanel( fontSize = 11.sp, lineHeight = 15.sp, ) - GuideActionButton("Naar zender", primary = true, onClick = onOpenChannel) + GuideActionButton( + "Naar zender", + primary = true, + focusRequester = primaryFocusRequester, + onFocused = onFocused, + onClick = onOpenChannel, + ) } GuideActionButton( if (isFavorite) "★ Favoriet" else "☆ Favoriet", primary = false, + onFocused = onFocused, onClick = onToggleFavorite, ) if (live) { - GuideActionButton("Zender openen", primary = false, onClick = onOpenChannel) + GuideActionButton( + "Zender openen", + primary = false, + onFocused = onFocused, + onClick = onOpenChannel, + ) } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun GuideActionButton(label: String, primary: Boolean, onClick: () -> Unit) { +private fun GuideActionButton( + label: String, + primary: Boolean, + focusRequester: FocusRequester? = null, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit, +) { Surface( onClick = onClick, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), colors = ClickableSurfaceDefaults.colors( containerColor = if (primary) McColors.Brand else Color(0xFF1A1A1A), focusedContainerColor = if (primary) Color(0xFFFF2A2A) else Color(0xFF2A2A2A), contentColor = Color.White, focusedContentColor = Color.White, ), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { if (it.isFocused) onFocused?.invoke() }, ) { Text( label, diff --git a/apps/master-api/src/viewer/castRemote.ts b/apps/master-api/src/viewer/castRemote.ts new file mode 100644 index 0000000..7fb51a1 --- /dev/null +++ b/apps/master-api/src/viewer/castRemote.ts @@ -0,0 +1,249 @@ +/** + * VMC cast/remote: phone → Android TV-app. + * In-memory presence + command queue (devices moeten online zijn). + */ +import { randomUUID } from "crypto"; +import { prisma } from "../database/client"; +import { AppError } from "../security/errors"; + +const RECEIVER_TTL_MS = 90_000; +const COMMAND_TTL_MS = 60_000; +const STATUS_TTL_MS = 120_000; + +export type CastCommandType = + | "play_vod" + | "play_live" + | "pause" + | "resume" + | "seek" + | "stop" + | "skip"; + +export type CastCommand = { + id: string; + targetDeviceId: string; + fromDeviceId: string; + viewerId: string; + type: CastCommandType; + payload: Record; + createdAt: number; +}; + +export type CastStatus = { + deviceId: string; + viewerId: string; + state: "idle" | "playing" | "paused" | "buffering"; + title?: string | null; + positionMs?: number; + durationMs?: number; + mediaFileId?: string | null; + streamId?: number | null; + mediaType?: string | null; + updatedAt: number; +}; + +type ReceiverPresence = { + viewerId: string; + deviceId: string; + name: string; + platform: string; + lastBeat: number; +}; + +const receivers = new Map(); +const queues = new Map(); +const statuses = new Map(); + +function prune() { + const now = Date.now(); + for (const [id, r] of receivers) { + if (now - r.lastBeat > RECEIVER_TTL_MS) receivers.delete(id); + } + for (const [deviceId, list] of queues) { + const kept = list.filter((c) => now - c.createdAt < COMMAND_TTL_MS); + if (kept.length === 0) queues.delete(deviceId); + else queues.set(deviceId, kept); + } + for (const [id, s] of statuses) { + if (now - s.updatedAt > STATUS_TTL_MS) statuses.delete(id); + } +} + +export async function beatPresence( + viewerId: string, + deviceId: string, + opts?: { displayName?: string; platform?: string } +) { + prune(); + const device = await prisma.viewerDevice.findFirst({ + where: { id: deviceId, viewerUserId: viewerId, revoked: false }, + select: { id: true, name: true, platform: true }, + }); + if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404); + + const name = opts?.displayName?.trim() || device.name || "Android TV"; + const platform = opts?.platform?.trim() || device.platform || "android_tv"; + + receivers.set(deviceId, { + viewerId, + deviceId, + name, + platform, + lastBeat: Date.now(), + }); + + await prisma.viewerDevice.update({ + where: { id: deviceId }, + data: { lastSeenAt: new Date() }, + }); + + return { ok: true as const, role: "receiver" as const }; +} + +export function listReceivers(viewerId: string, currentDeviceId: string) { + prune(); + const list = [...receivers.values()] + .filter((r) => r.viewerId === viewerId && r.deviceId !== currentDeviceId) + .map((r) => ({ + id: r.deviceId, + name: r.name, + platform: r.platform, + lastSeenAt: new Date(r.lastBeat).toISOString(), + online: true, + kind: "vmc_tv" as const, + })); + return { receivers: list }; +} + +export async function enqueueCommand( + viewerId: string, + fromDeviceId: string, + targetDeviceId: string, + type: CastCommandType, + payload: Record = {} +) { + prune(); + if (targetDeviceId === fromDeviceId) { + throw new AppError("INVALID_REQUEST", "Kan niet naar dit apparaat casten", 400); + } + const target = await prisma.viewerDevice.findFirst({ + where: { id: targetDeviceId, viewerUserId: viewerId, revoked: false }, + select: { id: true }, + }); + if (!target) throw new AppError("NOT_FOUND", "TV-apparaat niet gevonden", 404); + + const presence = receivers.get(targetDeviceId); + if (!presence || Date.now() - presence.lastBeat > RECEIVER_TTL_MS) { + throw new AppError("UNAVAILABLE", "TV is offline of VMC-app staat niet open", 503); + } + + const cmd: CastCommand = { + id: randomUUID(), + targetDeviceId, + fromDeviceId, + viewerId, + type, + payload, + createdAt: Date.now(), + }; + const q = queues.get(targetDeviceId) ?? []; + q.push(cmd); + queues.set(targetDeviceId, q); + return { ok: true as const, commandId: cmd.id }; +} + +export function pollCommands(viewerId: string, deviceId: string) { + prune(); + const q = queues.get(deviceId) ?? []; + const mine = q.filter((c) => c.viewerId === viewerId); + queues.delete(deviceId); + return { + commands: mine.map((c) => ({ + id: c.id, + type: c.type, + payload: c.payload, + fromDeviceId: c.fromDeviceId, + createdAt: new Date(c.createdAt).toISOString(), + })), + }; +} + +export function publishStatus( + viewerId: string, + deviceId: string, + body: { + state?: string; + title?: string | null; + positionMs?: number; + durationMs?: number; + mediaFileId?: string | null; + streamId?: number | null; + mediaType?: string | null; + } +) { + prune(); + const stateRaw = (body.state || "idle").toLowerCase(); + const state = + stateRaw === "playing" || stateRaw === "paused" || stateRaw === "buffering" + ? stateRaw + : "idle"; + const status: CastStatus = { + deviceId, + viewerId, + state, + title: body.title ?? null, + positionMs: typeof body.positionMs === "number" ? body.positionMs : 0, + durationMs: typeof body.durationMs === "number" ? body.durationMs : 0, + mediaFileId: body.mediaFileId ?? null, + streamId: typeof body.streamId === "number" ? body.streamId : null, + mediaType: body.mediaType ?? null, + updatedAt: Date.now(), + }; + statuses.set(deviceId, status); + return { ok: true as const }; +} + +export async function getStatus(viewerId: string, deviceId: string) { + prune(); + const device = await prisma.viewerDevice.findFirst({ + where: { id: deviceId, viewerUserId: viewerId, revoked: false }, + select: { id: true, name: true }, + }); + if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404); + const s = statuses.get(deviceId); + const presence = receivers.get(deviceId); + return { + deviceId, + name: presence?.name ?? device.name, + online: !!(presence && Date.now() - presence.lastBeat <= RECEIVER_TTL_MS), + status: s + ? { + state: s.state, + title: s.title, + positionMs: s.positionMs ?? 0, + durationMs: s.durationMs ?? 0, + mediaFileId: s.mediaFileId, + streamId: s.streamId, + mediaType: s.mediaType, + updatedAt: new Date(s.updatedAt).toISOString(), + } + : null, + }; +} + +export function parseCommandType(raw: unknown): CastCommandType { + const t = String(raw || "").toLowerCase(); + const allowed: CastCommandType[] = [ + "play_vod", + "play_live", + "pause", + "resume", + "seek", + "stop", + "skip", + ]; + if (!allowed.includes(t as CastCommandType)) { + throw new AppError("INVALID_REQUEST", `Onbekend command: ${t}`, 400); + } + return t as CastCommandType; +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 0a4fa61..829838b 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -839,4 +839,69 @@ export function registerViewerRoutes( raw === "com" ? "com" : raw === "local" ? "local" : "org"; return viewers.fetchSubtitle(fileId, source); }); + + // ——— Cast / VMC remote (phone → Android TV app) ——— + app.post("/api/v1/client/cast/presence", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const body = (request.body || {}) as { displayName?: string; platform?: string }; + const { beatPresence } = await import("./castRemote"); + return beatPresence(auth.viewerId, auth.deviceId, { + displayName: body.displayName, + platform: body.platform, + }); + }); + + app.get("/api/v1/client/cast/receivers", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { listReceivers } = await import("./castRemote"); + return listReceivers(auth.viewerId, auth.deviceId); + }); + + app.post("/api/v1/client/cast/command", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const body = (request.body || {}) as { + targetDeviceId?: string; + type?: string; + payload?: Record; + }; + if (!body.targetDeviceId?.trim()) { + throw new AppError("INVALID_REQUEST", "targetDeviceId verplicht", 400); + } + const { enqueueCommand, parseCommandType } = await import("./castRemote"); + return enqueueCommand( + auth.viewerId, + auth.deviceId, + body.targetDeviceId.trim(), + parseCommandType(body.type), + body.payload && typeof body.payload === "object" ? body.payload : {} + ); + }); + + app.get("/api/v1/client/cast/poll", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { pollCommands } = await import("./castRemote"); + return pollCommands(auth.viewerId, auth.deviceId); + }); + + app.post("/api/v1/client/cast/status", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const body = (request.body || {}) as { + state?: string; + title?: string | null; + positionMs?: number; + durationMs?: number; + mediaFileId?: string | null; + streamId?: number | null; + mediaType?: string | null; + }; + const { publishStatus } = await import("./castRemote"); + return publishStatus(auth.viewerId, auth.deviceId, body); + }); + + app.get("/api/v1/client/cast/status/:deviceId", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { deviceId } = request.params as { deviceId: string }; + const { getStatus } = await import("./castRemote"); + return getStatus(auth.viewerId, deviceId); + }); }