Add Google Cast and VMC phone-to-TV remote, plus TV guide focus fixes.
This commit is contained in:
parent
b566454f8a
commit
276467f09b
22 changed files with 1686 additions and 78 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +622,7 @@ private fun GuideChannelRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
|
CompositionLocalProvider(LocalBringIntoViewSpec provides GuideBringIntoViewSpec) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
|
@ -522,12 +636,18 @@ private fun GuideChannelRow(
|
||||||
width = 160.dp,
|
width = 160.dp,
|
||||||
live = false,
|
live = false,
|
||||||
selected = false,
|
selected = false,
|
||||||
|
isFirst = true,
|
||||||
|
isLast = true,
|
||||||
|
focusRequester = entryFocusRequester,
|
||||||
|
rightExitFocus = rightExitFocus,
|
||||||
|
canExitRight = canExitRight,
|
||||||
|
upFocus = if (isFirstRow) categoryUpFocus else null,
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { _, prog ->
|
itemsIndexed(programs, key = { _, p -> "${p.start}-${p.title}" }) { index, prog ->
|
||||||
val live = isProgramLive(prog, clock)
|
val live = isProgramLive(prog, clock)
|
||||||
GuideProgramCell(
|
GuideProgramCell(
|
||||||
title = prog.title,
|
title = prog.title,
|
||||||
|
|
@ -539,6 +659,12 @@ private fun GuideChannelRow(
|
||||||
live = live,
|
live = live,
|
||||||
selected = selectedProgram?.start == prog.start &&
|
selected = selectedProgram?.start == prog.start &&
|
||||||
selectedProgram?.title == prog.title,
|
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) },
|
onFocused = { onProgramFocused(prog) },
|
||||||
onClick = { onProgramClick(prog) },
|
onClick = { onProgramClick(prog) },
|
||||||
)
|
)
|
||||||
|
|
@ -547,8 +673,9 @@ private fun GuideChannelRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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,
|
||||||
|
|
|
||||||
249
apps/master-api/src/viewer/castRemote.ts
Normal file
249
apps/master-api/src/viewer/castRemote.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue