diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts
index 7ed9a5f..b0006c5 100644
--- a/apps/android-tv/app-mobile/build.gradle.kts
+++ b/apps/android-tv/app-mobile/build.gradle.kts
@@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster"
minSdk = 24
targetSdk = 36
- versionCode = 13
- versionName = "1.0.12"
+ versionCode = 17
+ versionName = "1.0.16"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
diff --git a/apps/android-tv/app-mobile/src/main/AndroidManifest.xml b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml
index 9eca756..2512769 100644
--- a/apps/android-tv/app-mobile/src/main/AndroidManifest.xml
+++ b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml
@@ -21,6 +21,7 @@
@@ -28,6 +29,12 @@
+
+
+
+
+
+
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/LinkCodeParser.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/LinkCodeParser.kt
new file mode 100644
index 0000000..b59a916
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/LinkCodeParser.kt
@@ -0,0 +1,39 @@
+package nl.vonas.mediacluster.tv
+
+import android.content.Intent
+import android.net.Uri
+
+object LinkCodeParser {
+ fun fromIntent(intent: Intent?): String? {
+ if (intent == null) return null
+ return fromUri(intent.data) ?: intent.getStringExtra(EXTRA_LINK_CODE)?.let { normalize(it) }
+ }
+
+ fun fromUri(uri: Uri?): String? {
+ if (uri == null) return null
+ val scheme = uri.scheme?.lowercase().orEmpty()
+ if (scheme == "vmc") {
+ uri.getQueryParameter("code")?.trim()?.takeIf { it.isNotEmpty() }?.let { return normalize(it) }
+ val path = uri.path?.trim('/')?.takeIf { it.isNotEmpty() }
+ if (!path.isNullOrBlank() && !path.equals("link", ignoreCase = true)) {
+ return normalize(path.substringAfterLast('/'))
+ }
+ uri.host?.takeIf { it.isNotBlank() && !it.equals("link", ignoreCase = true) }?.let {
+ return normalize(it)
+ }
+ }
+ uri.getQueryParameter("code")?.trim()?.takeIf { it.isNotEmpty() }?.let { return normalize(it) }
+ return null
+ }
+
+ fun normalize(raw: String): String {
+ val cleaned = raw.uppercase().filter { it.isLetterOrDigit() || it == '-' }
+ if (cleaned.contains('-')) return cleaned
+ if (cleaned.length == 8) {
+ return "${cleaned.take(4)}-${cleaned.drop(4)}"
+ }
+ return cleaned
+ }
+
+ const val EXTRA_LINK_CODE = "vmc_link_code"
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt
index 02f62e7..21802e9 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt
@@ -1,15 +1,21 @@
package nl.vonas.mediacluster.tv
+import android.content.Intent
import android.media.AudioManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat
+import kotlinx.coroutines.flow.MutableStateFlow
import nl.vonas.mediacluster.tv.ui.McNav
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
class MainActivity : ComponentActivity() {
+ private val pendingLinkCode = MutableStateFlow(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
volumeControlStream = AudioManager.STREAM_MUSIC
@@ -18,10 +24,21 @@ class MainActivity : ComponentActivity() {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
+ pendingLinkCode.value = LinkCodeParser.fromIntent(intent)
setContent {
MediaClusterTheme {
- McNav()
+ val linkCode by pendingLinkCode.collectAsState()
+ McNav(
+ pendingLinkCode = linkCode,
+ onPendingLinkCodeConsumed = { pendingLinkCode.value = null },
+ )
}
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ pendingLinkCode.value = LinkCodeParser.fromIntent(intent)
+ }
}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
index 50837d1..74e92ae 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
@@ -160,6 +160,21 @@ class ApiClient(
label = s.optString("label").ifBlank { "Lokaal" },
)
},
+ embeddedTracks = o.optJSONArray("embeddedTracks")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val t = arr.optJSONObject(i) ?: continue
+ add(
+ EmbeddedTrackInfo(
+ type = t.optString("type").ifBlank { "unknown" },
+ language = t.optString("language").ifBlank { null },
+ title = t.optString("title").ifBlank { null },
+ codec = t.optString("codec").ifBlank { null },
+ )
+ )
+ }
+ }
+ } ?: emptyList(),
)
}
}
@@ -378,6 +393,55 @@ class ApiClient(
ClientMe(
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
profile = o.optJSONObject("profile")?.toProfile(),
+ deviceId = o.optClean("deviceId"),
+ )
+ }
+ }
+
+ suspend fun listDevices(): List = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/devices").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("devices") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val d = arr.getJSONObject(i)
+ add(
+ LinkedDevice(
+ id = d.getString("id"),
+ name = d.getString("name"),
+ platform = d.optString("platform").ifBlank { null },
+ lastSeenAt = d.optClean("lastSeenAt"),
+ createdAt = d.optClean("createdAt"),
+ current = d.optBoolean("current", false),
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun revokeDevice(deviceId: String) = withContext(Dispatchers.IO) {
+ val encoded = java.net.URLEncoder.encode(deviceId, "UTF-8")
+ val req = authed("$baseUrl/api/v1/client/devices/$encoded/revoke")
+ .post("{}".toRequestBody(jsonMedia))
+ .build()
+ execute(req)
+ Unit
+ }
+
+ suspend fun approveDeviceCode(code: String): ApproveDeviceResult = withContext(Dispatchers.IO) {
+ val body = JSONObject()
+ .put("code", code.trim())
+ .toString()
+ .toRequestBody(jsonMedia)
+ val req = authed("$baseUrl/api/v1/client/device/approve")
+ .post(body)
+ .build()
+ parseObject(execute(req)) { o ->
+ ApproveDeviceResult(
+ ok = o.optBoolean("ok", true),
+ deviceName = o.optClean("deviceName"),
+ platform = o.optClean("platform"),
)
}
}
@@ -608,6 +672,14 @@ data class PlayResult(
val sessionId: String,
val mediaFileId: String,
val defaultSubtitle: DefaultSubtitle? = null,
+ val embeddedTracks: List = emptyList(),
+)
+
+data class EmbeddedTrackInfo(
+ val type: String,
+ val language: String? = null,
+ val title: String? = null,
+ val codec: String? = null,
)
data class DefaultSubtitle(
@@ -658,6 +730,22 @@ data class ShelfPref(
data class ClientMe(
val iptvEnabled: Boolean,
val profile: ViewerProfile? = null,
+ val deviceId: String? = null,
+)
+
+data class LinkedDevice(
+ val id: String,
+ val name: String,
+ val platform: String?,
+ val lastSeenAt: String?,
+ val createdAt: String?,
+ val current: Boolean,
+)
+
+data class ApproveDeviceResult(
+ val ok: Boolean,
+ val deviceName: String?,
+ val platform: String?,
)
data class ViewerProfile(
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
index 554d5d3..a56154f 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
@@ -241,39 +241,59 @@ fun liveMediaItem(url: String, format: String): MediaItem {
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
-fun ExoPlayer.listTracks(type: Int): List {
+fun ExoPlayer.listTracks(type: Int, embeddedHints: List = emptyList()): List {
val out = mutableListOf()
val groups = currentTracks.groups
+ // ExoPlayer zet elke text/audio track vaak in een eigen group → ti is dan altijd 0.
+ // Gebruik een globale ordinal voor unieke "Ondertitel N" / "Audio N" labels.
+ val hintPool = when (type) {
+ C.TRACK_TYPE_TEXT -> embeddedHints.filter { it.type.equals("subtitle", true) }
+ C.TRACK_TYPE_AUDIO -> embeddedHints.filter { it.type.equals("audio", true) }
+ else -> emptyList()
+ }
+ var ordinal = 0
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type) continue
for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti)
+ val hint = hintPool.getOrNull(ordinal)
val label = if (type == C.TRACK_TYPE_TEXT) {
- formatTextTrackLabel(format, ti)
+ formatTextTrackLabel(format, ordinal, hint)
} else {
- formatAudioTrackLabel(format, ti)
+ formatAudioTrackLabel(format, ordinal, hint)
}
+ ordinal++
out += PlayerTrack(gi, ti, label, type)
}
}
return out
}
-private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
+private fun formatAudioTrackLabel(
+ format: androidx.media3.common.Format,
+ index: Int,
+ hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
+): String {
val mime = format.sampleMimeType ?: ""
- val lang = prettyLanguage(format.language)
+ val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
+ val label = format.label?.takeIf { !isGenericTrackLabel(it) }
+ ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && prettyLanguage(extractLanguageToken(it)) == null }
return listOfNotNull(
- format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
lang,
+ label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
shortMime(mime).takeIf { it.isNotBlank() },
ch,
- ).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
+ ).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
}
-private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
- val lang = prettyLanguage(format.language)
+private fun formatTextTrackLabel(
+ format: androidx.media3.common.Format,
+ index: Int,
+ hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
+): String {
+ val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
val kind = when {
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
format.sampleMimeType?.contains("ssa", true) == true ||
@@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
else -> null
}
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
- val label = format.label?.takeIf {
- it.isNotBlank() &&
- !it.equals("und", true) &&
- !it.equals("sub", true) &&
- !it.equals("subtitle", true)
- }
- return listOfNotNull(label, lang, kind, forced)
+ val label = format.label?.takeIf { !isGenericTrackLabel(it) }
+ ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
+ return listOfNotNull(
+ lang,
+ label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
+ kind,
+ forced,
+ )
.distinct()
.joinToString(" · ")
.ifBlank { "Ondertitel ${index + 1}" }
}
+/** Taal uit Format.language, anders uit label/id (Dutch, nl, Track-junk negeren). */
+private fun resolveLanguage(format: androidx.media3.common.Format): String? {
+ prettyLanguage(format.language)?.let { return it }
+ prettyLanguage(extractLanguageToken(format.label))?.let { return it }
+ prettyLanguage(extractLanguageToken(format.id))?.let { return it }
+ return null
+}
+
+private fun isGenericTrackLabel(raw: String?): Boolean {
+ val s = raw?.trim().orEmpty()
+ if (s.isEmpty()) return true
+ val lower = s.lowercase()
+ if (lower in setOf("und", "unknown", "null", "sub", "subtitle", "subs")) return true
+ if (lower.contains("text/") || lower.startsWith("http")) return true
+ if (Regex("""^track\s*\d+$""").matches(lower)) return true
+ if (Regex("""^subtitle\s*\d+$""").matches(lower)) return true
+ if (Regex("""^ondertitel\s*\d+$""").matches(lower)) return true
+ if (Regex("""^audio\s*\d+$""").matches(lower)) return true
+ return false
+}
+
+private fun extractLanguageToken(raw: String?): String? {
+ val s = raw?.trim()?.lowercase().orEmpty()
+ if (s.isEmpty() || isGenericTrackLabel(s)) return null
+ val known = listOf(
+ "nederlands", "dutch", "nl", "dut", "nld",
+ "engels", "english", "en", "eng",
+ "duits", "german", "de", "ger", "deu",
+ "frans", "french", "fr", "fre", "fra",
+ "spaans", "spanish", "es", "spa",
+ "italiaans", "italian", "it", "ita",
+ "portugees", "portuguese", "pt", "por",
+ "pools", "polish", "pl", "pol",
+ "russisch", "russian", "ru", "rus",
+ "japans", "japanese", "ja", "jpn",
+ "koreaans", "korean", "ko", "kor",
+ "chinees", "chinese", "zh", "chi", "zho",
+ )
+ val tokens = Regex("""[a-z]{2,}""").findAll(s).map { it.value }.toList()
+ for (k in known) {
+ if (s == k || tokens.contains(k)) return k
+ }
+ return tokens.firstOrNull { it.length in 2..3 }
+}
+
private fun prettyLanguage(code: String?): String? {
- val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
+ val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" && it != "unknown" }
+ ?: return null
return when (c) {
- "nl", "dut", "nld" -> "Nederlands"
- "en", "eng" -> "Engels"
- "de", "ger", "deu" -> "Duits"
- "fr", "fre", "fra" -> "Frans"
- "es", "spa" -> "Spaans"
- "it", "ita" -> "Italiaans"
- "pt", "por" -> "Portugees"
- "pl", "pol" -> "Pools"
- "ru", "rus" -> "Russisch"
- "ja", "jpn" -> "Japans"
- "ko", "kor" -> "Koreaans"
- "zh", "chi", "zho", "cmn" -> "Chinees"
- "ar", "ara" -> "Arabisch"
- "tr", "tur" -> "Turks"
- "sv", "swe" -> "Zweeds"
- "no", "nor", "nb", "nn" -> "Noors"
- "da", "dan" -> "Deens"
- "fi", "fin" -> "Fins"
- else -> c.uppercase()
+ "nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
+ "en", "eng", "english", "engels" -> "Engels"
+ "de", "ger", "deu", "german", "duits" -> "Duits"
+ "fr", "fre", "fra", "french", "frans" -> "Frans"
+ "es", "spa", "spanish", "spaans" -> "Spaans"
+ "it", "ita", "italian", "italiaans" -> "Italiaans"
+ "pt", "por", "portuguese", "portugees" -> "Portugees"
+ "pl", "pol", "polish", "pools" -> "Pools"
+ "ru", "rus", "russian", "russisch" -> "Russisch"
+ "ja", "jpn", "japanese", "japans" -> "Japans"
+ "ko", "kor", "korean", "koreaans" -> "Koreaans"
+ "zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
+ "ar", "ara", "arabic", "arabisch" -> "Arabisch"
+ "tr", "tur", "turkish", "turks" -> "Turks"
+ "sv", "swe", "swedish", "zweeds" -> "Zweeds"
+ "no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
+ "da", "dan", "danish", "deens" -> "Deens"
+ "fi", "fin", "finnish", "fins" -> "Fins"
+ else -> {
+ if (c.length <= 3) {
+ val display = java.util.Locale.forLanguageTag(c).getDisplayLanguage(java.util.Locale("nl"))
+ if (display.isNotBlank() && !display.equals(c, ignoreCase = true)) {
+ display.replaceFirstChar { it.uppercase() }
+ } else {
+ c.uppercase()
+ }
+ } else {
+ c.replaceFirstChar { it.uppercase() }
+ }
+ }
}
}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DevicesScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DevicesScreen.kt
new file mode 100644
index 0000000..cea4ee8
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DevicesScreen.kt
@@ -0,0 +1,408 @@
+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.PaddingValues
+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.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.runtime.Composable
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.LinkCodeParser
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.LinkedDevice
+import nl.vonas.mediacluster.tv.ui.components.McButton
+import nl.vonas.mediacluster.tv.ui.components.SectionLabel
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+import nl.vonas.mediacluster.tv.ui.theme.McDimens
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun DevicesScreen(
+ api: ApiClient,
+ initialCode: String? = null,
+ onBack: () -> Unit,
+ onConsumedPendingCode: () -> Unit = {},
+) {
+ var devices by remember { mutableStateOf>(emptyList()) }
+ var loading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var manualCode by remember { mutableStateOf(initialCode.orEmpty()) }
+ var approving by remember { mutableStateOf(false) }
+ var approveMessage by remember { mutableStateOf(null) }
+ var approveError by remember { mutableStateOf(null) }
+ var revokeTarget by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+
+ fun reload() {
+ scope.launch {
+ loading = true
+ error = null
+ try {
+ devices = api.listDevices()
+ } catch (e: Exception) {
+ error = e.message
+ } finally {
+ loading = false
+ }
+ }
+ }
+
+ fun approve(raw: String) {
+ val code = LinkCodeParser.normalize(raw)
+ if (code.length < 8) return
+ scope.launch {
+ approving = true
+ approveError = null
+ approveMessage = null
+ try {
+ val result = api.approveDeviceCode(code)
+ approveMessage = "${result.deviceName ?: "Apparaat"} is gekoppeld."
+ manualCode = ""
+ onConsumedPendingCode()
+ devices = api.listDevices()
+ } catch (e: Exception) {
+ approveError = e.message
+ } finally {
+ approving = false
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) { reload() }
+ LaunchedEffect(initialCode) {
+ if (!initialCode.isNullOrBlank()) {
+ manualCode = LinkCodeParser.normalize(initialCode)
+ }
+ }
+
+ BackHandler(onBack = onBack)
+
+ Box(Modifier.fillMaxSize().background(McColors.Bg)) {
+ LazyColumn(
+ Modifier
+ .fillMaxSize()
+ .statusBarsPadding(),
+ contentPadding = PaddingValues(
+ start = McDimens.screenPad,
+ end = McDimens.screenPad,
+ top = 12.dp,
+ bottom = 28.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ item {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "‹ Terug",
+ color = McColors.Accent,
+ fontSize = McDimens.body,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onBack)
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ )
+ Spacer(Modifier.weight(1f))
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "Apparaten",
+ color = Color.White,
+ fontSize = McDimens.titleScreen,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(16.dp))
+ SectionLabel("Nieuw apparaat")
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "Scan de QR op je TV of voer de code hieronder in.",
+ color = McColors.TextDim,
+ fontSize = McDimens.bodySmall,
+ )
+ Spacer(Modifier.height(10.dp))
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(McColors.BgElevated)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ OutlinedTextField(
+ value = manualCode,
+ onValueChange = { manualCode = LinkCodeParser.normalize(it) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { androidx.compose.material3.Text("Code") },
+ placeholder = { androidx.compose.material3.Text("ABCD-EFGH") },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = McColors.Accent,
+ unfocusedBorderColor = Color(0xFF44444E),
+ focusedLabelColor = McColors.TextDim,
+ unfocusedLabelColor = McColors.TextDim,
+ cursorColor = McColors.Accent,
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ ),
+ )
+ McButton(
+ if (approving) "Bezig…" else "Koppelen",
+ primary = true,
+ enabled = !approving && LinkCodeParser.normalize(manualCode).length >= 8,
+ ) {
+ approve(manualCode)
+ }
+ if (approveMessage != null) {
+ Text(approveMessage!!, color = Color(0xFF46D369), fontSize = McDimens.bodySmall)
+ }
+ if (approveError != null) {
+ Text(approveError!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+ SectionLabel("Gekoppelde apparaten")
+ Spacer(Modifier.height(4.dp))
+ if (error != null) {
+ Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
+ }
+ }
+
+ if (loading && devices.isEmpty()) {
+ item {
+ Text("Laden…", color = McColors.TextDim, fontSize = McDimens.body)
+ }
+ } else if (!loading && devices.isEmpty() && error == null) {
+ item {
+ Text("Nog geen apparaten.", color = McColors.TextDim, fontSize = McDimens.body)
+ }
+ } else {
+ items(devices, key = { it.id }) { device ->
+ DeviceRow(
+ device = device,
+ onRevoke = { revokeTarget = device },
+ )
+ }
+ }
+ }
+
+ revokeTarget?.let { target ->
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xCC000000))
+ .clickable { revokeTarget = null },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(340.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(McColors.BgElevated)
+ .clickable(enabled = false) {}
+ .padding(22.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Apparaat verwijderen?", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "${target.name} moet opnieuw worden gekoppeld om VMC te gebruiken.",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ McButton("Annuleren") { revokeTarget = null }
+ McButton("Verwijderen", primary = true) {
+ scope.launch {
+ try {
+ api.revokeDevice(target.id)
+ revokeTarget = null
+ devices = api.listDevices()
+ } catch (e: Exception) {
+ error = e.message
+ revokeTarget = null
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun DeviceRow(
+ device: LinkedDevice,
+ onRevoke: () -> Unit,
+) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(McColors.BgElevated)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ device.name,
+ color = Color.White,
+ fontSize = McDimens.body,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ )
+ if (device.current) {
+ Text(
+ "Dit apparaat",
+ color = McColors.Accent,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(999.dp))
+ .background(McColors.Accent.copy(alpha = 0.18f))
+ .padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+ }
+ Text(platformLabel(device.platform), color = McColors.TextDim, fontSize = McDimens.meta)
+ formatSeen(device.lastSeenAt)?.let {
+ Text("Laatst gezien: $it", color = McColors.TextDim, fontSize = McDimens.meta)
+ }
+ if (!device.current) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ "Verwijderen",
+ color = McColors.Danger,
+ fontSize = McDimens.bodySmall,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onRevoke)
+ .padding(vertical = 4.dp),
+ )
+ }
+ }
+}
+
+private fun platformLabel(platform: String?): String = when (platform?.lowercase()) {
+ "android_tv", "android-tv", "tv" -> "Android TV"
+ "android", "android_mobile" -> "Android"
+ "ios", "iphone", "ipad" -> "iOS"
+ "tizen" -> "Samsung TV"
+ else -> platform?.takeIf { it.isNotBlank() } ?: "Apparaat"
+}
+
+private fun formatSeen(iso: String?): String? {
+ if (iso.isNullOrBlank()) return null
+ return try {
+ val parser = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ val date = parser.parse(iso)
+ ?: SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }.parse(iso)
+ if (date == null) return iso
+ SimpleDateFormat("d MMM yyyy HH:mm", Locale("nl", "NL")).format(date)
+ } catch (_: Exception) {
+ iso
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun DeviceApproveDialog(
+ code: String,
+ canManage: Boolean,
+ busy: Boolean,
+ error: String?,
+ doneName: String?,
+ onApprove: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xCC000000))
+ .clickable(onClick = onDismiss),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(360.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(McColors.BgElevated)
+ .clickable(enabled = false) {}
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("Apparaat koppelen?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "Code $code\nBevestig om dit apparaat aan jouw account te koppelen.",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ )
+ if (doneName != null) {
+ Text("$doneName is gekoppeld.", color = Color(0xFF46D369), fontSize = 14.sp)
+ }
+ if (error != null) {
+ Text(error, color = McColors.Danger, fontSize = 14.sp)
+ }
+ if (!canManage) {
+ Text(
+ "Schakel naar het hoofdprofiel om apparaten te koppelen.",
+ color = McColors.TextDim,
+ fontSize = 13.sp,
+ )
+ }
+ if (doneName == null) {
+ McButton(
+ if (busy) "Bezig…" else "Goedkeuren",
+ primary = true,
+ enabled = canManage && !busy,
+ onClick = onApprove,
+ )
+ }
+ McButton("Sluiten", onClick = onDismiss)
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt
index 5561880..3d84f32 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt
@@ -90,7 +90,10 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@Composable
-fun McNav() {
+fun McNav(
+ pendingLinkCode: String? = null,
+ onPendingLinkCodeConsumed: () -> Unit = {},
+) {
val context = LocalContext.current
val store = remember { SessionStore(context) }
val api = remember { ApiClient(baseUrl = "") }
@@ -99,6 +102,11 @@ fun McNav() {
var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) }
var hasProfile by remember { mutableStateOf(false) }
+ var canManageDevices by remember { mutableStateOf(false) }
+ var showApproveDialog by remember { mutableStateOf(false) }
+ var approveBusy by remember { mutableStateOf(false) }
+ var approveError by remember { mutableStateOf(null) }
+ var approveDoneName by remember { mutableStateOf(null) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player)
@@ -110,6 +118,13 @@ fun McNav() {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
}
+ fun refreshOwnerFlag() {
+ scope.launch {
+ val me = runCatching { api.clientMe() }.getOrNull()
+ canManageDevices = me?.profile?.isOwner == true
+ }
+ }
+
LaunchedEffect(Unit) {
val base = store.apiBase.first()
val token = store.refreshToken.first()
@@ -120,21 +135,34 @@ fun McNav() {
if (me?.profile != null) {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
hasProfile = true
+ canManageDevices = me.profile.isOwner
} else {
store.setProfile(null)
hasProfile = false
+ canManageDevices = false
}
} else {
hasProfile = false
+ canManageDevices = false
}
ready = true
}
+ LaunchedEffect(pendingLinkCode, ready, authed, hasProfile) {
+ if (!ready || pendingLinkCode.isNullOrBlank()) return@LaunchedEffect
+ if (authed && hasProfile) {
+ showApproveDialog = true
+ approveError = null
+ approveDoneName = null
+ }
+ }
+
if (!ready) {
Splash()
return
}
+ Box(Modifier.fillMaxSize()) {
NavHost(
navController = nav,
startDestination = when {
@@ -173,6 +201,7 @@ fun McNav() {
onLoggedIn = {
authed = true
hasProfile = false
+ canManageDevices = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
},
)
@@ -190,21 +219,32 @@ fun McNav() {
) {
authed = true
hasProfile = false
+ canManageDevices = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
}
}
composable("profiles") {
ProfilesScreen(api = api, store = store, switcherMode = true) {
hasProfile = true
+ refreshOwnerFlag()
nav.navigate("main") { popUpTo("profiles") { inclusive = true } }
}
}
composable("profiles_manage") {
ProfilesScreen(api = api, store = store, switcherMode = false) {
hasProfile = true
+ refreshOwnerFlag()
nav.popBackStack()
}
}
+ composable("devices") {
+ DevicesScreen(
+ api = api,
+ initialCode = pendingLinkCode,
+ onBack = { nav.popBackStack() },
+ onConsumedPendingCode = onPendingLinkCodeConsumed,
+ )
+ }
composable("main") {
MainHub(
api = api,
@@ -218,6 +258,7 @@ fun McNav() {
onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId,
profileName = profileName,
+ canManageDevices = canManageDevices,
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onBrowse = { catalogId, _title ->
@@ -239,6 +280,7 @@ fun McNav() {
runCatching { api.clearProfile() }
store.setProfile(null)
hasProfile = false
+ canManageDevices = false
nav.navigate("profiles") {
popUpTo("main") { inclusive = true }
}
@@ -247,12 +289,17 @@ fun McNav() {
onManageProfiles = {
nav.navigate("profiles_manage")
},
+ onManageDevices = {
+ nav.navigate("devices")
+ },
onLogout = {
scope.launch {
store.setToken(null)
api.configure(store.apiBase.first(), null)
authed = false
hasProfile = false
+ canManageDevices = false
+ onPendingLinkCodeConsumed()
nav.navigate("server") { popUpTo(0) { inclusive = true } }
}
},
@@ -344,6 +391,41 @@ fun McNav() {
)
}
}
+
+ if (showApproveDialog && !pendingLinkCode.isNullOrBlank()) {
+ DeviceApproveDialog(
+ code = pendingLinkCode,
+ canManage = canManageDevices,
+ busy = approveBusy,
+ error = approveError,
+ doneName = approveDoneName,
+ onApprove = {
+ scope.launch {
+ approveBusy = true
+ approveError = null
+ try {
+ val result = api.approveDeviceCode(pendingLinkCode)
+ approveDoneName = result.deviceName ?: "Apparaat"
+ delay(900)
+ showApproveDialog = false
+ onPendingLinkCodeConsumed()
+ approveDoneName = null
+ } catch (e: Exception) {
+ approveError = e.message
+ } finally {
+ approveBusy = false
+ }
+ }
+ },
+ onDismiss = {
+ showApproveDialog = false
+ onPendingLinkCodeConsumed()
+ approveError = null
+ approveDoneName = null
+ },
+ )
+ }
+ }
}
@Composable
@@ -359,6 +441,7 @@ private fun MainHub(
onTvFocusContent: () -> Unit,
profileAvatarId: String?,
profileName: String?,
+ canManageDevices: Boolean,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit,
@@ -366,6 +449,7 @@ private fun MainHub(
onPlayLive: (Int) -> Unit,
onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit,
+ onManageDevices: () -> Unit,
onLogout: () -> Unit,
) {
var iptvEnabled by remember { mutableStateOf(false) }
@@ -539,12 +623,14 @@ private fun MainHub(
api = api,
profileAvatarId = profileAvatarId,
profileName = profileName,
+ canManageDevices = canManageDevices,
onBack = {
onTabChange(MainTab.Home)
focusTopNav()
},
onSwitchProfile = onSwitchProfile,
onManageProfiles = onManageProfiles,
+ onManageDevices = onManageDevices,
onLogout = onLogout,
)
}
@@ -1139,9 +1225,11 @@ private fun SettingsScreen(
api: ApiClient,
profileAvatarId: String? = null,
profileName: String? = null,
+ canManageDevices: Boolean = false,
onBack: () -> Unit,
onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {},
+ onManageDevices: () -> Unit = {},
onLogout: () -> Unit = {},
) {
var prefs by remember { mutableStateOf>(emptyList()) }
@@ -1262,6 +1350,31 @@ private fun SettingsScreen(
fontWeight = FontWeight.SemiBold,
)
}
+ if (canManageDevices) {
+ Spacer(Modifier.height(8.dp))
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(McColors.BgElevated)
+ .clickable(onClick = onManageDevices)
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ ) {
+ Column {
+ Text(
+ "Apparaten",
+ color = Color.White,
+ fontSize = McDimens.body,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ "Koppelen & beheren",
+ color = McColors.TextDim,
+ fontSize = McDimens.meta,
+ )
+ }
+ }
+ }
Spacer(Modifier.height(8.dp))
Box(
Modifier
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
index f9c212c..f877e3b 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -108,6 +109,8 @@ fun PlayerScreen(
var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf>(emptyList()) }
var textTracks by remember { mutableStateOf>(emptyList()) }
+ var embeddedHints by remember { mutableStateOf>(emptyList()) }
+ val embeddedHintsState = rememberUpdatedState(embeddedHints)
var menu by remember { mutableStateOf(null) } // audio | text | os
var osHits by remember { mutableStateOf>(emptyList()) }
var osLoading by remember { mutableStateOf(false) }
@@ -209,6 +212,7 @@ fun PlayerScreen(
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
+ embeddedHints = play.embeddedTracks
player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
@@ -248,8 +252,8 @@ fun PlayerScreen(
if (state == Player.STATE_READY) {
player.volume = 1f
player.ensureAudioSelected()
- audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
- textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
}
}
@@ -258,8 +262,8 @@ fun PlayerScreen(
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
- audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
- textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
player.ensureAudioSelected()
player.volume = 1f
}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt
index d68b434..dfca999 100644
--- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
@@ -45,13 +46,14 @@ fun McButton(
modifier: Modifier = Modifier,
primary: Boolean = false,
compact: Boolean = false,
+ enabled: Boolean = true,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
- targetValue = if (pressed) 0.96f else 1f,
+ targetValue = if (pressed && enabled) 0.96f else 1f,
animationSpec = tween(100),
label = "btnScale",
)
@@ -64,10 +66,17 @@ fun McButton(
.graphicsLayer {
scaleX = scale
scaleY = scale
+ alpha = if (enabled) 1f else 0.4f
}
.clip(RoundedCornerShape(4.dp))
.background(bg)
- .clickable(interactionSource = interaction, indication = null, onClick = onClick)
+ .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
+ .clickable(
+ enabled = enabled,
+ interactionSource = interaction,
+ indication = null,
+ onClick = onClick,
+ )
.padding(horizontal = hPad, vertical = vPad),
contentAlignment = Alignment.Center,
) {
diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts
index a13ddc8..729eb80 100644
--- a/apps/android-tv/app/build.gradle.kts
+++ b/apps/android-tv/app/build.gradle.kts
@@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
- versionCode = 82
- versionName = "0.14.19"
+ versionCode = 86
+ versionName = "0.14.23"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
index 14fca2f..917e482 100644
--- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
@@ -172,6 +172,21 @@ class ApiClient(
label = s.optString("label").ifBlank { "Lokaal" },
)
},
+ embeddedTracks = o.optJSONArray("embeddedTracks")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val t = arr.optJSONObject(i) ?: continue
+ add(
+ EmbeddedTrackInfo(
+ type = t.optString("type").ifBlank { "unknown" },
+ language = t.optString("language").ifBlank { null },
+ title = t.optString("title").ifBlank { null },
+ codec = t.optString("codec").ifBlank { null },
+ )
+ )
+ }
+ }
+ } ?: emptyList(),
)
}
}
@@ -592,6 +607,14 @@ data class PlayResult(
val sessionId: String,
val mediaFileId: String,
val defaultSubtitle: DefaultSubtitle? = null,
+ val embeddedTracks: List = emptyList(),
+)
+
+data class EmbeddedTrackInfo(
+ val type: String,
+ val language: String? = null,
+ val title: String? = null,
+ val codec: String? = null,
)
data class DefaultSubtitle(
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
index 554d5d3..a56154f 100644
--- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
@@ -241,39 +241,59 @@ fun liveMediaItem(url: String, format: String): MediaItem {
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
-fun ExoPlayer.listTracks(type: Int): List {
+fun ExoPlayer.listTracks(type: Int, embeddedHints: List = emptyList()): List {
val out = mutableListOf()
val groups = currentTracks.groups
+ // ExoPlayer zet elke text/audio track vaak in een eigen group → ti is dan altijd 0.
+ // Gebruik een globale ordinal voor unieke "Ondertitel N" / "Audio N" labels.
+ val hintPool = when (type) {
+ C.TRACK_TYPE_TEXT -> embeddedHints.filter { it.type.equals("subtitle", true) }
+ C.TRACK_TYPE_AUDIO -> embeddedHints.filter { it.type.equals("audio", true) }
+ else -> emptyList()
+ }
+ var ordinal = 0
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != type) continue
for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti)
+ val hint = hintPool.getOrNull(ordinal)
val label = if (type == C.TRACK_TYPE_TEXT) {
- formatTextTrackLabel(format, ti)
+ formatTextTrackLabel(format, ordinal, hint)
} else {
- formatAudioTrackLabel(format, ti)
+ formatAudioTrackLabel(format, ordinal, hint)
}
+ ordinal++
out += PlayerTrack(gi, ti, label, type)
}
}
return out
}
-private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
+private fun formatAudioTrackLabel(
+ format: androidx.media3.common.Format,
+ index: Int,
+ hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
+): String {
val mime = format.sampleMimeType ?: ""
- val lang = prettyLanguage(format.language)
+ val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
+ val label = format.label?.takeIf { !isGenericTrackLabel(it) }
+ ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && prettyLanguage(extractLanguageToken(it)) == null }
return listOfNotNull(
- format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
lang,
+ label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
shortMime(mime).takeIf { it.isNotBlank() },
ch,
- ).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
+ ).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
}
-private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
- val lang = prettyLanguage(format.language)
+private fun formatTextTrackLabel(
+ format: androidx.media3.common.Format,
+ index: Int,
+ hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
+): String {
+ val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
val kind = when {
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
format.sampleMimeType?.contains("ssa", true) == true ||
@@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
else -> null
}
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
- val label = format.label?.takeIf {
- it.isNotBlank() &&
- !it.equals("und", true) &&
- !it.equals("sub", true) &&
- !it.equals("subtitle", true)
- }
- return listOfNotNull(label, lang, kind, forced)
+ val label = format.label?.takeIf { !isGenericTrackLabel(it) }
+ ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
+ return listOfNotNull(
+ lang,
+ label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
+ kind,
+ forced,
+ )
.distinct()
.joinToString(" · ")
.ifBlank { "Ondertitel ${index + 1}" }
}
+/** Taal uit Format.language, anders uit label/id (Dutch, nl, Track-junk negeren). */
+private fun resolveLanguage(format: androidx.media3.common.Format): String? {
+ prettyLanguage(format.language)?.let { return it }
+ prettyLanguage(extractLanguageToken(format.label))?.let { return it }
+ prettyLanguage(extractLanguageToken(format.id))?.let { return it }
+ return null
+}
+
+private fun isGenericTrackLabel(raw: String?): Boolean {
+ val s = raw?.trim().orEmpty()
+ if (s.isEmpty()) return true
+ val lower = s.lowercase()
+ if (lower in setOf("und", "unknown", "null", "sub", "subtitle", "subs")) return true
+ if (lower.contains("text/") || lower.startsWith("http")) return true
+ if (Regex("""^track\s*\d+$""").matches(lower)) return true
+ if (Regex("""^subtitle\s*\d+$""").matches(lower)) return true
+ if (Regex("""^ondertitel\s*\d+$""").matches(lower)) return true
+ if (Regex("""^audio\s*\d+$""").matches(lower)) return true
+ return false
+}
+
+private fun extractLanguageToken(raw: String?): String? {
+ val s = raw?.trim()?.lowercase().orEmpty()
+ if (s.isEmpty() || isGenericTrackLabel(s)) return null
+ val known = listOf(
+ "nederlands", "dutch", "nl", "dut", "nld",
+ "engels", "english", "en", "eng",
+ "duits", "german", "de", "ger", "deu",
+ "frans", "french", "fr", "fre", "fra",
+ "spaans", "spanish", "es", "spa",
+ "italiaans", "italian", "it", "ita",
+ "portugees", "portuguese", "pt", "por",
+ "pools", "polish", "pl", "pol",
+ "russisch", "russian", "ru", "rus",
+ "japans", "japanese", "ja", "jpn",
+ "koreaans", "korean", "ko", "kor",
+ "chinees", "chinese", "zh", "chi", "zho",
+ )
+ val tokens = Regex("""[a-z]{2,}""").findAll(s).map { it.value }.toList()
+ for (k in known) {
+ if (s == k || tokens.contains(k)) return k
+ }
+ return tokens.firstOrNull { it.length in 2..3 }
+}
+
private fun prettyLanguage(code: String?): String? {
- val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
+ val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" && it != "unknown" }
+ ?: return null
return when (c) {
- "nl", "dut", "nld" -> "Nederlands"
- "en", "eng" -> "Engels"
- "de", "ger", "deu" -> "Duits"
- "fr", "fre", "fra" -> "Frans"
- "es", "spa" -> "Spaans"
- "it", "ita" -> "Italiaans"
- "pt", "por" -> "Portugees"
- "pl", "pol" -> "Pools"
- "ru", "rus" -> "Russisch"
- "ja", "jpn" -> "Japans"
- "ko", "kor" -> "Koreaans"
- "zh", "chi", "zho", "cmn" -> "Chinees"
- "ar", "ara" -> "Arabisch"
- "tr", "tur" -> "Turks"
- "sv", "swe" -> "Zweeds"
- "no", "nor", "nb", "nn" -> "Noors"
- "da", "dan" -> "Deens"
- "fi", "fin" -> "Fins"
- else -> c.uppercase()
+ "nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
+ "en", "eng", "english", "engels" -> "Engels"
+ "de", "ger", "deu", "german", "duits" -> "Duits"
+ "fr", "fre", "fra", "french", "frans" -> "Frans"
+ "es", "spa", "spanish", "spaans" -> "Spaans"
+ "it", "ita", "italian", "italiaans" -> "Italiaans"
+ "pt", "por", "portuguese", "portugees" -> "Portugees"
+ "pl", "pol", "polish", "pools" -> "Pools"
+ "ru", "rus", "russian", "russisch" -> "Russisch"
+ "ja", "jpn", "japanese", "japans" -> "Japans"
+ "ko", "kor", "korean", "koreaans" -> "Koreaans"
+ "zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
+ "ar", "ara", "arabic", "arabisch" -> "Arabisch"
+ "tr", "tur", "turkish", "turks" -> "Turks"
+ "sv", "swe", "swedish", "zweeds" -> "Zweeds"
+ "no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
+ "da", "dan", "danish", "deens" -> "Deens"
+ "fi", "fin", "finnish", "fins" -> "Fins"
+ else -> {
+ if (c.length <= 3) {
+ val display = java.util.Locale.forLanguageTag(c).getDisplayLanguage(java.util.Locale("nl"))
+ if (display.isNotBlank() && !display.equals(c, ignoreCase = true)) {
+ display.replaceFirstChar { it.uppercase() }
+ } else {
+ c.uppercase()
+ }
+ } else {
+ c.replaceFirstChar { it.uppercase() }
+ }
+ }
}
}
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
index aa62baa..e8964c1 100644
--- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
@@ -31,6 +31,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -76,7 +78,6 @@ import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64
-import androidx.compose.runtime.rememberCoroutineScope
import java.io.File
@OptIn(ExperimentalTvMaterial3Api::class)
@@ -105,6 +106,8 @@ fun PlayerScreen(
var durationMs by remember { mutableLongStateOf(0L) }
var audioTracks by remember { mutableStateOf>(emptyList()) }
var textTracks by remember { mutableStateOf>(emptyList()) }
+ var embeddedHints by remember { mutableStateOf>(emptyList()) }
+ val embeddedHintsState = rememberUpdatedState(embeddedHints)
var menu by remember { mutableStateOf(null) } // audio | text | os
var osHits by remember { mutableStateOf>(emptyList()) }
var osLoading by remember { mutableStateOf(false) }
@@ -206,6 +209,7 @@ fun PlayerScreen(
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
+ embeddedHints = play.embeddedTracks
player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
@@ -245,8 +249,8 @@ fun PlayerScreen(
if (state == Player.STATE_READY) {
player.volume = 1f
player.ensureAudioSelected()
- audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
- textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
}
}
@@ -255,8 +259,8 @@ fun PlayerScreen(
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
- audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
- textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
player.ensureAudioSelected()
player.volume = 1f
}
diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties
index 03b8819..99f79f5 100644
--- a/apps/android-tv/gradle.properties
+++ b/apps/android-tv/gradle.properties
@@ -1,4 +1,4 @@
-org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx5120m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.parallel=false
org.gradle.workers.max=1
org.gradle.caching=true
diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj
index a575971..368760f 100644
--- a/apps/ios/VMC.xcodeproj/project.pbxproj
+++ b/apps/ios/VMC.xcodeproj/project.pbxproj
@@ -317,7 +317,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 10;
+ CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -332,7 +332,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.4.6;
+ MARKETING_VERSION = 0.4.7;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -350,7 +350,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 10;
+ CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -365,7 +365,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
- MARKETING_VERSION = 0.4.6;
+ MARKETING_VERSION = 0.4.7;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift
index a298c05..46f7d44 100644
--- a/apps/ios/VMC/Data/AppModel.swift
+++ b/apps/ios/VMC/Data/AppModel.swift
@@ -188,8 +188,7 @@ final class AppModel: ObservableObject {
let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" }
if cleaned.contains("-") { return cleaned }
if cleaned.count == 8 {
- let i = cleaned.index(cleaned.startIndex, offsetBy: 4)
- return "\(cleaned[..i])-\(cleaned[i...])"
+ return "\(cleaned.prefix(4))-\(cleaned.suffix(4))"
}
return cleaned
}
diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift
index 4b196fc..851feb4 100644
--- a/apps/ios/VMC/Playback/VLCPlayerController.swift
+++ b/apps/ios/VMC/Playback/VLCPlayerController.swift
@@ -3,7 +3,7 @@ import MobileVLCKit
import SwiftUI
import UIKit
-final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate {
+final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate {
@Published var isPlaying = false
@Published var position: Float = 0
@Published var timeText = "0:00"
@@ -36,6 +36,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
subtitleTracks = []
audioTracks = []
let media = VLCMedia(url: url)
+ media.delegate = self
media.addOption(":network-caching=2500")
media.addOption(":http-reconnect")
media.addOption(":avcodec-hw=any")
@@ -45,6 +46,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1")
mediaPlayer.media = media
+ // Async parse zodat tracksInformation language/title beschikbaar wordt.
+ // MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift.
+ media.parse()
mediaPlayer.play()
applySubtitleStyle()
if startMs > 2000 {
@@ -69,21 +73,25 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
errorMessage = nil
stateText = "Live laden…"
let media = VLCMedia(url: url)
+ media.delegate = self
media.addOption(":network-caching=1200")
media.addOption(":http-reconnect")
media.addOption(":live-caching=1000")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
mediaPlayer.media = media
+ media.parse()
mediaPlayer.play()
applySubtitleStyle()
if let fallback {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, !self.isPlaying || self.hasError else { return }
let fb = VLCMedia(url: fallback)
+ fb.delegate = self
fb.addOption(":network-caching=1200")
fb.addOption(":http-reconnect")
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
self.mediaPlayer.media = fb
+ fb.parse()
self.mediaPlayer.play()
self.applySubtitleStyle()
}
@@ -141,41 +149,74 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
func refreshTracks() {
+ let meta = trackMetaById()
+ var textOrderHints: [String?] = []
+ var audioOrderHints: [String?] = []
+ for info in (mediaPlayer.media?.tracksInformation as? [Any]) ?? [] {
+ guard let dict = info as? [AnyHashable: Any] else { continue }
+ let type = ((dict[VLCMediaTracksInformationType] as? String)
+ ?? (dict["type"] as? String)
+ ?? "").lowercased()
+ let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
+ ?? (dict["language"] as? String)
+ let desc = (dict[VLCMediaTracksInformationDescription] as? String)
+ ?? (dict["description"] as? String)
+ let hint = lang ?? desc
+ if type.contains("text") {
+ textOrderHints.append(hint)
+ } else if type.contains("audio") {
+ audioOrderHints.append(hint)
+ }
+ }
+
var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")]
+ var textOrd = 0
if let names = mediaPlayer.videoSubTitlesNames as? [Any],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] {
for (i, raw) in indexes.enumerated() {
let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1)
if idx < 0 { continue }
- let label: String
- if i < names.count, let n = names[i] as? String, !n.isEmpty {
- label = n
- } else {
- label = "Ondertitel \(subs.count)"
- }
- // Skip junk mime-like labels
- if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") {
- subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)"))
- } else {
- subs.append(PlayerTrack(id: idx, name: label))
- }
+ let rawName: String = {
+ if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
+ return ""
+ }()
+ let fromId = meta[idx]
+ let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil
+ textOrd += 1
+ let label = Self.displayTrackLabel(
+ rawName: rawName,
+ fallbackIndex: subs.count,
+ kind: .subtitle,
+ languageHint: fromId?.language ?? fromOrder,
+ descriptionHint: fromId?.description
+ )
+ subs.append(PlayerTrack(id: idx, name: label))
}
}
subtitleTracks = subs
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
var audios: [PlayerTrack] = []
+ var audioOrd = 0
if let names = mediaPlayer.audioTrackNames as? [Any],
let indexes = mediaPlayer.audioTrackIndexes as? [Any] {
for (i, raw) in indexes.enumerated() {
let idx = (raw as? NSNumber)?.int32Value ?? -1
if idx < 0 { continue }
- let label: String
- if i < names.count, let n = names[i] as? String, !n.isEmpty {
- label = n
- } else {
- label = "Audio \(audios.count + 1)"
- }
+ let rawName: String = {
+ if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
+ return ""
+ }()
+ let fromId = meta[idx]
+ let fromOrder = audioOrd < audioOrderHints.count ? audioOrderHints[audioOrd] : nil
+ audioOrd += 1
+ let label = Self.displayTrackLabel(
+ rawName: rawName,
+ fallbackIndex: audios.count + 1,
+ kind: .audio,
+ languageHint: fromId?.language ?? fromOrder,
+ descriptionHint: fromId?.description
+ )
audios.append(PlayerTrack(id: idx, name: label))
}
}
@@ -183,6 +224,138 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
currentAudioId = mediaPlayer.currentAudioTrackIndex
}
+ private struct TrackMeta {
+ var language: String?
+ var description: String?
+ }
+
+ private func trackMetaById() -> [Int32: TrackMeta] {
+ guard let media = mediaPlayer.media else { return [:] }
+ var out: [Int32: TrackMeta] = [:]
+ for info in (media.tracksInformation as? [Any]) ?? [] {
+ guard let dict = info as? [AnyHashable: Any] else { continue }
+ let tid = (dict[VLCMediaTracksInformationId] as? NSNumber)?.int32Value
+ ?? (dict["id"] as? NSNumber)?.int32Value
+ guard let tid else { continue }
+ let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
+ ?? (dict["language"] as? String)
+ let desc = (dict[VLCMediaTracksInformationDescription] as? String)
+ ?? (dict["description"] as? String)
+ out[tid] = TrackMeta(language: lang, description: desc)
+ }
+ return out
+ }
+
+ private enum TrackKind { case subtitle, audio }
+
+ private static func displayTrackLabel(
+ rawName: String,
+ fallbackIndex: Int,
+ kind: TrackKind,
+ languageHint: String?,
+ descriptionHint: String?
+ ) -> String {
+ let prettyLang =
+ prettyLanguage(languageHint)
+ ?? prettyLanguage(extractLanguageToken(from: descriptionHint))
+ ?? prettyLanguage(extractLanguageToken(from: rawName))
+
+ if isGenericTrackLabel(rawName)
+ || rawName.uppercased().contains("TEXT/")
+ || rawName.uppercased().hasPrefix("HTTP")
+ {
+ if let prettyLang {
+ if let descriptionHint,
+ !descriptionHint.isEmpty,
+ !isGenericTrackLabel(descriptionHint),
+ !descriptionHint.localizedCaseInsensitiveContains(prettyLang)
+ {
+ return "\(prettyLang) · \(descriptionHint)"
+ }
+ return prettyLang
+ }
+ return kind == .subtitle ? "Ondertitel \(fallbackIndex)" : "Audio \(fallbackIndex)"
+ }
+
+ if let prettyLang, !rawName.localizedCaseInsensitiveContains(prettyLang) {
+ return "\(prettyLang) · \(rawName)"
+ }
+ return rawName
+ }
+
+ private static func isGenericTrackLabel(_ raw: String?) -> Bool {
+ let s = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if s.isEmpty { return true }
+ let lower = s.lowercased()
+ if lower == "und" || lower == "unknown" || lower == "null" || lower == "sub" || lower == "subtitle" {
+ return true
+ }
+ if lower.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil { return true }
+ if lower.range(of: #"^subtitle\s*\d+$"#, options: .regularExpression) != nil { return true }
+ if lower.range(of: #"^ondertitel\s*\d+$"#, options: .regularExpression) != nil { return true }
+ if lower.range(of: #"^audio\s*\d+$"#, options: .regularExpression) != nil { return true }
+ return false
+ }
+
+ private static func extractLanguageToken(from raw: String?) -> String? {
+ guard let raw, !raw.isEmpty else { return nil }
+ let lower = raw.lowercased()
+ let known = [
+ "nederlands", "dutch", "nl", "dut", "nld",
+ "engels", "english", "en", "eng",
+ "duits", "german", "de", "ger", "deu",
+ "frans", "french", "fr", "fre", "fra",
+ "spaans", "spanish", "es", "spa",
+ "italiaans", "italian", "it", "ita",
+ "portugees", "portuguese", "pt", "por",
+ "pools", "polish", "pl", "pol",
+ "russisch", "russian", "ru", "rus",
+ "japans", "japanese", "ja", "jpn",
+ "koreaans", "korean", "ko", "kor",
+ "chinees", "chinese", "zh", "chi", "zho",
+ ]
+ let tokens = lower.split(whereSeparator: { !$0.isLetter }).map(String.init)
+ for token in known where tokens.contains(token) || lower == token {
+ return token
+ }
+ if let match = lower.range(of: #"\b[a-z]{2,3}\b"#, options: .regularExpression) {
+ return String(lower[match])
+ }
+ return nil
+ }
+
+ private static func prettyLanguage(_ code: String?) -> String? {
+ let c = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !c.isEmpty, c != "und", c != "null", c != "unknown" else { return nil }
+ switch c {
+ case "nl", "dut", "nld", "dutch", "nederlands": return "Nederlands"
+ case "en", "eng", "english", "engels": return "Engels"
+ case "de", "ger", "deu", "german", "duits": return "Duits"
+ case "fr", "fre", "fra", "french", "frans": return "Frans"
+ case "es", "spa", "spanish", "spaans": return "Spaans"
+ case "it", "ita", "italian", "italiaans": return "Italiaans"
+ case "pt", "por", "portuguese", "portugees": return "Portugees"
+ case "pl", "pol", "polish", "pools": return "Pools"
+ case "ru", "rus", "russian", "russisch": return "Russisch"
+ case "ja", "jpn", "japanese", "japans": return "Japans"
+ case "ko", "kor", "korean", "koreaans": return "Koreaans"
+ case "zh", "chi", "zho", "cmn", "chinese", "chinees": return "Chinees"
+ case "ar", "ara", "arabic", "arabisch": return "Arabisch"
+ case "tr", "tur", "turkish", "turks": return "Turks"
+ case "sv", "swe", "swedish", "zweeds": return "Zweeds"
+ case "no", "nor", "nb", "nn", "norwegian", "noors": return "Noors"
+ case "da", "dan", "danish", "deens": return "Deens"
+ case "fi", "fin", "finnish", "fins": return "Fins"
+ default:
+ if c.count <= 3 {
+ let name = Locale(identifier: "nl_NL").localizedString(forLanguageCode: c)
+ if let name, !name.isEmpty, name.lowercased() != c { return name.capitalized }
+ return c.uppercased()
+ }
+ return c.capitalized
+ }
+ }
+
func stop() {
tick?.cancel()
tick = nil
@@ -216,6 +389,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
}
}
+ func mediaDidFinishParsing(_ aMedia: VLCMedia) {
+ DispatchQueue.main.async { [weak self] in
+ self?.refreshTracks()
+ }
+ }
+
private func startTicker() {
tick?.cancel()
tick = Timer.publish(every: 0.4, on: .main, in: .common)
diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift
index 9cd9592..cda4d22 100644
--- a/apps/ios/VMC/UI/SettingsScreen.swift
+++ b/apps/ios/VMC/UI/SettingsScreen.swift
@@ -90,7 +90,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
- LabeledContent("Versie", value: "0.4.6")
+ LabeledContent("Versie", value: "0.4.7")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
}
.listRowBackground(McColors.bgElevated)
diff --git a/apps/ios/scripts/VMC-install-iPhone.command b/apps/ios/scripts/VMC-install-iPhone.command
new file mode 100644
index 0000000..90fdf76
--- /dev/null
+++ b/apps/ios/scripts/VMC-install-iPhone.command
@@ -0,0 +1,9 @@
+#!/bin/bash
+exec > /tmp/vmc-ios-gui.log 2>&1
+echo "START $(date)"
+security unlock-keychain ~/Library/Keychains/login.keychain-db
+bash ~/Code/mediacluster/apps/ios/scripts/vmc-ios-device-build.sh
+EC=$?
+echo "EXIT:$EC"
+echo "$EC" > /tmp/vmc-ios-gui.done
+exit "$EC"
diff --git a/apps/ios/scripts/vmc-ios-device-build.sh b/apps/ios/scripts/vmc-ios-device-build.sh
new file mode 100644
index 0000000..58fa0e4
--- /dev/null
+++ b/apps/ios/scripts/vmc-ios-device-build.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+set -euo pipefail
+cd ~/Code/mediacluster/apps/ios
+mkdir -p build
+security list-keychains -s ~/Library/Keychains/login.keychain-db /Library/Keychains/System.keychain || true
+echo "BUILD_START $(date)"
+xcodebuild -project VMC.xcodeproj -scheme VMC -configuration Debug \
+ -destination "platform=iOS,id=00008150-00040DC10250C01C" \
+ -allowProvisioningUpdates \
+ DEVELOPMENT_TEAM=X25NYX3K49 \
+ CODE_SIGN_STYLE=Automatic \
+ -derivedDataPath "$HOME/Code/mediacluster/apps/ios/build/DerivedData" \
+ build 2>&1 | tee build/device-build.log
+EC=${PIPESTATUS[0]}
+echo "BUILD_EXIT:$EC"
+if [ "$EC" -ne 0 ]; then
+ echo "---- last errors ----"
+ grep -E "error:|FAILED" build/device-build.log | tail -40 || true
+ exit "$EC"
+fi
+APP=$(find build/DerivedData/Build/Products -name "VMC.app" -type d | head -1)
+echo "APP=$APP"
+xcrun devicectl device install app --device 00008150-00040DC10250C01C "$APP"
+echo "INSTALL_OK $(date)"
diff --git a/apps/master-api/src/playback/service.ts b/apps/master-api/src/playback/service.ts
index 795f514..714101e 100644
--- a/apps/master-api/src/playback/service.ts
+++ b/apps/master-api/src/playback/service.ts
@@ -7,17 +7,19 @@ import {
import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config";
+import type { MediaTrackInfo } from "@media-cluster/protocol";
-type SessionResult = {
+export type SessionResult = {
sessionId: string;
streamUrl: string;
token: string;
nodeId: string;
mediaFileId: string;
+ tracks?: MediaTrackInfo[];
};
/** In-memory play tokens so concurrent Stremio probes reuse one session. */
-const playTokens = new Map();
+const playTokens = new Map();
const createLocks = new Map>();
function sessionKey(addonTokenId: string | undefined, mediaFileId: string): string {
@@ -94,6 +96,7 @@ export class PlaybackService {
token: cached.token,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
+ tracks: cached.tracks,
};
}
@@ -140,7 +143,7 @@ export class PlaybackService {
},
});
- const acked = await nodeConnectionManager.createPlaybackSessionAcked(mediaFile.nodeId, {
+ const ack = await nodeConnectionManager.createPlaybackSessionAcked(mediaFile.nodeId, {
sessionId,
tokenHash,
localFileId: mediaFile.localFileId,
@@ -148,7 +151,7 @@ export class PlaybackService {
absoluteExpiresAt: absoluteExpiresAt.toISOString(),
});
- if (!acked) {
+ if (!ack?.ok) {
await prisma.playbackSession.update({
where: { id: sessionId },
data: { status: "REVOKED", revoked: true },
@@ -156,7 +159,8 @@ export class PlaybackService {
throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503);
}
- playTokens.set(key, { token, sessionId });
+ const tracks = Array.isArray(ack.tracks) ? ack.tracks : undefined;
+ playTokens.set(key, { token, sessionId, tracks });
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
return {
@@ -165,6 +169,7 @@ export class PlaybackService {
token,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
+ tracks,
};
}
diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts
index 86f3ba3..2d6922a 100644
--- a/apps/master-api/src/viewer/service.ts
+++ b/apps/master-api/src/viewer/service.ts
@@ -2207,6 +2207,7 @@ export class ViewerService {
sessionId: session.sessionId,
mediaFileId: session.mediaFileId,
defaultSubtitle,
+ embeddedTracks: session.tracks ?? [],
};
}
diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts
index e2ca02d..0a5b6b0 100644
--- a/apps/master-api/src/websocket/manager.ts
+++ b/apps/master-api/src/websocket/manager.ts
@@ -33,7 +33,7 @@ interface NodeConnection {
}
interface PendingAck {
- resolve: (ok: boolean) => void;
+ resolve: (payload: CreatePlaybackSessionAckPayload) => void;
timer: ReturnType;
}
@@ -454,7 +454,7 @@ class NodeConnectionManager {
if (!pending) return;
clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId);
- pending.resolve(payload.ok);
+ pending.resolve(payload);
}
private handleImportMediaAck(payload: ImportMediaAckPayload): void {
@@ -686,14 +686,14 @@ class NodeConnectionManager {
nodeId: string,
payload: CreatePlaybackSessionPayload,
timeoutMs = 12000
- ): Promise {
- if (!this.isOnline(nodeId)) return false;
+ ): Promise {
+ if (!this.isOnline(nodeId)) return null;
- const acked = new Promise((resolve) => {
+ const acked = new Promise((resolve) => {
const timer = setTimeout(() => {
// Fail closed: never hand Stremio a node URL without a confirmed session.
this.pendingSessionAcks.delete(payload.sessionId);
- resolve(false);
+ resolve(null);
}, timeoutMs);
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
});
@@ -705,7 +705,7 @@ class NodeConnectionManager {
clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId);
}
- return false;
+ return null;
}
return acked;
diff --git a/node/media-node/internal/control/client.go b/node/media-node/internal/control/client.go
index 4523bd3..57bb140 100644
--- a/node/media-node/internal/control/client.go
+++ b/node/media-node/internal/control/client.go
@@ -21,6 +21,7 @@ import (
"github.com/sthmedia/media-node/internal/database"
"github.com/sthmedia/media-node/internal/health"
"github.com/sthmedia/media-node/internal/importmedia"
+ "github.com/sthmedia/media-node/internal/mediainfo"
"github.com/sthmedia/media-node/internal/scanner"
"github.com/sthmedia/media-node/internal/streaming"
)
@@ -346,11 +347,21 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
} else {
log.Printf("Playback session created: %s file=%s", p.SessionID, p.LocalFileID)
}
- ack := controlMessage("CREATE_PLAYBACK_SESSION_ACK", map[string]interface{}{
+ ackPayload := map[string]interface{}{
"sessionId": p.SessionID,
"ok": ok,
"error": errMsg,
- })
+ }
+ if ok {
+ if file, ferr := c.store.GetFile(p.LocalFileID); ferr == nil && file != nil && file.Path != "" {
+ if tracks, perr := mediainfo.ProbeFile(file.Path); perr == nil && len(tracks) > 0 {
+ ackPayload["tracks"] = tracks
+ } else if perr != nil {
+ log.Printf("track probe failed for %s: %v", file.Path, perr)
+ }
+ }
+ }
+ ack := controlMessage("CREATE_PLAYBACK_SESSION_ACK", ackPayload)
if werr := c.write(ack); werr != nil {
log.Printf("failed to send session ACK: %v", werr)
}
diff --git a/node/media-node/internal/mediainfo/matroska.go b/node/media-node/internal/mediainfo/matroska.go
new file mode 100644
index 0000000..21c4b13
--- /dev/null
+++ b/node/media-node/internal/mediainfo/matroska.go
@@ -0,0 +1,302 @@
+package mediainfo
+
+import (
+ "encoding/binary"
+ "io"
+ "os"
+ "strings"
+)
+
+// Track is a media stream with optional Matroska language/title.
+type Track struct {
+ Type string `json:"type"` // video | audio | subtitle | unknown
+ Number int `json:"number"`
+ Language string `json:"language,omitempty"`
+ Title string `json:"title,omitempty"`
+ Codec string `json:"codec,omitempty"`
+}
+
+// ProbeFile returns embedded tracks. For MKV/WebM we parse EBML; other containers return nil.
+func ProbeFile(path string) ([]Track, error) {
+ lower := strings.ToLower(path)
+ if strings.HasSuffix(lower, ".mkv") || strings.HasSuffix(lower, ".webm") || strings.HasSuffix(lower, ".mka") {
+ return probeMatroska(path)
+ }
+ return nil, nil
+}
+
+func probeMatroska(path string) ([]Track, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ // Tracks element is normally near the start; cap read for huge files.
+ limit := info.Size()
+ if limit > 8<<20 {
+ limit = 8 << 20
+ }
+ r := &ebmlReader{r: io.LimitReader(f, limit)}
+
+ id, _, err := r.readIDSize()
+ if err != nil {
+ return nil, err
+ }
+ // EBML header
+ if id != 0x1A45DFA3 {
+ return nil, nil
+ }
+ if err := r.skip(r.lastSize); err != nil {
+ return nil, err
+ }
+
+ var tracks []Track
+ for {
+ id, size, err := r.readIDSize()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ break
+ }
+ switch id {
+ case 0x18538067: // Segment — descend
+ continue
+ case 0x1654AE6B: // Tracks
+ tracks, err = parseTracksElement(r, size)
+ return tracks, err
+ case 0x114D9B74, 0x1549A966, 0x1C53BB6B, 0x1F43B675, 0x1043A770, 0x1254C367, 0x1941A469:
+ // SeekHead, Info, Cues, Cluster, Chapters, Tags, Attachments
+ if err := r.skip(size); err != nil {
+ return tracks, nil
+ }
+ default:
+ if err := r.skip(size); err != nil {
+ return tracks, nil
+ }
+ }
+ }
+ return tracks, nil
+}
+
+func parseTracksElement(r *ebmlReader, size uint64) ([]Track, error) {
+ end := r.pos + size
+ var out []Track
+ for r.pos < end {
+ id, sz, err := r.readIDSize()
+ if err != nil {
+ return out, nil
+ }
+ if id == 0xAE { // TrackEntry
+ t, err := parseTrackEntry(r, sz)
+ if err == nil && t.Type != "" {
+ out = append(out, t)
+ }
+ } else {
+ _ = r.skip(sz)
+ }
+ }
+ return out, nil
+}
+
+func parseTrackEntry(r *ebmlReader, size uint64) (Track, error) {
+ end := r.pos + size
+ t := Track{Type: "unknown"}
+ for r.pos < end {
+ id, sz, err := r.readIDSize()
+ if err != nil {
+ return t, err
+ }
+ switch id {
+ case 0xD7: // TrackNumber
+ v, err := r.readUint(sz)
+ if err != nil {
+ return t, err
+ }
+ t.Number = int(v)
+ case 0x83: // TrackType
+ v, err := r.readUint(sz)
+ if err != nil {
+ return t, err
+ }
+ switch v {
+ case 1:
+ t.Type = "video"
+ case 2:
+ t.Type = "audio"
+ case 17:
+ t.Type = "subtitle"
+ default:
+ t.Type = "unknown"
+ }
+ case 0x22B59C, 0x22B59D: // Language / LanguageIETF
+ s, err := r.readString(sz)
+ if err != nil {
+ return t, err
+ }
+ s = strings.TrimSpace(s)
+ if s != "" && !strings.EqualFold(s, "und") {
+ t.Language = s
+ }
+ case 0x536E: // Name
+ s, err := r.readString(sz)
+ if err != nil {
+ return t, err
+ }
+ t.Title = strings.TrimSpace(s)
+ case 0x86: // CodecID
+ s, err := r.readString(sz)
+ if err != nil {
+ return t, err
+ }
+ t.Codec = s
+ default:
+ if err := r.skip(sz); err != nil {
+ return t, err
+ }
+ }
+ }
+ return t, nil
+}
+
+type ebmlReader struct {
+ r io.Reader
+ pos uint64
+ lastSize uint64
+ buf [8]byte
+}
+
+func (e *ebmlReader) readByte() (byte, error) {
+ n, err := e.r.Read(e.buf[:1])
+ if n == 1 {
+ e.pos++
+ return e.buf[0], nil
+ }
+ if err == nil {
+ err = io.EOF
+ }
+ return 0, err
+}
+
+func (e *ebmlReader) readIDSize() (id uint32, size uint64, err error) {
+ b0, err := e.readByte()
+ if err != nil {
+ return 0, 0, err
+ }
+ var idLen int
+ switch {
+ case b0&0x80 != 0:
+ idLen = 1
+ id = uint32(b0)
+ case b0&0x40 != 0:
+ idLen = 2
+ id = uint32(b0)
+ case b0&0x20 != 0:
+ idLen = 3
+ id = uint32(b0)
+ case b0&0x10 != 0:
+ idLen = 4
+ id = uint32(b0)
+ default:
+ return 0, 0, io.ErrUnexpectedEOF
+ }
+ for i := 1; i < idLen; i++ {
+ b, err := e.readByte()
+ if err != nil {
+ return 0, 0, err
+ }
+ id = (id << 8) | uint32(b)
+ }
+
+ b0, err = e.readByte()
+ if err != nil {
+ return 0, 0, err
+ }
+ var sizeLen int
+ var mask byte
+ switch {
+ case b0&0x80 != 0:
+ sizeLen, mask = 1, 0x7F
+ case b0&0x40 != 0:
+ sizeLen, mask = 2, 0x3F
+ case b0&0x20 != 0:
+ sizeLen, mask = 3, 0x1F
+ case b0&0x10 != 0:
+ sizeLen, mask = 4, 0x0F
+ case b0&0x08 != 0:
+ sizeLen, mask = 5, 0x07
+ case b0&0x04 != 0:
+ sizeLen, mask = 6, 0x03
+ case b0&0x02 != 0:
+ sizeLen, mask = 7, 0x01
+ case b0&0x01 != 0:
+ sizeLen, mask = 8, 0x00
+ default:
+ return 0, 0, io.ErrUnexpectedEOF
+ }
+ size = uint64(b0 & mask)
+ for i := 1; i < sizeLen; i++ {
+ b, err := e.readByte()
+ if err != nil {
+ return 0, 0, err
+ }
+ size = (size << 8) | uint64(b)
+ }
+ // Unknown size marker
+ if size == (uint64(1)<<(7*sizeLen))-1 {
+ size = 0
+ }
+ e.lastSize = size
+ return id, size, nil
+}
+
+func (e *ebmlReader) skip(n uint64) error {
+ if n == 0 {
+ return nil
+ }
+ buf := make([]byte, 32*1024)
+ left := n
+ for left > 0 {
+ chunk := uint64(len(buf))
+ if chunk > left {
+ chunk = left
+ }
+ read, err := io.ReadFull(e.r, buf[:chunk])
+ e.pos += uint64(read)
+ if err != nil {
+ return err
+ }
+ left -= chunk
+ }
+ return nil
+}
+
+func (e *ebmlReader) readUint(n uint64) (uint64, error) {
+ if n == 0 || n > 8 {
+ return 0, io.ErrUnexpectedEOF
+ }
+ var raw [8]byte
+ if _, err := io.ReadFull(e.r, raw[8-n:]); err != nil {
+ return 0, err
+ }
+ e.pos += n
+ return binary.BigEndian.Uint64(raw[:]), nil
+}
+
+func (e *ebmlReader) readString(n uint64) (string, error) {
+ if n > 1<<20 {
+ return "", io.ErrUnexpectedEOF
+ }
+ buf := make([]byte, n)
+ if _, err := io.ReadFull(e.r, buf); err != nil {
+ return "", err
+ }
+ e.pos += n
+ // Trim trailing NULs common in Matroska strings
+ return strings.TrimRight(string(buf), "\x00"), nil
+}
diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
index 8f8123c..d1ff471 100644
--- a/packages/protocol/src/index.ts
+++ b/packages/protocol/src/index.ts
@@ -230,10 +230,19 @@ export interface PlaybackSessionProgressPayload {
fileSizeBytes: number;
}
+export interface MediaTrackInfo {
+ type: string;
+ number?: number;
+ language?: string;
+ title?: string;
+ codec?: string;
+}
+
export interface CreatePlaybackSessionAckPayload {
sessionId: string;
ok: boolean;
error?: string;
+ tracks?: MediaTrackInfo[];
}
export function createMessage(