Add Google Cast and VMC phone-to-TV remote, plus TV guide focus fixes.

This commit is contained in:
Jos Vooges | STH 2026-09-16 00:52:56 +02:00
parent b566454f8a
commit 276467f09b
22 changed files with 1686 additions and 78 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 21 versionCode = 22
versionName = "1.0.20" versionName = "1.0.21"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@ -86,6 +86,10 @@ dependencies {
implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3")
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0") 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("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.1")

View file

@ -18,6 +18,9 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MediaClusterTv" android:theme="@style/Theme.MediaClusterTv"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="nl.vonas.mediacluster.tv.cast.CastOptionsProvider" />
<activity <activity
android:name="nl.vonas.mediacluster.tv.MainActivity" android:name="nl.vonas.mediacluster.tv.MainActivity"
android:exported="true" android:exported="true"

View file

@ -1,5 +1,11 @@
package nl.vonas.mediacluster.tv package nl.vonas.mediacluster.tv
import android.app.Application import android.app.Application
import nl.vonas.mediacluster.tv.cast.GoogleCastHelper
class McApp : Application() class McApp : Application() {
override fun onCreate() {
super.onCreate()
GoogleCastHelper.init(this)
}
}

View file

@ -0,0 +1,18 @@
package nl.vonas.mediacluster.tv.cast
import android.content.Context
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.CastMediaControlIntent
/** Default Media Receiver — speelt HTTPS video/HLS streams af. */
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
}

View file

@ -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<List<CastTarget.Chromecast>>(emptyList()) }
var vmcTvs by remember { mutableStateOf<List<CastReceiver>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(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
}
}

View file

@ -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<CastSession> {
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"
}
}
}

View file

@ -602,6 +602,65 @@ class ApiClient(
return result return result
} }
suspend fun castListReceivers(): List<CastReceiver> = 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? { private fun JSONObject.toEpgProgram(): EpgProgram? {
val title = optClean("title") ?: return null val title = optClean("title") ?: return null
return EpgProgram( return EpgProgram(
@ -701,6 +760,26 @@ data class PlayResult(
val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(), val embeddedTracks: List<EmbeddedTrackInfo> = 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( data class EmbeddedTrackInfo(
val type: String, val type: String,
val language: String? = null, val language: String? = null,

View file

@ -69,6 +69,8 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.cast.CastPlayRequest
import nl.vonas.mediacluster.tv.cast.CastTargetPickerDialog
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvChannel
@ -95,6 +97,7 @@ fun LiveTvPlayerScreen(
onCategoryChange: (String) -> Unit, onCategoryChange: (String) -> Unit,
onStreamChange: (Int) -> Unit, onStreamChange: (Int) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) } val player = remember { buildLiveTvPlayer(context) }
@ -108,6 +111,8 @@ fun LiveTvPlayerScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var channelName by remember { mutableStateOf("") } var channelName by remember { mutableStateOf("") }
var logoUrl by remember { mutableStateOf<String?>(null) } var logoUrl by remember { mutableStateOf<String?>(null) }
var streamUrl by remember { mutableStateOf<String?>(null) }
var showCastPicker by remember { mutableStateOf(false) }
var epg by remember { mutableStateOf<IptvEpg?>(null) } var epg by remember { mutableStateOf<IptvEpg?>(null) }
var infoOverlay by remember { mutableStateOf(true) } var infoOverlay by remember { mutableStateOf(true) }
var channelMenuOpen by remember { mutableStateOf(false) } var channelMenuOpen by remember { mutableStateOf(false) }
@ -141,6 +146,7 @@ fun LiveTvPlayerScreen(
val play = api.iptvPlayUrl(streamIdToPlay) val play = api.iptvPlayUrl(streamIdToPlay)
channelName = play.name channelName = play.name
logoUrl = play.logoUrl logoUrl = play.logoUrl
streamUrl = play.streamUrl
val tsUrl = when { val tsUrl = when {
play.format.equals("ts", ignoreCase = true) -> play.streamUrl play.format.equals("ts", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl 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) 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( 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) @OptIn(ExperimentalTvMaterial3Api::class)

View file

@ -382,6 +382,9 @@ fun McNav(
seriesId = dash(entry.arguments?.getString("seriesId")), seriesId = dash(entry.arguments?.getString("seriesId")),
startMs = entry.arguments?.getLong("startMs") ?: 0L, startMs = entry.arguments?.getLong("startMs") ?: 0L,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onOpenVmcRemote = { deviceId, deviceName ->
nav.navigate("vmc_remote/$deviceId/${android.net.Uri.encode(deviceName)}")
},
) )
} }
composable( composable(
@ -399,6 +402,26 @@ fun McNav(
onCategoryChange = { tvCategoryId = it }, onCategoryChange = { tvCategoryId = it },
onStreamChange = { tvLastStreamId = it }, onStreamChange = { tvLastStreamId = it },
onBack = { nav.popBackStack() }, 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() },
) )
} }
} }

View file

@ -70,6 +70,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch 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.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -93,6 +95,7 @@ fun PlayerScreen(
seriesId: String?, seriesId: String?,
startMs: Long, startMs: Long,
onBack: () -> Unit, onBack: () -> Unit,
onOpenVmcRemote: (deviceId: String, deviceName: String) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { buildTvPlayer(context) } val player = remember { buildTvPlayer(context) }
@ -107,6 +110,8 @@ fun PlayerScreen(
var playing by remember { mutableStateOf(true) } var playing by remember { mutableStateOf(true) }
var positionMs by remember { mutableLongStateOf(0L) } var positionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) }
var streamUrl by remember { mutableStateOf<String?>(null) }
var showCastPicker by remember { mutableStateOf(false) }
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) } var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) } var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var embeddedHints by remember { mutableStateOf<List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo>>(emptyList()) } var embeddedHints by remember { mutableStateOf<List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo>>(emptyList()) }
@ -213,6 +218,7 @@ fun PlayerScreen(
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
embeddedHints = play.embeddedTracks embeddedHints = play.embeddedTracks
streamUrl = play.streamUrl
player.volume = 1f player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare() player.prepare()
@ -466,14 +472,20 @@ fun PlayerScreen(
), ),
) )
// Terug linksboven // Terug linksboven + cast rechtsboven
Row( Row(
Modifier Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.fillMaxWidth()
.padding(28.dp), .padding(28.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
NetflixTextButton("← Terug", onClick = onBack) NetflixTextButton("← Terug", onClick = onBack)
NetflixTextButton("Cast") {
showCastPicker = true
showControls()
}
} }
// Grote play/pause in het midden als gepauzeerd // Grote play/pause in het midden als gepauzeerd
@ -545,6 +557,10 @@ fun PlayerScreen(
showControls() showControls()
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
NetflixTextButton("Cast") {
showCastPicker = true
showControls()
}
NetflixTextButton("Audio") { NetflixTextButton("Audio") {
menu = "audio" menu = "audio"
showControls() 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) { if (menu != null) {
val isAudio = menu == "audio" val isAudio = menu == "audio"
val isOs = menu == "os" val isOs = menu == "os"

View file

@ -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<CastDeviceStatus?>(null) }
var error by remember { mutableStateOf<String?>(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)
}

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 107 versionCode = 109
versionName = "0.14.44" versionName = "0.14.46"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -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
}
}
}
}

View file

@ -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<CastPlayerCommand>(extraBufferCapacity = 8)
val commands: SharedFlow<CastPlayerCommand> = _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
}
}

View file

@ -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" }

View file

@ -653,6 +653,62 @@ class ApiClient(
return result 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<CastRemoteCommand> = 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? { private fun JSONObject.toEpgProgram(): EpgProgram? {
val title = optClean("title") ?: return null val title = optClean("title") ?: return null
return EpgProgram( return EpgProgram(
@ -779,6 +835,13 @@ data class PlayResult(
val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(), val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(),
) )
data class CastRemoteCommand(
val id: String,
val type: String,
val payload: org.json.JSONObject,
val fromDeviceId: String?,
)
data class EmbeddedTrackInfo( data class EmbeddedTrackInfo(
val type: String, val type: String,
val language: String? = null, val language: String? = null,

View file

@ -69,6 +69,7 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvChannel
@ -128,6 +129,15 @@ fun LiveTvPlayerScreen(
val menuListState = rememberLazyListState() val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() } val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
BindCastPlayerRemote(
api = api,
player = player,
title = channelName.ifBlank { null },
streamId = currentStreamId,
mediaType = "LIVE",
onStop = onBack,
)
fun startStream(url: String, format: String) { fun startStream(url: String, format: String) {
player.setMediaItem(liveMediaItem(url, format)) player.setMediaItem(liveMediaItem(url, format))
player.prepare() player.prepare()

View file

@ -86,6 +86,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.BuildConfig 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.ApiClient
import nl.vonas.mediacluster.tv.data.HomePayload import nl.vonas.mediacluster.tv.data.HomePayload
import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.MediaItem
@ -137,6 +138,34 @@ fun McNav() {
val seriesGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() val seriesGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val watchlistGridState = 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) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
val token = store.refreshToken.first() val token = store.refreshToken.first()

View file

@ -67,6 +67,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.cast.BindCastPlayerRemote
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -123,6 +124,15 @@ fun PlayerScreen(
var nextCountdown by remember { mutableIntStateOf(10) } var nextCountdown by remember { mutableIntStateOf(10) }
var nextOverlayConsumed by remember { mutableStateOf(false) } var nextOverlayConsumed by remember { mutableStateOf(false) }
BindCastPlayerRemote(
api = api,
player = player,
title = null,
mediaFileId = mediaFileId,
mediaType = mediaType,
onStop = onBack,
)
fun showControls() { fun showControls() {
controls = true controls = true
} }

View file

@ -2,8 +2,11 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -25,6 +28,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
@ -32,9 +36,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -65,10 +73,35 @@ private val GuideBg = Color(0xFF0B0B0B)
private val GuidePanel = Color(0xFF121212) private val GuidePanel = Color(0xFF121212)
private val GuideCell = Color(0xFF1A1A1A) private val GuideCell = Color(0xFF1A1A1A)
private val GuideCellLive = Color(0xFF2A1515) private val GuideCellLive = Color(0xFF2A1515)
private val GuideCellFocused = Color(0xFF333333)
private val GuideCellFocusedLive = Color(0xFF4A1E1E)
private val GuideDivider = Color(0xFF2A2A2A) private val GuideDivider = Color(0xFF2A2A2A)
private const val PX_PER_MIN = 4f private const val PX_PER_MIN = 4f
private const val GUIDE_HOURS = 6 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun TvGuideScreen( fun TvGuideScreen(
@ -96,6 +129,8 @@ fun TvGuideScreen(
val categoryRowState = rememberLazyListState() val categoryRowState = rememberLazyListState()
val channelListState = rememberLazyListState() val channelListState = rememberLazyListState()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() } val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val detailPrimaryFocus = remember { FocusRequester() }
val firstGridFocus = remember { FocusRequester() }
val displayCategories = remember(categories, favoriteIds) { val displayCategories = remember(categories, favoriteIds) {
listOf(IptvCategory(TV_FAV_CATEGORY_ID, "Favorieten", favoriteIds.size)) + categories 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) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(30_000) delay(30_000)
@ -176,11 +217,7 @@ fun TvGuideScreen(
} }
BackHandler(enabled = focusInGrid) { BackHandler(enabled = focusInGrid) {
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id requestCategoryFocus()
if (catId != null) {
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInGrid = false
}
} }
BackHandler(enabled = !focusInGrid) { onAtTopBack() } BackHandler(enabled = !focusInGrid) { onAtTopBack() }
@ -206,6 +243,7 @@ fun TvGuideScreen(
selectedId = selectedCategoryId, selectedId = selectedCategoryId,
listState = categoryRowState, listState = categoryRowState,
categoryFocus = categoryFocus, categoryFocus = categoryFocus,
downFocus = firstGridFocus,
onSelect = { onSelect = {
onCategoryChange(it) onCategoryChange(it)
focusInGrid = true focusInGrid = true
@ -227,14 +265,22 @@ fun TvGuideScreen(
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 12.sp, fontSize = 12.sp,
) )
GuideChip(label = "Nu", onClick = { jumpToNowToken++ }) GuideChip(
label = "Nu",
downFocus = firstGridFocus,
onClick = { jumpToNowToken++ },
)
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(GuideDivider)) Box(Modifier.fillMaxWidth().height(1.dp).background(GuideDivider))
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row(Modifier.weight(1f).fillMaxWidth()) { Row(Modifier.weight(1f).fillMaxWidth()) {
Column(Modifier.weight(0.62f).fillMaxHeight()) { Column(
Modifier
.weight(0.62f)
.fillMaxHeight(),
) {
GuideTimeRuler(clock = clock) GuideTimeRuler(clock = clock)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
when { when {
@ -263,15 +309,20 @@ fun TvGuideScreen(
verticalArrangement = Arrangement.spacedBy(3.dp), verticalArrangement = Arrangement.spacedBy(3.dp),
contentPadding = PaddingValues(bottom = 12.dp), contentPadding = PaddingValues(bottom = 12.dp),
) { ) {
items(visibleChannels, key = { it.streamId }) { ch -> itemsIndexed(visibleChannels, key = { _, ch -> ch.streamId }) { rowIndex, ch ->
GuideChannelRow( GuideChannelRow(
channel = ch, channel = ch,
clock = clock, clock = clock,
jumpToNowToken = jumpToNowToken, jumpToNowToken = jumpToNowToken,
isFavorite = favoriteIds.contains(ch.streamId), isFavorite = favoriteIds.contains(ch.streamId),
isFirstRow = rowIndex == 0,
entryFocusRequester = firstGridFocus.takeIf { rowIndex == 0 },
selectedProgram = selected selectedProgram = selected
?.takeIf { it.channel.streamId == ch.streamId } ?.takeIf { it.channel.streamId == ch.streamId }
?.program, ?.program,
rightExitFocus = detailPrimaryFocus,
canExitRight = selected != null,
categoryUpFocus = selectedCategoryId?.let { categoryFocus[it] },
onProgramFocused = { prog -> onProgramFocused = { prog ->
focusInGrid = true focusInGrid = true
onContentFocused() onContentFocused()
@ -302,9 +353,14 @@ fun TvGuideScreen(
selection = selected, selection = selected,
clock = clock, clock = clock,
isFavorite = selected?.channel?.streamId?.let { favoriteIds.contains(it) } == true, isFavorite = selected?.channel?.streamId?.let { favoriteIds.contains(it) } == true,
primaryFocusRequester = detailPrimaryFocus,
onToggleFavorite = { selected?.channel?.streamId?.let(onToggleFavorite) }, onToggleFavorite = { selected?.channel?.streamId?.let(onToggleFavorite) },
onWatch = { selected?.channel?.streamId?.let(onPlayChannel) }, onWatch = { selected?.channel?.streamId?.let(onPlayChannel) },
onOpenChannel = { selected?.channel?.streamId?.let(onOpenLiveTv) }, onOpenChannel = { selected?.channel?.streamId?.let(onOpenLiveTv) },
onFocused = {
focusInGrid = true
onContentFocused()
},
modifier = Modifier modifier = Modifier
.weight(0.38f) .weight(0.38f)
.fillMaxHeight() .fillMaxHeight()
@ -329,6 +385,7 @@ private fun GuideCategoryRow(
selectedId: String?, selectedId: String?,
listState: LazyListState, listState: LazyListState,
categoryFocus: MutableMap<String, FocusRequester>, categoryFocus: MutableMap<String, FocusRequester>,
downFocus: FocusRequester,
onSelect: (String) -> Unit, onSelect: (String) -> Unit,
onFocused: () -> Unit, onFocused: () -> Unit,
) { ) {
@ -340,20 +397,35 @@ private fun GuideCategoryRow(
items(categories, key = { it.id }) { cat -> items(categories, key = { it.id }) { cat ->
val active = cat.id == selectedId val active = cat.id == selectedId
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() } val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
var focused by remember { mutableStateOf(false) }
val textColor by animateColorAsState( val textColor by animateColorAsState(
if (active) Color.White else Color(0xFF888888), when {
focused -> Color.White
active -> Color.White
else -> Color(0xFF888888)
},
label = "guideTab", label = "guideTab",
) )
Surface( Surface(
onClick = { onSelect(cat.id) }, onClick = { onSelect(cat.id) },
modifier = Modifier modifier = Modifier
.focusRequester(fr) .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)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x18FFFFFF), focusedContainerColor = Color(0x33FFFFFF),
contentColor = textColor, contentColor = textColor,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
@ -366,16 +438,22 @@ private fun GuideCategoryRow(
cat.name, cat.name,
color = Color.Unspecified, color = Color.Unspecified,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (active || focused) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1, maxLines = 1,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Box( Box(
Modifier Modifier
.height(2.dp) .height(2.dp)
.width(if (active) 24.dp else 0.dp) .width(if (active || focused) 24.dp else 0.dp)
.clip(RoundedCornerShape(1.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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun GuideChip(label: String, onClick: () -> Unit) { private fun GuideChip(
label: String,
downFocus: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = Modifier.then(
if (downFocus != null) Modifier.focusProperties { down = downFocus } else Modifier,
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A), containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF2A2A2A), focusedContainerColor = Color(0xFF2A2A2A),
@ -430,13 +521,19 @@ private fun GuideTimeRuler(clock: Long) {
} }
} }
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable @Composable
private fun GuideChannelRow( private fun GuideChannelRow(
channel: IptvGuideChannel, channel: IptvGuideChannel,
clock: Long, clock: Long,
jumpToNowToken: Int, jumpToNowToken: Int,
isFavorite: Boolean, isFavorite: Boolean,
isFirstRow: Boolean,
entryFocusRequester: FocusRequester?,
selectedProgram: EpgProgram?, selectedProgram: EpgProgram?,
rightExitFocus: FocusRequester,
canExitRight: Boolean,
categoryUpFocus: FocusRequester?,
onProgramFocused: (EpgProgram) -> Unit, onProgramFocused: (EpgProgram) -> Unit,
onProgramClick: (EpgProgram) -> Unit, onProgramClick: (EpgProgram) -> Unit,
) { ) {
@ -451,14 +548,30 @@ private fun GuideChannelRow(
raw.filter { overlapsWindow(it, windowStart, windowEnd) } raw.filter { overlapsWindow(it, windowStart, windowEnd) }
.sortedBy { parseMs(it.start) ?: 0L } .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) } val liveIdx = programs.indexOfFirst { isProgramLive(it, clock) }
if (liveIdx >= 0) rowState.animateScrollToItem(liveIdx) if (liveIdx >= 0) rowState.animateScrollToItem(liveIdx)
} }
Row( Row(
Modifier.fillMaxWidth().height(52.dp), Modifier.fillMaxWidth().height(56.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row( Row(
@ -509,46 +622,60 @@ private fun GuideChannelRow(
} }
} }
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
LazyRow( CompositionLocalProvider(LocalBringIntoViewSpec provides GuideBringIntoViewSpec) {
state = rowState, LazyRow(
modifier = Modifier.weight(1f).fillMaxHeight(), state = rowState,
horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f).fillMaxHeight(),
) { horizontalArrangement = Arrangement.spacedBy(3.dp),
if (programs.isEmpty()) { ) {
item { if (programs.isEmpty()) {
GuideProgramCell( item {
title = "Geen EPG", GuideProgramCell(
subtitle = null, title = "Geen EPG",
width = 160.dp, subtitle = null,
live = false, width = 160.dp,
selected = false, live = false,
onFocused = {}, selected = false,
onClick = {}, isFirst = true,
) isLast = true,
} focusRequester = entryFocusRequester,
} else { rightExitFocus = rightExitFocus,
itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { _, prog -> canExitRight = canExitRight,
val live = isProgramLive(prog, clock) upFocus = if (isFirstRow) categoryUpFocus else null,
GuideProgramCell( onFocused = {},
title = prog.title, onClick = {},
subtitle = listOfNotNull( )
prog.episode?.takeUnless { it.equals("null", true) }, }
formatShortTime(prog.start), } else {
).joinToString(" · ").ifBlank { null }, itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { index, prog ->
width = programWidthDp(prog, windowStart, windowEnd), val live = isProgramLive(prog, clock)
live = live, GuideProgramCell(
selected = selectedProgram?.start == prog.start && title = prog.title,
selectedProgram?.title == prog.title, subtitle = listOfNotNull(
onFocused = { onProgramFocused(prog) }, prog.episode?.takeUnless { it.equals("null", true) },
onClick = { onProgramClick(prog) }, 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 @Composable
private fun GuideProgramCell( private fun GuideProgramCell(
title: String, title: String,
@ -556,13 +683,20 @@ private fun GuideProgramCell(
width: Dp, width: Dp,
live: Boolean, live: Boolean,
selected: Boolean, selected: Boolean,
isFirst: Boolean,
isLast: Boolean,
focusRequester: FocusRequester? = null,
rightExitFocus: FocusRequester,
canExitRight: Boolean,
upFocus: FocusRequester?,
onFocused: () -> Unit, onFocused: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val bg by animateColorAsState( val bg by animateColorAsState(
when { 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 live -> GuideCellLive
else -> GuideCell else -> GuideCell
}, },
@ -573,17 +707,49 @@ private fun GuideProgramCell(
modifier = Modifier modifier = Modifier
.width(width.coerceAtLeast(72.dp)) .width(width.coerceAtLeast(72.dp))
.fillMaxHeight() .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 { .onFocusChanged {
focused = it.isFocused focused = it.isFocused
if (it.isFocused) onFocused() 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)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = bg, containerColor = bg,
focusedContainerColor = bg, focusedContainerColor = bg,
@ -592,7 +758,9 @@ private fun GuideProgramCell(
), ),
) { ) {
Column( Column(
Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp), Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Row( Row(
@ -606,7 +774,7 @@ private fun GuideProgramCell(
title, title,
color = Color.White, color = Color.White,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@ -614,7 +782,7 @@ private fun GuideProgramCell(
if (!subtitle.isNullOrBlank()) { if (!subtitle.isNullOrBlank()) {
Text( Text(
subtitle, subtitle,
color = Color(0xFF888888), color = if (focused) Color(0xFFBBBBBB) else Color(0xFF888888),
fontSize = 10.sp, fontSize = 10.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -630,9 +798,11 @@ private fun GuideDetailPanel(
selection: GuideSelection?, selection: GuideSelection?,
clock: Long, clock: Long,
isFavorite: Boolean, isFavorite: Boolean,
primaryFocusRequester: FocusRequester,
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
onWatch: () -> Unit, onWatch: () -> Unit,
onOpenChannel: () -> Unit, onOpenChannel: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -768,7 +938,13 @@ private fun GuideDetailPanel(
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (live) { if (live) {
GuideActionButton("▶ Kijken", primary = true, onClick = onWatch) GuideActionButton(
"▶ Kijken",
primary = true,
focusRequester = primaryFocusRequester,
onFocused = onFocused,
onClick = onWatch,
)
} else { } else {
Text( Text(
"Dit programma is nog niet begonnen — je kunt wel naar de zender.", "Dit programma is nog niet begonnen — je kunt wel naar de zender.",
@ -776,33 +952,60 @@ private fun GuideDetailPanel(
fontSize = 11.sp, fontSize = 11.sp,
lineHeight = 15.sp, lineHeight = 15.sp,
) )
GuideActionButton("Naar zender", primary = true, onClick = onOpenChannel) GuideActionButton(
"Naar zender",
primary = true,
focusRequester = primaryFocusRequester,
onFocused = onFocused,
onClick = onOpenChannel,
)
} }
GuideActionButton( GuideActionButton(
if (isFavorite) "★ Favoriet" else "☆ Favoriet", if (isFavorite) "★ Favoriet" else "☆ Favoriet",
primary = false, primary = false,
onFocused = onFocused,
onClick = onToggleFavorite, onClick = onToggleFavorite,
) )
if (live) { if (live) {
GuideActionButton("Zender openen", primary = false, onClick = onOpenChannel) GuideActionButton(
"Zender openen",
primary = false,
onFocused = onFocused,
onClick = onOpenChannel,
)
} }
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @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( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Brand else Color(0xFF1A1A1A), containerColor = if (primary) McColors.Brand else Color(0xFF1A1A1A),
focusedContainerColor = if (primary) Color(0xFFFF2A2A) else Color(0xFF2A2A2A), focusedContainerColor = if (primary) Color(0xFFFF2A2A) else Color(0xFF2A2A2A),
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = 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( Text(
label, label,

View file

@ -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<string, unknown>;
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<string, ReceiverPresence>();
const queues = new Map<string, CastCommand[]>();
const statuses = new Map<string, CastStatus>();
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<string, unknown> = {}
) {
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;
}

View file

@ -839,4 +839,69 @@ export function registerViewerRoutes(
raw === "com" ? "com" : raw === "local" ? "local" : "org"; raw === "com" ? "com" : raw === "local" ? "local" : "org";
return viewers.fetchSubtitle(fileId, source); 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<string, unknown>;
};
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);
});
} }