From 6de2c767bf9a51300013772aa1ed094161ca6e0b Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 12 Sep 2026 20:58:24 +0200 Subject: [PATCH] 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. --- apps/android-tv/app-mobile/build.gradle.kts | 4 +- .../app-mobile/src/main/AndroidManifest.xml | 7 + .../vonas/mediacluster/tv/LinkCodeParser.kt | 39 ++ .../nl/vonas/mediacluster/tv/MainActivity.kt | 19 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 88 ++++ .../mediacluster/tv/playback/PlayerFactory.kt | 150 +++++-- .../vonas/mediacluster/tv/ui/DevicesScreen.kt | 408 ++++++++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 115 ++++- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 12 +- .../tv/ui/components/Components.kt | 13 +- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 23 + .../mediacluster/tv/playback/PlayerFactory.kt | 150 +++++-- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 14 +- apps/android-tv/gradle.properties | 2 +- apps/ios/VMC.xcodeproj/project.pbxproj | 8 +- apps/ios/VMC/Data/AppModel.swift | 3 +- .../VMC/Playback/VLCPlayerController.swift | 217 +++++++++- apps/ios/VMC/UI/SettingsScreen.swift | 2 +- apps/ios/scripts/VMC-install-iPhone.command | 9 + apps/ios/scripts/vmc-ios-device-build.sh | 24 ++ apps/master-api/src/playback/service.ts | 15 +- apps/master-api/src/viewer/service.ts | 1 + apps/master-api/src/websocket/manager.ts | 14 +- node/media-node/internal/control/client.go | 15 +- .../media-node/internal/mediainfo/matroska.go | 302 +++++++++++++ packages/protocol/src/index.ts | 9 + 27 files changed, 1537 insertions(+), 130 deletions(-) create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/LinkCodeParser.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DevicesScreen.kt create mode 100644 apps/ios/scripts/VMC-install-iPhone.command create mode 100644 apps/ios/scripts/vmc-ios-device-build.sh create mode 100644 node/media-node/internal/mediainfo/matroska.go 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(