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:
parent
4c15a37783
commit
6de2c767bf
27 changed files with 1537 additions and 130 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
9
apps/ios/scripts/VMC-install-iPhone.command
Normal file
9
apps/ios/scripts/VMC-install-iPhone.command
Normal 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"
|
||||||
24
apps/ios/scripts/vmc-ios-device-build.sh
Normal file
24
apps/ios/scripts/vmc-ios-device-build.sh
Normal 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)"
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2207,6 +2207,7 @@ export class ViewerService {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
mediaFileId: session.mediaFileId,
|
mediaFileId: session.mediaFileId,
|
||||||
defaultSubtitle,
|
defaultSubtitle,
|
||||||
|
embeddedTracks: session.tracks ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
302
node/media-node/internal/mediainfo/matroska.go
Normal file
302
node/media-node/internal/mediainfo/matroska.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue