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);
+ });
}