Show real subtitle languages by probing MKV track tags on the node.

Pass embedded track language/title through play ACK into clients so Android/iOS can label tracks instead of generic Ondertitel N when ExoPlayer/VLC miss tags.
This commit is contained in:
Jos Vooges | STH 2026-09-12 20:58:24 +02:00
parent 4c15a37783
commit 6de2c767bf
27 changed files with 1537 additions and 130 deletions

View file

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

View file

@ -21,6 +21,7 @@
<activity <activity
android:name="nl.vonas.mediacluster.tv.MainActivity" android:name="nl.vonas.mediacluster.tv.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:screenOrientation="unspecified"> android:screenOrientation="unspecified">
@ -28,6 +29,12 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="vmc" android:host="link" />
</intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View file

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

View file

@ -1,15 +1,21 @@
package nl.vonas.mediacluster.tv package nl.vonas.mediacluster.tv
import android.content.Intent
import android.media.AudioManager import android.media.AudioManager
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import kotlinx.coroutines.flow.MutableStateFlow
import nl.vonas.mediacluster.tv.ui.McNav import nl.vonas.mediacluster.tv.ui.McNav
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val pendingLinkCode = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
volumeControlStream = AudioManager.STREAM_MUSIC volumeControlStream = AudioManager.STREAM_MUSIC
@ -18,10 +24,21 @@ class MainActivity : ComponentActivity() {
isAppearanceLightStatusBars = false isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false isAppearanceLightNavigationBars = false
} }
pendingLinkCode.value = LinkCodeParser.fromIntent(intent)
setContent { setContent {
MediaClusterTheme { 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)
}
} }

View file

@ -160,6 +160,21 @@ class ApiClient(
label = s.optString("label").ifBlank { "Lokaal" }, 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( ClientMe(
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
profile = o.optJSONObject("profile")?.toProfile(), profile = o.optJSONObject("profile")?.toProfile(),
deviceId = o.optClean("deviceId"),
)
}
}
suspend fun listDevices(): List<LinkedDevice> = 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 sessionId: String,
val mediaFileId: String, val mediaFileId: String,
val defaultSubtitle: DefaultSubtitle? = null, val defaultSubtitle: DefaultSubtitle? = null,
val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(),
)
data class EmbeddedTrackInfo(
val type: String,
val language: String? = null,
val title: String? = null,
val codec: String? = null,
) )
data class DefaultSubtitle( data class DefaultSubtitle(
@ -658,6 +730,22 @@ data class ShelfPref(
data class ClientMe( data class ClientMe(
val iptvEnabled: Boolean, val iptvEnabled: Boolean,
val profile: ViewerProfile? = null, 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( data class ViewerProfile(

View file

@ -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) data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> { fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> {
val out = mutableListOf<PlayerTrack>() val out = mutableListOf<PlayerTrack>()
val groups = currentTracks.groups 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) { for (gi in 0 until groups.size) {
val group = groups[gi] val group = groups[gi]
if (group.type != type) continue if (group.type != type) continue
for (ti in 0 until group.length) { for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti) val format = group.getTrackFormat(ti)
val hint = hintPool.getOrNull(ordinal)
val label = if (type == C.TRACK_TYPE_TEXT) { val label = if (type == C.TRACK_TYPE_TEXT) {
formatTextTrackLabel(format, ti) formatTextTrackLabel(format, ordinal, hint)
} else { } else {
formatAudioTrackLabel(format, ti) formatAudioTrackLabel(format, ordinal, hint)
} }
ordinal++
out += PlayerTrack(gi, ti, label, type) out += PlayerTrack(gi, ti, label, type)
} }
} }
return out 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 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 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( return listOfNotNull(
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
lang, lang,
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
shortMime(mime).takeIf { it.isNotBlank() }, shortMime(mime).takeIf { it.isNotBlank() },
ch, ch,
).joinToString(" · ").ifBlank { "Audio ${index + 1}" } ).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
} }
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String { private fun formatTextTrackLabel(
val lang = prettyLanguage(format.language) 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 { val kind = when {
format.sampleMimeType?.contains("vtt", true) == true -> "VTT" format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
format.sampleMimeType?.contains("ssa", true) == true || format.sampleMimeType?.contains("ssa", true) == true ||
@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
else -> null else -> null
} }
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
val label = format.label?.takeIf { val label = format.label?.takeIf { !isGenericTrackLabel(it) }
it.isNotBlank() && ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
!it.equals("und", true) && return listOfNotNull(
!it.equals("sub", true) && lang,
!it.equals("subtitle", true) label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
} kind,
return listOfNotNull(label, lang, kind, forced) forced,
)
.distinct() .distinct()
.joinToString(" · ") .joinToString(" · ")
.ifBlank { "Ondertitel ${index + 1}" } .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? { 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) { return when (c) {
"nl", "dut", "nld" -> "Nederlands" "nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
"en", "eng" -> "Engels" "en", "eng", "english", "engels" -> "Engels"
"de", "ger", "deu" -> "Duits" "de", "ger", "deu", "german", "duits" -> "Duits"
"fr", "fre", "fra" -> "Frans" "fr", "fre", "fra", "french", "frans" -> "Frans"
"es", "spa" -> "Spaans" "es", "spa", "spanish", "spaans" -> "Spaans"
"it", "ita" -> "Italiaans" "it", "ita", "italian", "italiaans" -> "Italiaans"
"pt", "por" -> "Portugees" "pt", "por", "portuguese", "portugees" -> "Portugees"
"pl", "pol" -> "Pools" "pl", "pol", "polish", "pools" -> "Pools"
"ru", "rus" -> "Russisch" "ru", "rus", "russian", "russisch" -> "Russisch"
"ja", "jpn" -> "Japans" "ja", "jpn", "japanese", "japans" -> "Japans"
"ko", "kor" -> "Koreaans" "ko", "kor", "korean", "koreaans" -> "Koreaans"
"zh", "chi", "zho", "cmn" -> "Chinees" "zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
"ar", "ara" -> "Arabisch" "ar", "ara", "arabic", "arabisch" -> "Arabisch"
"tr", "tur" -> "Turks" "tr", "tur", "turkish", "turks" -> "Turks"
"sv", "swe" -> "Zweeds" "sv", "swe", "swedish", "zweeds" -> "Zweeds"
"no", "nor", "nb", "nn" -> "Noors" "no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
"da", "dan" -> "Deens" "da", "dan", "danish", "deens" -> "Deens"
"fi", "fin" -> "Fins" "fi", "fin", "finnish", "fins" -> "Fins"
else -> c.uppercase() 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() }
}
}
} }
} }

View file

@ -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<List<LinkedDevice>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var manualCode by remember { mutableStateOf(initialCode.orEmpty()) }
var approving by remember { mutableStateOf(false) }
var approveMessage by remember { mutableStateOf<String?>(null) }
var approveError by remember { mutableStateOf<String?>(null) }
var revokeTarget by remember { mutableStateOf<LinkedDevice?>(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)
}
}
}

View file

@ -90,7 +90,10 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens import nl.vonas.mediacluster.tv.ui.theme.McDimens
@Composable @Composable
fun McNav() { fun McNav(
pendingLinkCode: String? = null,
onPendingLinkCodeConsumed: () -> Unit = {},
) {
val context = LocalContext.current val context = LocalContext.current
val store = remember { SessionStore(context) } val store = remember { SessionStore(context) }
val api = remember { ApiClient(baseUrl = "") } val api = remember { ApiClient(baseUrl = "") }
@ -99,6 +102,11 @@ fun McNav() {
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) }
var hasProfile 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<String?>(null) }
var approveDoneName by remember { mutableStateOf<String?>(null) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileName by store.profileName.collectAsState(initial = null) val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player) // Blijft behouden als main uit compositie gaat (bijv. live player)
@ -110,6 +118,13 @@ fun McNav() {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home 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) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
val token = store.refreshToken.first() val token = store.refreshToken.first()
@ -120,21 +135,34 @@ fun McNav() {
if (me?.profile != null) { if (me?.profile != null) {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
hasProfile = true hasProfile = true
canManageDevices = me.profile.isOwner
} else { } else {
store.setProfile(null) store.setProfile(null)
hasProfile = false hasProfile = false
canManageDevices = false
} }
} else { } else {
hasProfile = false hasProfile = false
canManageDevices = false
} }
ready = true ready = true
} }
LaunchedEffect(pendingLinkCode, ready, authed, hasProfile) {
if (!ready || pendingLinkCode.isNullOrBlank()) return@LaunchedEffect
if (authed && hasProfile) {
showApproveDialog = true
approveError = null
approveDoneName = null
}
}
if (!ready) { if (!ready) {
Splash() Splash()
return return
} }
Box(Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = nav, navController = nav,
startDestination = when { startDestination = when {
@ -173,6 +201,7 @@ fun McNav() {
onLoggedIn = { onLoggedIn = {
authed = true authed = true
hasProfile = false hasProfile = false
canManageDevices = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
}, },
) )
@ -190,21 +219,32 @@ fun McNav() {
) { ) {
authed = true authed = true
hasProfile = false hasProfile = false
canManageDevices = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
} }
} }
composable("profiles") { composable("profiles") {
ProfilesScreen(api = api, store = store, switcherMode = true) { ProfilesScreen(api = api, store = store, switcherMode = true) {
hasProfile = true hasProfile = true
refreshOwnerFlag()
nav.navigate("main") { popUpTo("profiles") { inclusive = true } } nav.navigate("main") { popUpTo("profiles") { inclusive = true } }
} }
} }
composable("profiles_manage") { composable("profiles_manage") {
ProfilesScreen(api = api, store = store, switcherMode = false) { ProfilesScreen(api = api, store = store, switcherMode = false) {
hasProfile = true hasProfile = true
refreshOwnerFlag()
nav.popBackStack() nav.popBackStack()
} }
} }
composable("devices") {
DevicesScreen(
api = api,
initialCode = pendingLinkCode,
onBack = { nav.popBackStack() },
onConsumedPendingCode = onPendingLinkCodeConsumed,
)
}
composable("main") { composable("main") {
MainHub( MainHub(
api = api, api = api,
@ -218,6 +258,7 @@ fun McNav() {
onTvFocusContent = { tvFocusNonce++ }, onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileName = profileName, profileName = profileName,
canManageDevices = canManageDevices,
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") }, onOpenSeries = { nav.navigate("series/$it") },
onBrowse = { catalogId, _title -> onBrowse = { catalogId, _title ->
@ -239,6 +280,7 @@ fun McNav() {
runCatching { api.clearProfile() } runCatching { api.clearProfile() }
store.setProfile(null) store.setProfile(null)
hasProfile = false hasProfile = false
canManageDevices = false
nav.navigate("profiles") { nav.navigate("profiles") {
popUpTo("main") { inclusive = true } popUpTo("main") { inclusive = true }
} }
@ -247,12 +289,17 @@ fun McNav() {
onManageProfiles = { onManageProfiles = {
nav.navigate("profiles_manage") nav.navigate("profiles_manage")
}, },
onManageDevices = {
nav.navigate("devices")
},
onLogout = { onLogout = {
scope.launch { scope.launch {
store.setToken(null) store.setToken(null)
api.configure(store.apiBase.first(), null) api.configure(store.apiBase.first(), null)
authed = false authed = false
hasProfile = false hasProfile = false
canManageDevices = false
onPendingLinkCodeConsumed()
nav.navigate("server") { popUpTo(0) { inclusive = true } } 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 @Composable
@ -359,6 +441,7 @@ private fun MainHub(
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
profileAvatarId: String?, profileAvatarId: String?,
profileName: String?, profileName: String?,
canManageDevices: Boolean,
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit, onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit,
@ -366,6 +449,7 @@ private fun MainHub(
onPlayLive: (Int) -> Unit, onPlayLive: (Int) -> Unit,
onSwitchProfile: () -> Unit, onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit, onManageProfiles: () -> Unit,
onManageDevices: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
) { ) {
var iptvEnabled by remember { mutableStateOf(false) } var iptvEnabled by remember { mutableStateOf(false) }
@ -539,12 +623,14 @@ private fun MainHub(
api = api, api = api,
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileName = profileName, profileName = profileName,
canManageDevices = canManageDevices,
onBack = { onBack = {
onTabChange(MainTab.Home) onTabChange(MainTab.Home)
focusTopNav() focusTopNav()
}, },
onSwitchProfile = onSwitchProfile, onSwitchProfile = onSwitchProfile,
onManageProfiles = onManageProfiles, onManageProfiles = onManageProfiles,
onManageDevices = onManageDevices,
onLogout = onLogout, onLogout = onLogout,
) )
} }
@ -1139,9 +1225,11 @@ private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null, profileAvatarId: String? = null,
profileName: String? = null, profileName: String? = null,
canManageDevices: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {}, onManageProfiles: () -> Unit = {},
onManageDevices: () -> Unit = {},
onLogout: () -> Unit = {}, onLogout: () -> Unit = {},
) { ) {
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) } var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
@ -1262,6 +1350,31 @@ private fun SettingsScreen(
fontWeight = FontWeight.SemiBold, 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)) Spacer(Modifier.height(8.dp))
Box( Box(
Modifier Modifier

View file

@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -108,6 +109,8 @@ fun PlayerScreen(
var durationMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) }
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()) }
val embeddedHintsState = rememberUpdatedState(embeddedHints)
var menu by remember { mutableStateOf<String?>(null) } // audio | text | os var menu by remember { mutableStateOf<String?>(null) } // audio | text | os
var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) } var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
var osLoading by remember { mutableStateOf(false) } var osLoading by remember { mutableStateOf(false) }
@ -209,6 +212,7 @@ fun PlayerScreen(
LaunchedEffect(mediaFileId) { LaunchedEffect(mediaFileId) {
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
embeddedHints = play.embeddedTracks
player.volume = 1f player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare() player.prepare()
@ -248,8 +252,8 @@ fun PlayerScreen(
if (state == Player.STATE_READY) { if (state == Player.STATE_READY) {
player.volume = 1f player.volume = 1f
player.ensureAudioSelected() player.ensureAudioSelected()
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
} }
} }
@ -258,8 +262,8 @@ fun PlayerScreen(
} }
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
player.ensureAudioSelected() player.ensureAudioSelected()
player.volume = 1f player.volume = 1f
} }

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
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.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -45,13 +46,14 @@ fun McButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
primary: Boolean = false, primary: Boolean = false,
compact: Boolean = false, compact: Boolean = false,
enabled: Boolean = true,
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f, targetValue = if (pressed && enabled) 0.96f else 1f,
animationSpec = tween(100), animationSpec = tween(100),
label = "btnScale", label = "btnScale",
) )
@ -64,10 +66,17 @@ fun McButton(
.graphicsLayer { .graphicsLayer {
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
alpha = if (enabled) 1f else 0.4f
} }
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(bg) .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), .padding(horizontal = hPad, vertical = vPad),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

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

View file

@ -172,6 +172,21 @@ class ApiClient(
label = s.optString("label").ifBlank { "Lokaal" }, 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 sessionId: String,
val mediaFileId: String, val mediaFileId: String,
val defaultSubtitle: DefaultSubtitle? = null, val defaultSubtitle: DefaultSubtitle? = null,
val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(),
)
data class EmbeddedTrackInfo(
val type: String,
val language: String? = null,
val title: String? = null,
val codec: String? = null,
) )
data class DefaultSubtitle( data class DefaultSubtitle(

View file

@ -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) data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> { fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> {
val out = mutableListOf<PlayerTrack>() val out = mutableListOf<PlayerTrack>()
val groups = currentTracks.groups 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) { for (gi in 0 until groups.size) {
val group = groups[gi] val group = groups[gi]
if (group.type != type) continue if (group.type != type) continue
for (ti in 0 until group.length) { for (ti in 0 until group.length) {
val format = group.getTrackFormat(ti) val format = group.getTrackFormat(ti)
val hint = hintPool.getOrNull(ordinal)
val label = if (type == C.TRACK_TYPE_TEXT) { val label = if (type == C.TRACK_TYPE_TEXT) {
formatTextTrackLabel(format, ti) formatTextTrackLabel(format, ordinal, hint)
} else { } else {
formatAudioTrackLabel(format, ti) formatAudioTrackLabel(format, ordinal, hint)
} }
ordinal++
out += PlayerTrack(gi, ti, label, type) out += PlayerTrack(gi, ti, label, type)
} }
} }
return out 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 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 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( return listOfNotNull(
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
lang, lang,
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
shortMime(mime).takeIf { it.isNotBlank() }, shortMime(mime).takeIf { it.isNotBlank() },
ch, ch,
).joinToString(" · ").ifBlank { "Audio ${index + 1}" } ).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
} }
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String { private fun formatTextTrackLabel(
val lang = prettyLanguage(format.language) 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 { val kind = when {
format.sampleMimeType?.contains("vtt", true) == true -> "VTT" format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
format.sampleMimeType?.contains("ssa", true) == true || format.sampleMimeType?.contains("ssa", true) == true ||
@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
else -> null else -> null
} }
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
val label = format.label?.takeIf { val label = format.label?.takeIf { !isGenericTrackLabel(it) }
it.isNotBlank() && ?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
!it.equals("und", true) && return listOfNotNull(
!it.equals("sub", true) && lang,
!it.equals("subtitle", true) label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
} kind,
return listOfNotNull(label, lang, kind, forced) forced,
)
.distinct() .distinct()
.joinToString(" · ") .joinToString(" · ")
.ifBlank { "Ondertitel ${index + 1}" } .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? { 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) { return when (c) {
"nl", "dut", "nld" -> "Nederlands" "nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
"en", "eng" -> "Engels" "en", "eng", "english", "engels" -> "Engels"
"de", "ger", "deu" -> "Duits" "de", "ger", "deu", "german", "duits" -> "Duits"
"fr", "fre", "fra" -> "Frans" "fr", "fre", "fra", "french", "frans" -> "Frans"
"es", "spa" -> "Spaans" "es", "spa", "spanish", "spaans" -> "Spaans"
"it", "ita" -> "Italiaans" "it", "ita", "italian", "italiaans" -> "Italiaans"
"pt", "por" -> "Portugees" "pt", "por", "portuguese", "portugees" -> "Portugees"
"pl", "pol" -> "Pools" "pl", "pol", "polish", "pools" -> "Pools"
"ru", "rus" -> "Russisch" "ru", "rus", "russian", "russisch" -> "Russisch"
"ja", "jpn" -> "Japans" "ja", "jpn", "japanese", "japans" -> "Japans"
"ko", "kor" -> "Koreaans" "ko", "kor", "korean", "koreaans" -> "Koreaans"
"zh", "chi", "zho", "cmn" -> "Chinees" "zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
"ar", "ara" -> "Arabisch" "ar", "ara", "arabic", "arabisch" -> "Arabisch"
"tr", "tur" -> "Turks" "tr", "tur", "turkish", "turks" -> "Turks"
"sv", "swe" -> "Zweeds" "sv", "swe", "swedish", "zweeds" -> "Zweeds"
"no", "nor", "nb", "nn" -> "Noors" "no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
"da", "dan" -> "Deens" "da", "dan", "danish", "deens" -> "Deens"
"fi", "fin" -> "Fins" "fi", "fin", "finnish", "fins" -> "Fins"
else -> c.uppercase() 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() }
}
}
} }
} }

View file

@ -31,6 +31,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64 import android.util.Base64
import androidx.compose.runtime.rememberCoroutineScope
import java.io.File import java.io.File
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -105,6 +106,8 @@ fun PlayerScreen(
var durationMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) }
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()) }
val embeddedHintsState = rememberUpdatedState(embeddedHints)
var menu by remember { mutableStateOf<String?>(null) } // audio | text | os var menu by remember { mutableStateOf<String?>(null) } // audio | text | os
var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) } var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
var osLoading by remember { mutableStateOf(false) } var osLoading by remember { mutableStateOf(false) }
@ -206,6 +209,7 @@ fun PlayerScreen(
LaunchedEffect(mediaFileId) { LaunchedEffect(mediaFileId) {
try { try {
val play = api.play(mediaFileId) val play = api.play(mediaFileId)
embeddedHints = play.embeddedTracks
player.volume = 1f player.volume = 1f
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl)) player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare() player.prepare()
@ -245,8 +249,8 @@ fun PlayerScreen(
if (state == Player.STATE_READY) { if (state == Player.STATE_READY) {
player.volume = 1f player.volume = 1f
player.ensureAudioSelected() player.ensureAudioSelected()
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
} }
} }
@ -255,8 +259,8 @@ fun PlayerScreen(
} }
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO) audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
textTracks = player.listTracks(C.TRACK_TYPE_TEXT) textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
player.ensureAudioSelected() player.ensureAudioSelected()
player.volume = 1f player.volume = 1f
} }

View file

@ -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.parallel=false
org.gradle.workers.max=1 org.gradle.workers.max=1
org.gradle.caching=true org.gradle.caching=true

View file

@ -317,7 +317,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -332,7 +332,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.4.6; MARKETING_VERSION = 0.4.7;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -350,7 +350,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -365,7 +365,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.4.6; MARKETING_VERSION = 0.4.7;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -188,8 +188,7 @@ final class AppModel: ObservableObject {
let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" } let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" }
if cleaned.contains("-") { return cleaned } if cleaned.contains("-") { return cleaned }
if cleaned.count == 8 { if cleaned.count == 8 {
let i = cleaned.index(cleaned.startIndex, offsetBy: 4) return "\(cleaned.prefix(4))-\(cleaned.suffix(4))"
return "\(cleaned[..i])-\(cleaned[i...])"
} }
return cleaned return cleaned
} }

View file

@ -3,7 +3,7 @@ import MobileVLCKit
import SwiftUI import SwiftUI
import UIKit import UIKit
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate { final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate {
@Published var isPlaying = false @Published var isPlaying = false
@Published var position: Float = 0 @Published var position: Float = 0
@Published var timeText = "0:00" @Published var timeText = "0:00"
@ -36,6 +36,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
subtitleTracks = [] subtitleTracks = []
audioTracks = [] audioTracks = []
let media = VLCMedia(url: url) let media = VLCMedia(url: url)
media.delegate = self
media.addOption(":network-caching=2500") media.addOption(":network-caching=2500")
media.addOption(":http-reconnect") media.addOption(":http-reconnect")
media.addOption(":avcodec-hw=any") 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-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1") media.addOption(":freetype-bold=1")
mediaPlayer.media = media mediaPlayer.media = media
// Async parse zodat tracksInformation language/title beschikbaar wordt.
// MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift.
media.parse()
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle() applySubtitleStyle()
if startMs > 2000 { if startMs > 2000 {
@ -69,21 +73,25 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
errorMessage = nil errorMessage = nil
stateText = "Live laden…" stateText = "Live laden…"
let media = VLCMedia(url: url) let media = VLCMedia(url: url)
media.delegate = self
media.addOption(":network-caching=1200") media.addOption(":network-caching=1200")
media.addOption(":http-reconnect") media.addOption(":http-reconnect")
media.addOption(":live-caching=1000") media.addOption(":live-caching=1000")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
mediaPlayer.media = media mediaPlayer.media = media
media.parse()
mediaPlayer.play() mediaPlayer.play()
applySubtitleStyle() applySubtitleStyle()
if let fallback { if let fallback {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, !self.isPlaying || self.hasError else { return } guard let self, !self.isPlaying || self.hasError else { return }
let fb = VLCMedia(url: fallback) let fb = VLCMedia(url: fallback)
fb.delegate = self
fb.addOption(":network-caching=1200") fb.addOption(":network-caching=1200")
fb.addOption(":http-reconnect") fb.addOption(":http-reconnect")
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)") fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
self.mediaPlayer.media = fb self.mediaPlayer.media = fb
fb.parse()
self.mediaPlayer.play() self.mediaPlayer.play()
self.applySubtitleStyle() self.applySubtitleStyle()
} }
@ -141,41 +149,74 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
} }
func refreshTracks() { 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 subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")]
var textOrd = 0
if let names = mediaPlayer.videoSubTitlesNames as? [Any], if let names = mediaPlayer.videoSubTitlesNames as? [Any],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] { let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] {
for (i, raw) in indexes.enumerated() { for (i, raw) in indexes.enumerated() {
let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1) let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1)
if idx < 0 { continue } if idx < 0 { continue }
let label: String let rawName: String = {
if i < names.count, let n = names[i] as? String, !n.isEmpty { if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
label = n return ""
} else { }()
label = "Ondertitel \(subs.count)" let fromId = meta[idx]
} let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil
// Skip junk mime-like labels textOrd += 1
if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") { let label = Self.displayTrackLabel(
subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)")) rawName: rawName,
} else { fallbackIndex: subs.count,
subs.append(PlayerTrack(id: idx, name: label)) kind: .subtitle,
} languageHint: fromId?.language ?? fromOrder,
descriptionHint: fromId?.description
)
subs.append(PlayerTrack(id: idx, name: label))
} }
} }
subtitleTracks = subs subtitleTracks = subs
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
var audios: [PlayerTrack] = [] var audios: [PlayerTrack] = []
var audioOrd = 0
if let names = mediaPlayer.audioTrackNames as? [Any], if let names = mediaPlayer.audioTrackNames as? [Any],
let indexes = mediaPlayer.audioTrackIndexes as? [Any] { let indexes = mediaPlayer.audioTrackIndexes as? [Any] {
for (i, raw) in indexes.enumerated() { for (i, raw) in indexes.enumerated() {
let idx = (raw as? NSNumber)?.int32Value ?? -1 let idx = (raw as? NSNumber)?.int32Value ?? -1
if idx < 0 { continue } if idx < 0 { continue }
let label: String let rawName: String = {
if i < names.count, let n = names[i] as? String, !n.isEmpty { if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
label = n return ""
} else { }()
label = "Audio \(audios.count + 1)" 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)) audios.append(PlayerTrack(id: idx, name: label))
} }
} }
@ -183,6 +224,138 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
currentAudioId = mediaPlayer.currentAudioTrackIndex 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() { func stop() {
tick?.cancel() tick?.cancel()
tick = nil 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() { private func startTicker() {
tick?.cancel() tick?.cancel()
tick = Timer.publish(every: 0.4, on: .main, in: .common) tick = Timer.publish(every: 0.4, on: .main, in: .common)

View file

@ -90,7 +90,7 @@ struct SettingsScreen: View {
Section("Info") { Section("Info") {
LabeledContent("App", value: "VMC iOS") 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") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
} }
.listRowBackground(McColors.bgElevated) .listRowBackground(McColors.bgElevated)

View file

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

View file

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

View file

@ -7,17 +7,19 @@ import {
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager"; import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config"; import type { Config } from "../config";
import type { MediaTrackInfo } from "@media-cluster/protocol";
type SessionResult = { export type SessionResult = {
sessionId: string; sessionId: string;
streamUrl: string; streamUrl: string;
token: string; token: string;
nodeId: string; nodeId: string;
mediaFileId: string; mediaFileId: string;
tracks?: MediaTrackInfo[];
}; };
/** In-memory play tokens so concurrent Stremio probes reuse one session. */ /** In-memory play tokens so concurrent Stremio probes reuse one session. */
const playTokens = new Map<string, { token: string; sessionId: string }>(); const playTokens = new Map<string, { token: string; sessionId: string; tracks?: MediaTrackInfo[] }>();
const createLocks = new Map<string, Promise<SessionResult>>(); const createLocks = new Map<string, Promise<SessionResult>>();
function sessionKey(addonTokenId: string | undefined, mediaFileId: string): string { function sessionKey(addonTokenId: string | undefined, mediaFileId: string): string {
@ -94,6 +96,7 @@ export class PlaybackService {
token: cached.token, token: cached.token,
nodeId: mediaFile.nodeId, nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id, 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, sessionId,
tokenHash, tokenHash,
localFileId: mediaFile.localFileId, localFileId: mediaFile.localFileId,
@ -148,7 +151,7 @@ export class PlaybackService {
absoluteExpiresAt: absoluteExpiresAt.toISOString(), absoluteExpiresAt: absoluteExpiresAt.toISOString(),
}); });
if (!acked) { if (!ack?.ok) {
await prisma.playbackSession.update({ await prisma.playbackSession.update({
where: { id: sessionId }, where: { id: sessionId },
data: { status: "REVOKED", revoked: true }, data: { status: "REVOKED", revoked: true },
@ -156,7 +159,8 @@ export class PlaybackService {
throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503); 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}`; const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
return { return {
@ -165,6 +169,7 @@ export class PlaybackService {
token, token,
nodeId: mediaFile.nodeId, nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id, mediaFileId: mediaFile.id,
tracks,
}; };
} }

View file

@ -2207,6 +2207,7 @@ export class ViewerService {
sessionId: session.sessionId, sessionId: session.sessionId,
mediaFileId: session.mediaFileId, mediaFileId: session.mediaFileId,
defaultSubtitle, defaultSubtitle,
embeddedTracks: session.tracks ?? [],
}; };
} }

View file

@ -33,7 +33,7 @@ interface NodeConnection {
} }
interface PendingAck { interface PendingAck {
resolve: (ok: boolean) => void; resolve: (payload: CreatePlaybackSessionAckPayload) => void;
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
@ -454,7 +454,7 @@ class NodeConnectionManager {
if (!pending) return; if (!pending) return;
clearTimeout(pending.timer); clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId); this.pendingSessionAcks.delete(payload.sessionId);
pending.resolve(payload.ok); pending.resolve(payload);
} }
private handleImportMediaAck(payload: ImportMediaAckPayload): void { private handleImportMediaAck(payload: ImportMediaAckPayload): void {
@ -686,14 +686,14 @@ class NodeConnectionManager {
nodeId: string, nodeId: string,
payload: CreatePlaybackSessionPayload, payload: CreatePlaybackSessionPayload,
timeoutMs = 12000 timeoutMs = 12000
): Promise<boolean> { ): Promise<CreatePlaybackSessionAckPayload | null> {
if (!this.isOnline(nodeId)) return false; if (!this.isOnline(nodeId)) return null;
const acked = new Promise<boolean>((resolve) => { const acked = new Promise<CreatePlaybackSessionAckPayload | null>((resolve) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Fail closed: never hand Stremio a node URL without a confirmed session. // Fail closed: never hand Stremio a node URL without a confirmed session.
this.pendingSessionAcks.delete(payload.sessionId); this.pendingSessionAcks.delete(payload.sessionId);
resolve(false); resolve(null);
}, timeoutMs); }, timeoutMs);
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer }); this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
}); });
@ -705,7 +705,7 @@ class NodeConnectionManager {
clearTimeout(pending.timer); clearTimeout(pending.timer);
this.pendingSessionAcks.delete(payload.sessionId); this.pendingSessionAcks.delete(payload.sessionId);
} }
return false; return null;
} }
return acked; return acked;

View file

@ -21,6 +21,7 @@ import (
"github.com/sthmedia/media-node/internal/database" "github.com/sthmedia/media-node/internal/database"
"github.com/sthmedia/media-node/internal/health" "github.com/sthmedia/media-node/internal/health"
"github.com/sthmedia/media-node/internal/importmedia" "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/scanner"
"github.com/sthmedia/media-node/internal/streaming" "github.com/sthmedia/media-node/internal/streaming"
) )
@ -346,11 +347,21 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
} else { } else {
log.Printf("Playback session created: %s file=%s", p.SessionID, p.LocalFileID) 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, "sessionId": p.SessionID,
"ok": ok, "ok": ok,
"error": errMsg, "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 { if werr := c.write(ack); werr != nil {
log.Printf("failed to send session ACK: %v", werr) log.Printf("failed to send session ACK: %v", werr)
} }

View file

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

View file

@ -230,10 +230,19 @@ export interface PlaybackSessionProgressPayload {
fileSizeBytes: number; fileSizeBytes: number;
} }
export interface MediaTrackInfo {
type: string;
number?: number;
language?: string;
title?: string;
codec?: string;
}
export interface CreatePlaybackSessionAckPayload { export interface CreatePlaybackSessionAckPayload {
sessionId: string; sessionId: string;
ok: boolean; ok: boolean;
error?: string; error?: string;
tracks?: MediaTrackInfo[];
} }
export function createMessage<T>( export function createMessage<T>(