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"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 13
|
||||
versionName = "1.0.12"
|
||||
versionCode = 17
|
||||
versionName = "1.0.16"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<activity
|
||||
android:name="nl.vonas.mediacluster.tv.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="unspecified">
|
||||
|
|
@ -28,6 +29,12 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
</application>
|
||||
</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
|
||||
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.view.WindowCompat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import nl.vonas.mediacluster.tv.ui.McNav
|
||||
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val pendingLinkCode = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
volumeControlStream = AudioManager.STREAM_MUSIC
|
||||
|
|
@ -18,10 +24,21 @@ class MainActivity : ComponentActivity() {
|
|||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
pendingLinkCode.value = LinkCodeParser.fromIntent(intent)
|
||||
setContent {
|
||||
MediaClusterTheme {
|
||||
McNav()
|
||||
val linkCode by pendingLinkCode.collectAsState()
|
||||
McNav(
|
||||
pendingLinkCode = linkCode,
|
||||
onPendingLinkCodeConsumed = { pendingLinkCode.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingLinkCode.value = LinkCodeParser.fromIntent(intent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,21 @@ class ApiClient(
|
|||
label = s.optString("label").ifBlank { "Lokaal" },
|
||||
)
|
||||
},
|
||||
embeddedTracks = o.optJSONArray("embeddedTracks")?.let { arr ->
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val t = arr.optJSONObject(i) ?: continue
|
||||
add(
|
||||
EmbeddedTrackInfo(
|
||||
type = t.optString("type").ifBlank { "unknown" },
|
||||
language = t.optString("language").ifBlank { null },
|
||||
title = t.optString("title").ifBlank { null },
|
||||
codec = t.optString("codec").ifBlank { null },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -378,6 +393,55 @@ class ApiClient(
|
|||
ClientMe(
|
||||
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
||||
profile = o.optJSONObject("profile")?.toProfile(),
|
||||
deviceId = o.optClean("deviceId"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listDevices(): List<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 mediaFileId: String,
|
||||
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(
|
||||
|
|
@ -658,6 +730,22 @@ data class ShelfPref(
|
|||
data class ClientMe(
|
||||
val iptvEnabled: Boolean,
|
||||
val profile: ViewerProfile? = null,
|
||||
val deviceId: String? = null,
|
||||
)
|
||||
|
||||
data class LinkedDevice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val platform: String?,
|
||||
val lastSeenAt: String?,
|
||||
val createdAt: String?,
|
||||
val current: Boolean,
|
||||
)
|
||||
|
||||
data class ApproveDeviceResult(
|
||||
val ok: Boolean,
|
||||
val deviceName: String?,
|
||||
val platform: String?,
|
||||
)
|
||||
|
||||
data class ViewerProfile(
|
||||
|
|
|
|||
|
|
@ -241,39 +241,59 @@ fun liveMediaItem(url: String, format: String): MediaItem {
|
|||
|
||||
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
|
||||
|
||||
fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
|
||||
fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> {
|
||||
val out = mutableListOf<PlayerTrack>()
|
||||
val groups = currentTracks.groups
|
||||
// ExoPlayer zet elke text/audio track vaak in een eigen group → ti is dan altijd 0.
|
||||
// Gebruik een globale ordinal voor unieke "Ondertitel N" / "Audio N" labels.
|
||||
val hintPool = when (type) {
|
||||
C.TRACK_TYPE_TEXT -> embeddedHints.filter { it.type.equals("subtitle", true) }
|
||||
C.TRACK_TYPE_AUDIO -> embeddedHints.filter { it.type.equals("audio", true) }
|
||||
else -> emptyList()
|
||||
}
|
||||
var ordinal = 0
|
||||
for (gi in 0 until groups.size) {
|
||||
val group = groups[gi]
|
||||
if (group.type != type) continue
|
||||
for (ti in 0 until group.length) {
|
||||
val format = group.getTrackFormat(ti)
|
||||
val hint = hintPool.getOrNull(ordinal)
|
||||
val label = if (type == C.TRACK_TYPE_TEXT) {
|
||||
formatTextTrackLabel(format, ti)
|
||||
formatTextTrackLabel(format, ordinal, hint)
|
||||
} else {
|
||||
formatAudioTrackLabel(format, ti)
|
||||
formatAudioTrackLabel(format, ordinal, hint)
|
||||
}
|
||||
ordinal++
|
||||
out += PlayerTrack(gi, ti, label, type)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
private fun formatAudioTrackLabel(
|
||||
format: androidx.media3.common.Format,
|
||||
index: Int,
|
||||
hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
|
||||
): String {
|
||||
val mime = format.sampleMimeType ?: ""
|
||||
val lang = prettyLanguage(format.language)
|
||||
val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
|
||||
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
|
||||
val label = format.label?.takeIf { !isGenericTrackLabel(it) }
|
||||
?: hint?.title?.takeIf { !isGenericTrackLabel(it) && prettyLanguage(extractLanguageToken(it)) == null }
|
||||
return listOfNotNull(
|
||||
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
|
||||
lang,
|
||||
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
|
||||
shortMime(mime).takeIf { it.isNotBlank() },
|
||||
ch,
|
||||
).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
|
||||
).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
|
||||
}
|
||||
|
||||
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
val lang = prettyLanguage(format.language)
|
||||
private fun formatTextTrackLabel(
|
||||
format: androidx.media3.common.Format,
|
||||
index: Int,
|
||||
hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
|
||||
): String {
|
||||
val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
|
||||
val kind = when {
|
||||
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
|
||||
format.sampleMimeType?.contains("ssa", true) == true ||
|
||||
|
|
@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
|
|||
else -> null
|
||||
}
|
||||
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
|
||||
val label = format.label?.takeIf {
|
||||
it.isNotBlank() &&
|
||||
!it.equals("und", true) &&
|
||||
!it.equals("sub", true) &&
|
||||
!it.equals("subtitle", true)
|
||||
}
|
||||
return listOfNotNull(label, lang, kind, forced)
|
||||
val label = format.label?.takeIf { !isGenericTrackLabel(it) }
|
||||
?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
|
||||
return listOfNotNull(
|
||||
lang,
|
||||
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
|
||||
kind,
|
||||
forced,
|
||||
)
|
||||
.distinct()
|
||||
.joinToString(" · ")
|
||||
.ifBlank { "Ondertitel ${index + 1}" }
|
||||
}
|
||||
|
||||
/** Taal uit Format.language, anders uit label/id (Dutch, nl, Track-junk negeren). */
|
||||
private fun resolveLanguage(format: androidx.media3.common.Format): String? {
|
||||
prettyLanguage(format.language)?.let { return it }
|
||||
prettyLanguage(extractLanguageToken(format.label))?.let { return it }
|
||||
prettyLanguage(extractLanguageToken(format.id))?.let { return it }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isGenericTrackLabel(raw: String?): Boolean {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return true
|
||||
val lower = s.lowercase()
|
||||
if (lower in setOf("und", "unknown", "null", "sub", "subtitle", "subs")) return true
|
||||
if (lower.contains("text/") || lower.startsWith("http")) return true
|
||||
if (Regex("""^track\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^subtitle\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^ondertitel\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^audio\s*\d+$""").matches(lower)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun extractLanguageToken(raw: String?): String? {
|
||||
val s = raw?.trim()?.lowercase().orEmpty()
|
||||
if (s.isEmpty() || isGenericTrackLabel(s)) return null
|
||||
val known = listOf(
|
||||
"nederlands", "dutch", "nl", "dut", "nld",
|
||||
"engels", "english", "en", "eng",
|
||||
"duits", "german", "de", "ger", "deu",
|
||||
"frans", "french", "fr", "fre", "fra",
|
||||
"spaans", "spanish", "es", "spa",
|
||||
"italiaans", "italian", "it", "ita",
|
||||
"portugees", "portuguese", "pt", "por",
|
||||
"pools", "polish", "pl", "pol",
|
||||
"russisch", "russian", "ru", "rus",
|
||||
"japans", "japanese", "ja", "jpn",
|
||||
"koreaans", "korean", "ko", "kor",
|
||||
"chinees", "chinese", "zh", "chi", "zho",
|
||||
)
|
||||
val tokens = Regex("""[a-z]{2,}""").findAll(s).map { it.value }.toList()
|
||||
for (k in known) {
|
||||
if (s == k || tokens.contains(k)) return k
|
||||
}
|
||||
return tokens.firstOrNull { it.length in 2..3 }
|
||||
}
|
||||
|
||||
private fun prettyLanguage(code: String?): String? {
|
||||
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
|
||||
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" && it != "unknown" }
|
||||
?: return null
|
||||
return when (c) {
|
||||
"nl", "dut", "nld" -> "Nederlands"
|
||||
"en", "eng" -> "Engels"
|
||||
"de", "ger", "deu" -> "Duits"
|
||||
"fr", "fre", "fra" -> "Frans"
|
||||
"es", "spa" -> "Spaans"
|
||||
"it", "ita" -> "Italiaans"
|
||||
"pt", "por" -> "Portugees"
|
||||
"pl", "pol" -> "Pools"
|
||||
"ru", "rus" -> "Russisch"
|
||||
"ja", "jpn" -> "Japans"
|
||||
"ko", "kor" -> "Koreaans"
|
||||
"zh", "chi", "zho", "cmn" -> "Chinees"
|
||||
"ar", "ara" -> "Arabisch"
|
||||
"tr", "tur" -> "Turks"
|
||||
"sv", "swe" -> "Zweeds"
|
||||
"no", "nor", "nb", "nn" -> "Noors"
|
||||
"da", "dan" -> "Deens"
|
||||
"fi", "fin" -> "Fins"
|
||||
else -> c.uppercase()
|
||||
"nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
|
||||
"en", "eng", "english", "engels" -> "Engels"
|
||||
"de", "ger", "deu", "german", "duits" -> "Duits"
|
||||
"fr", "fre", "fra", "french", "frans" -> "Frans"
|
||||
"es", "spa", "spanish", "spaans" -> "Spaans"
|
||||
"it", "ita", "italian", "italiaans" -> "Italiaans"
|
||||
"pt", "por", "portuguese", "portugees" -> "Portugees"
|
||||
"pl", "pol", "polish", "pools" -> "Pools"
|
||||
"ru", "rus", "russian", "russisch" -> "Russisch"
|
||||
"ja", "jpn", "japanese", "japans" -> "Japans"
|
||||
"ko", "kor", "korean", "koreaans" -> "Koreaans"
|
||||
"zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
|
||||
"ar", "ara", "arabic", "arabisch" -> "Arabisch"
|
||||
"tr", "tur", "turkish", "turks" -> "Turks"
|
||||
"sv", "swe", "swedish", "zweeds" -> "Zweeds"
|
||||
"no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
|
||||
"da", "dan", "danish", "deens" -> "Deens"
|
||||
"fi", "fin", "finnish", "fins" -> "Fins"
|
||||
else -> {
|
||||
if (c.length <= 3) {
|
||||
val display = java.util.Locale.forLanguageTag(c).getDisplayLanguage(java.util.Locale("nl"))
|
||||
if (display.isNotBlank() && !display.equals(c, ignoreCase = true)) {
|
||||
display.replaceFirstChar { it.uppercase() }
|
||||
} else {
|
||||
c.uppercase()
|
||||
}
|
||||
} else {
|
||||
c.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
@Composable
|
||||
fun McNav() {
|
||||
fun McNav(
|
||||
pendingLinkCode: String? = null,
|
||||
onPendingLinkCodeConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val store = remember { SessionStore(context) }
|
||||
val api = remember { ApiClient(baseUrl = "") }
|
||||
|
|
@ -99,6 +102,11 @@ fun McNav() {
|
|||
var ready by remember { mutableStateOf(false) }
|
||||
var authed by remember { mutableStateOf(false) }
|
||||
var hasProfile by remember { mutableStateOf(false) }
|
||||
var canManageDevices by remember { mutableStateOf(false) }
|
||||
var showApproveDialog by remember { mutableStateOf(false) }
|
||||
var approveBusy by remember { mutableStateOf(false) }
|
||||
var approveError by remember { mutableStateOf<String?>(null) }
|
||||
var approveDoneName by remember { mutableStateOf<String?>(null) }
|
||||
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
|
||||
val profileName by store.profileName.collectAsState(initial = null)
|
||||
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||
|
|
@ -110,6 +118,13 @@ fun McNav() {
|
|||
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
|
||||
}
|
||||
|
||||
fun refreshOwnerFlag() {
|
||||
scope.launch {
|
||||
val me = runCatching { api.clientMe() }.getOrNull()
|
||||
canManageDevices = me?.profile?.isOwner == true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val base = store.apiBase.first()
|
||||
val token = store.refreshToken.first()
|
||||
|
|
@ -120,21 +135,34 @@ fun McNav() {
|
|||
if (me?.profile != null) {
|
||||
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
|
||||
hasProfile = true
|
||||
canManageDevices = me.profile.isOwner
|
||||
} else {
|
||||
store.setProfile(null)
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
}
|
||||
} else {
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
}
|
||||
ready = true
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingLinkCode, ready, authed, hasProfile) {
|
||||
if (!ready || pendingLinkCode.isNullOrBlank()) return@LaunchedEffect
|
||||
if (authed && hasProfile) {
|
||||
showApproveDialog = true
|
||||
approveError = null
|
||||
approveDoneName = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
Splash()
|
||||
return
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = when {
|
||||
|
|
@ -173,6 +201,7 @@ fun McNav() {
|
|||
onLoggedIn = {
|
||||
authed = true
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
|
||||
},
|
||||
)
|
||||
|
|
@ -190,21 +219,32 @@ fun McNav() {
|
|||
) {
|
||||
authed = true
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
|
||||
}
|
||||
}
|
||||
composable("profiles") {
|
||||
ProfilesScreen(api = api, store = store, switcherMode = true) {
|
||||
hasProfile = true
|
||||
refreshOwnerFlag()
|
||||
nav.navigate("main") { popUpTo("profiles") { inclusive = true } }
|
||||
}
|
||||
}
|
||||
composable("profiles_manage") {
|
||||
ProfilesScreen(api = api, store = store, switcherMode = false) {
|
||||
hasProfile = true
|
||||
refreshOwnerFlag()
|
||||
nav.popBackStack()
|
||||
}
|
||||
}
|
||||
composable("devices") {
|
||||
DevicesScreen(
|
||||
api = api,
|
||||
initialCode = pendingLinkCode,
|
||||
onBack = { nav.popBackStack() },
|
||||
onConsumedPendingCode = onPendingLinkCodeConsumed,
|
||||
)
|
||||
}
|
||||
composable("main") {
|
||||
MainHub(
|
||||
api = api,
|
||||
|
|
@ -218,6 +258,7 @@ fun McNav() {
|
|||
onTvFocusContent = { tvFocusNonce++ },
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileName = profileName,
|
||||
canManageDevices = canManageDevices,
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onBrowse = { catalogId, _title ->
|
||||
|
|
@ -239,6 +280,7 @@ fun McNav() {
|
|||
runCatching { api.clearProfile() }
|
||||
store.setProfile(null)
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
nav.navigate("profiles") {
|
||||
popUpTo("main") { inclusive = true }
|
||||
}
|
||||
|
|
@ -247,12 +289,17 @@ fun McNav() {
|
|||
onManageProfiles = {
|
||||
nav.navigate("profiles_manage")
|
||||
},
|
||||
onManageDevices = {
|
||||
nav.navigate("devices")
|
||||
},
|
||||
onLogout = {
|
||||
scope.launch {
|
||||
store.setToken(null)
|
||||
api.configure(store.apiBase.first(), null)
|
||||
authed = false
|
||||
hasProfile = false
|
||||
canManageDevices = false
|
||||
onPendingLinkCodeConsumed()
|
||||
nav.navigate("server") { popUpTo(0) { inclusive = true } }
|
||||
}
|
||||
},
|
||||
|
|
@ -344,6 +391,41 @@ fun McNav() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showApproveDialog && !pendingLinkCode.isNullOrBlank()) {
|
||||
DeviceApproveDialog(
|
||||
code = pendingLinkCode,
|
||||
canManage = canManageDevices,
|
||||
busy = approveBusy,
|
||||
error = approveError,
|
||||
doneName = approveDoneName,
|
||||
onApprove = {
|
||||
scope.launch {
|
||||
approveBusy = true
|
||||
approveError = null
|
||||
try {
|
||||
val result = api.approveDeviceCode(pendingLinkCode)
|
||||
approveDoneName = result.deviceName ?: "Apparaat"
|
||||
delay(900)
|
||||
showApproveDialog = false
|
||||
onPendingLinkCodeConsumed()
|
||||
approveDoneName = null
|
||||
} catch (e: Exception) {
|
||||
approveError = e.message
|
||||
} finally {
|
||||
approveBusy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showApproveDialog = false
|
||||
onPendingLinkCodeConsumed()
|
||||
approveError = null
|
||||
approveDoneName = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -359,6 +441,7 @@ private fun MainHub(
|
|||
onTvFocusContent: () -> Unit,
|
||||
profileAvatarId: String?,
|
||||
profileName: String?,
|
||||
canManageDevices: Boolean,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||
|
|
@ -366,6 +449,7 @@ private fun MainHub(
|
|||
onPlayLive: (Int) -> Unit,
|
||||
onSwitchProfile: () -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
onManageDevices: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
var iptvEnabled by remember { mutableStateOf(false) }
|
||||
|
|
@ -539,12 +623,14 @@ private fun MainHub(
|
|||
api = api,
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileName = profileName,
|
||||
canManageDevices = canManageDevices,
|
||||
onBack = {
|
||||
onTabChange(MainTab.Home)
|
||||
focusTopNav()
|
||||
},
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onManageProfiles = onManageProfiles,
|
||||
onManageDevices = onManageDevices,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
}
|
||||
|
|
@ -1139,9 +1225,11 @@ private fun SettingsScreen(
|
|||
api: ApiClient,
|
||||
profileAvatarId: String? = null,
|
||||
profileName: String? = null,
|
||||
canManageDevices: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
onManageProfiles: () -> Unit = {},
|
||||
onManageDevices: () -> Unit = {},
|
||||
onLogout: () -> Unit = {},
|
||||
) {
|
||||
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||
|
|
@ -1262,6 +1350,31 @@ private fun SettingsScreen(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
if (canManageDevices) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(McColors.BgElevated)
|
||||
.clickable(onClick = onManageDevices)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Apparaten",
|
||||
color = Color.White,
|
||||
fontSize = McDimens.body,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Koppelen & beheren",
|
||||
color = McColors.TextDim,
|
||||
fontSize = McDimens.meta,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -108,6 +109,8 @@ fun PlayerScreen(
|
|||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
var audioTracks by remember { mutableStateOf<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 osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
|
||||
var osLoading by remember { mutableStateOf(false) }
|
||||
|
|
@ -209,6 +212,7 @@ fun PlayerScreen(
|
|||
LaunchedEffect(mediaFileId) {
|
||||
try {
|
||||
val play = api.play(mediaFileId)
|
||||
embeddedHints = play.embeddedTracks
|
||||
player.volume = 1f
|
||||
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
|
||||
player.prepare()
|
||||
|
|
@ -248,8 +252,8 @@ fun PlayerScreen(
|
|||
if (state == Player.STATE_READY) {
|
||||
player.volume = 1f
|
||||
player.ensureAudioSelected()
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,8 +262,8 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
|
||||
player.ensureAudioSelected()
|
||||
player.volume = 1f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -45,13 +46,14 @@ fun McButton(
|
|||
modifier: Modifier = Modifier,
|
||||
primary: Boolean = false,
|
||||
compact: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.96f else 1f,
|
||||
targetValue = if (pressed && enabled) 0.96f else 1f,
|
||||
animationSpec = tween(100),
|
||||
label = "btnScale",
|
||||
)
|
||||
|
|
@ -64,10 +66,17 @@ fun McButton(
|
|||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
alpha = if (enabled) 1f else 0.4f
|
||||
}
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bg)
|
||||
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = hPad, vertical = vPad),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 82
|
||||
versionName = "0.14.19"
|
||||
versionCode = 86
|
||||
versionName = "0.14.23"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -172,6 +172,21 @@ class ApiClient(
|
|||
label = s.optString("label").ifBlank { "Lokaal" },
|
||||
)
|
||||
},
|
||||
embeddedTracks = o.optJSONArray("embeddedTracks")?.let { arr ->
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val t = arr.optJSONObject(i) ?: continue
|
||||
add(
|
||||
EmbeddedTrackInfo(
|
||||
type = t.optString("type").ifBlank { "unknown" },
|
||||
language = t.optString("language").ifBlank { null },
|
||||
title = t.optString("title").ifBlank { null },
|
||||
codec = t.optString("codec").ifBlank { null },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -592,6 +607,14 @@ data class PlayResult(
|
|||
val sessionId: String,
|
||||
val mediaFileId: String,
|
||||
val defaultSubtitle: DefaultSubtitle? = null,
|
||||
val embeddedTracks: List<EmbeddedTrackInfo> = emptyList(),
|
||||
)
|
||||
|
||||
data class EmbeddedTrackInfo(
|
||||
val type: String,
|
||||
val language: String? = null,
|
||||
val title: String? = null,
|
||||
val codec: String? = null,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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 groups = currentTracks.groups
|
||||
// ExoPlayer zet elke text/audio track vaak in een eigen group → ti is dan altijd 0.
|
||||
// Gebruik een globale ordinal voor unieke "Ondertitel N" / "Audio N" labels.
|
||||
val hintPool = when (type) {
|
||||
C.TRACK_TYPE_TEXT -> embeddedHints.filter { it.type.equals("subtitle", true) }
|
||||
C.TRACK_TYPE_AUDIO -> embeddedHints.filter { it.type.equals("audio", true) }
|
||||
else -> emptyList()
|
||||
}
|
||||
var ordinal = 0
|
||||
for (gi in 0 until groups.size) {
|
||||
val group = groups[gi]
|
||||
if (group.type != type) continue
|
||||
for (ti in 0 until group.length) {
|
||||
val format = group.getTrackFormat(ti)
|
||||
val hint = hintPool.getOrNull(ordinal)
|
||||
val label = if (type == C.TRACK_TYPE_TEXT) {
|
||||
formatTextTrackLabel(format, ti)
|
||||
formatTextTrackLabel(format, ordinal, hint)
|
||||
} else {
|
||||
formatAudioTrackLabel(format, ti)
|
||||
formatAudioTrackLabel(format, ordinal, hint)
|
||||
}
|
||||
ordinal++
|
||||
out += PlayerTrack(gi, ti, label, type)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
private fun formatAudioTrackLabel(
|
||||
format: androidx.media3.common.Format,
|
||||
index: Int,
|
||||
hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
|
||||
): String {
|
||||
val mime = format.sampleMimeType ?: ""
|
||||
val lang = prettyLanguage(format.language)
|
||||
val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
|
||||
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
|
||||
val label = format.label?.takeIf { !isGenericTrackLabel(it) }
|
||||
?: hint?.title?.takeIf { !isGenericTrackLabel(it) && prettyLanguage(extractLanguageToken(it)) == null }
|
||||
return listOfNotNull(
|
||||
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
|
||||
lang,
|
||||
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
|
||||
shortMime(mime).takeIf { it.isNotBlank() },
|
||||
ch,
|
||||
).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
|
||||
).distinct().joinToString(" · ").ifBlank { "Audio ${index + 1}" }
|
||||
}
|
||||
|
||||
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
val lang = prettyLanguage(format.language)
|
||||
private fun formatTextTrackLabel(
|
||||
format: androidx.media3.common.Format,
|
||||
index: Int,
|
||||
hint: nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo? = null,
|
||||
): String {
|
||||
val lang = resolveLanguage(format) ?: prettyLanguage(hint?.language) ?: prettyLanguage(extractLanguageToken(hint?.title))
|
||||
val kind = when {
|
||||
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
|
||||
format.sampleMimeType?.contains("ssa", true) == true ||
|
||||
|
|
@ -283,40 +303,98 @@ private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: I
|
|||
else -> null
|
||||
}
|
||||
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
|
||||
val label = format.label?.takeIf {
|
||||
it.isNotBlank() &&
|
||||
!it.equals("und", true) &&
|
||||
!it.equals("sub", true) &&
|
||||
!it.equals("subtitle", true)
|
||||
}
|
||||
return listOfNotNull(label, lang, kind, forced)
|
||||
val label = format.label?.takeIf { !isGenericTrackLabel(it) }
|
||||
?: hint?.title?.takeIf { !isGenericTrackLabel(it) && (lang == null || !it.contains(lang, ignoreCase = true)) }
|
||||
return listOfNotNull(
|
||||
lang,
|
||||
label?.takeIf { lang == null || !it.contains(lang, ignoreCase = true) },
|
||||
kind,
|
||||
forced,
|
||||
)
|
||||
.distinct()
|
||||
.joinToString(" · ")
|
||||
.ifBlank { "Ondertitel ${index + 1}" }
|
||||
}
|
||||
|
||||
/** Taal uit Format.language, anders uit label/id (Dutch, nl, Track-junk negeren). */
|
||||
private fun resolveLanguage(format: androidx.media3.common.Format): String? {
|
||||
prettyLanguage(format.language)?.let { return it }
|
||||
prettyLanguage(extractLanguageToken(format.label))?.let { return it }
|
||||
prettyLanguage(extractLanguageToken(format.id))?.let { return it }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isGenericTrackLabel(raw: String?): Boolean {
|
||||
val s = raw?.trim().orEmpty()
|
||||
if (s.isEmpty()) return true
|
||||
val lower = s.lowercase()
|
||||
if (lower in setOf("und", "unknown", "null", "sub", "subtitle", "subs")) return true
|
||||
if (lower.contains("text/") || lower.startsWith("http")) return true
|
||||
if (Regex("""^track\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^subtitle\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^ondertitel\s*\d+$""").matches(lower)) return true
|
||||
if (Regex("""^audio\s*\d+$""").matches(lower)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun extractLanguageToken(raw: String?): String? {
|
||||
val s = raw?.trim()?.lowercase().orEmpty()
|
||||
if (s.isEmpty() || isGenericTrackLabel(s)) return null
|
||||
val known = listOf(
|
||||
"nederlands", "dutch", "nl", "dut", "nld",
|
||||
"engels", "english", "en", "eng",
|
||||
"duits", "german", "de", "ger", "deu",
|
||||
"frans", "french", "fr", "fre", "fra",
|
||||
"spaans", "spanish", "es", "spa",
|
||||
"italiaans", "italian", "it", "ita",
|
||||
"portugees", "portuguese", "pt", "por",
|
||||
"pools", "polish", "pl", "pol",
|
||||
"russisch", "russian", "ru", "rus",
|
||||
"japans", "japanese", "ja", "jpn",
|
||||
"koreaans", "korean", "ko", "kor",
|
||||
"chinees", "chinese", "zh", "chi", "zho",
|
||||
)
|
||||
val tokens = Regex("""[a-z]{2,}""").findAll(s).map { it.value }.toList()
|
||||
for (k in known) {
|
||||
if (s == k || tokens.contains(k)) return k
|
||||
}
|
||||
return tokens.firstOrNull { it.length in 2..3 }
|
||||
}
|
||||
|
||||
private fun prettyLanguage(code: String?): String? {
|
||||
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
|
||||
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" && it != "unknown" }
|
||||
?: return null
|
||||
return when (c) {
|
||||
"nl", "dut", "nld" -> "Nederlands"
|
||||
"en", "eng" -> "Engels"
|
||||
"de", "ger", "deu" -> "Duits"
|
||||
"fr", "fre", "fra" -> "Frans"
|
||||
"es", "spa" -> "Spaans"
|
||||
"it", "ita" -> "Italiaans"
|
||||
"pt", "por" -> "Portugees"
|
||||
"pl", "pol" -> "Pools"
|
||||
"ru", "rus" -> "Russisch"
|
||||
"ja", "jpn" -> "Japans"
|
||||
"ko", "kor" -> "Koreaans"
|
||||
"zh", "chi", "zho", "cmn" -> "Chinees"
|
||||
"ar", "ara" -> "Arabisch"
|
||||
"tr", "tur" -> "Turks"
|
||||
"sv", "swe" -> "Zweeds"
|
||||
"no", "nor", "nb", "nn" -> "Noors"
|
||||
"da", "dan" -> "Deens"
|
||||
"fi", "fin" -> "Fins"
|
||||
else -> c.uppercase()
|
||||
"nl", "dut", "nld", "dutch", "nederlands" -> "Nederlands"
|
||||
"en", "eng", "english", "engels" -> "Engels"
|
||||
"de", "ger", "deu", "german", "duits" -> "Duits"
|
||||
"fr", "fre", "fra", "french", "frans" -> "Frans"
|
||||
"es", "spa", "spanish", "spaans" -> "Spaans"
|
||||
"it", "ita", "italian", "italiaans" -> "Italiaans"
|
||||
"pt", "por", "portuguese", "portugees" -> "Portugees"
|
||||
"pl", "pol", "polish", "pools" -> "Pools"
|
||||
"ru", "rus", "russian", "russisch" -> "Russisch"
|
||||
"ja", "jpn", "japanese", "japans" -> "Japans"
|
||||
"ko", "kor", "korean", "koreaans" -> "Koreaans"
|
||||
"zh", "chi", "zho", "cmn", "chinese", "chinees" -> "Chinees"
|
||||
"ar", "ara", "arabic", "arabisch" -> "Arabisch"
|
||||
"tr", "tur", "turkish", "turks" -> "Turks"
|
||||
"sv", "swe", "swedish", "zweeds" -> "Zweeds"
|
||||
"no", "nor", "nb", "nn", "norwegian", "noors" -> "Noors"
|
||||
"da", "dan", "danish", "deens" -> "Deens"
|
||||
"fi", "fin", "finnish", "fins" -> "Fins"
|
||||
else -> {
|
||||
if (c.length <= 3) {
|
||||
val display = java.util.Locale.forLanguageTag(c).getDisplayLanguage(java.util.Locale("nl"))
|
||||
if (display.isNotBlank() && !display.equals(c, ignoreCase = true)) {
|
||||
display.replaceFirstChar { it.uppercase() }
|
||||
} else {
|
||||
c.uppercase()
|
||||
}
|
||||
} else {
|
||||
c.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -76,7 +78,6 @@ import nl.vonas.mediacluster.tv.playback.selectTrack
|
|||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -105,6 +106,8 @@ fun PlayerScreen(
|
|||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
var audioTracks by remember { mutableStateOf<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 osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
|
||||
var osLoading by remember { mutableStateOf(false) }
|
||||
|
|
@ -206,6 +209,7 @@ fun PlayerScreen(
|
|||
LaunchedEffect(mediaFileId) {
|
||||
try {
|
||||
val play = api.play(mediaFileId)
|
||||
embeddedHints = play.embeddedTracks
|
||||
player.volume = 1f
|
||||
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
|
||||
player.prepare()
|
||||
|
|
@ -245,8 +249,8 @@ fun PlayerScreen(
|
|||
if (state == Player.STATE_READY) {
|
||||
player.volume = 1f
|
||||
player.ensureAudioSelected()
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,8 +259,8 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
|
||||
player.ensureAudioSelected()
|
||||
player.volume = 1f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx5120m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.caching=true
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -332,7 +332,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
MARKETING_VERSION = 0.4.7;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -350,7 +350,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 11;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -365,7 +365,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
MARKETING_VERSION = 0.4.7;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -188,8 +188,7 @@ final class AppModel: ObservableObject {
|
|||
let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" }
|
||||
if cleaned.contains("-") { return cleaned }
|
||||
if cleaned.count == 8 {
|
||||
let i = cleaned.index(cleaned.startIndex, offsetBy: 4)
|
||||
return "\(cleaned[..i])-\(cleaned[i...])"
|
||||
return "\(cleaned.prefix(4))-\(cleaned.suffix(4))"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import MobileVLCKit
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate {
|
||||
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate, VLCMediaDelegate {
|
||||
@Published var isPlaying = false
|
||||
@Published var position: Float = 0
|
||||
@Published var timeText = "0:00"
|
||||
|
|
@ -36,6 +36,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
subtitleTracks = []
|
||||
audioTracks = []
|
||||
let media = VLCMedia(url: url)
|
||||
media.delegate = self
|
||||
media.addOption(":network-caching=2500")
|
||||
media.addOption(":http-reconnect")
|
||||
media.addOption(":avcodec-hw=any")
|
||||
|
|
@ -45,6 +46,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||
media.addOption(":freetype-bold=1")
|
||||
mediaPlayer.media = media
|
||||
// Async parse zodat tracksInformation language/title beschikbaar wordt.
|
||||
// MobileVLCKit (deze binary) exposeert parseWithOptions: niet netjes naar Swift.
|
||||
media.parse()
|
||||
mediaPlayer.play()
|
||||
applySubtitleStyle()
|
||||
if startMs > 2000 {
|
||||
|
|
@ -69,21 +73,25 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
errorMessage = nil
|
||||
stateText = "Live laden…"
|
||||
let media = VLCMedia(url: url)
|
||||
media.delegate = self
|
||||
media.addOption(":network-caching=1200")
|
||||
media.addOption(":http-reconnect")
|
||||
media.addOption(":live-caching=1000")
|
||||
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||
mediaPlayer.media = media
|
||||
media.parse()
|
||||
mediaPlayer.play()
|
||||
applySubtitleStyle()
|
||||
if let fallback {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||
guard let self, !self.isPlaying || self.hasError else { return }
|
||||
let fb = VLCMedia(url: fallback)
|
||||
fb.delegate = self
|
||||
fb.addOption(":network-caching=1200")
|
||||
fb.addOption(":http-reconnect")
|
||||
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
|
||||
self.mediaPlayer.media = fb
|
||||
fb.parse()
|
||||
self.mediaPlayer.play()
|
||||
self.applySubtitleStyle()
|
||||
}
|
||||
|
|
@ -141,41 +149,74 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
}
|
||||
|
||||
func refreshTracks() {
|
||||
let meta = trackMetaById()
|
||||
var textOrderHints: [String?] = []
|
||||
var audioOrderHints: [String?] = []
|
||||
for info in (mediaPlayer.media?.tracksInformation as? [Any]) ?? [] {
|
||||
guard let dict = info as? [AnyHashable: Any] else { continue }
|
||||
let type = ((dict[VLCMediaTracksInformationType] as? String)
|
||||
?? (dict["type"] as? String)
|
||||
?? "").lowercased()
|
||||
let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
|
||||
?? (dict["language"] as? String)
|
||||
let desc = (dict[VLCMediaTracksInformationDescription] as? String)
|
||||
?? (dict["description"] as? String)
|
||||
let hint = lang ?? desc
|
||||
if type.contains("text") {
|
||||
textOrderHints.append(hint)
|
||||
} else if type.contains("audio") {
|
||||
audioOrderHints.append(hint)
|
||||
}
|
||||
}
|
||||
|
||||
var subs: [PlayerTrack] = [PlayerTrack(id: -1, name: "Uit")]
|
||||
var textOrd = 0
|
||||
if let names = mediaPlayer.videoSubTitlesNames as? [Any],
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [Any] {
|
||||
for (i, raw) in indexes.enumerated() {
|
||||
let idx = (raw as? NSNumber)?.int32Value ?? Int32(truncating: raw as? NSNumber ?? -1)
|
||||
if idx < 0 { continue }
|
||||
let label: String
|
||||
if i < names.count, let n = names[i] as? String, !n.isEmpty {
|
||||
label = n
|
||||
} else {
|
||||
label = "Ondertitel \(subs.count)"
|
||||
}
|
||||
// Skip junk mime-like labels
|
||||
if label.uppercased().contains("TEXT/") || label.uppercased().hasPrefix("HTTP") {
|
||||
subs.append(PlayerTrack(id: idx, name: "Ondertitel \(subs.count)"))
|
||||
} else {
|
||||
let rawName: String = {
|
||||
if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
|
||||
return ""
|
||||
}()
|
||||
let fromId = meta[idx]
|
||||
let fromOrder = textOrd < textOrderHints.count ? textOrderHints[textOrd] : nil
|
||||
textOrd += 1
|
||||
let label = Self.displayTrackLabel(
|
||||
rawName: rawName,
|
||||
fallbackIndex: subs.count,
|
||||
kind: .subtitle,
|
||||
languageHint: fromId?.language ?? fromOrder,
|
||||
descriptionHint: fromId?.description
|
||||
)
|
||||
subs.append(PlayerTrack(id: idx, name: label))
|
||||
}
|
||||
}
|
||||
}
|
||||
subtitleTracks = subs
|
||||
currentSubtitleId = mediaPlayer.currentVideoSubTitleIndex
|
||||
|
||||
var audios: [PlayerTrack] = []
|
||||
var audioOrd = 0
|
||||
if let names = mediaPlayer.audioTrackNames as? [Any],
|
||||
let indexes = mediaPlayer.audioTrackIndexes as? [Any] {
|
||||
for (i, raw) in indexes.enumerated() {
|
||||
let idx = (raw as? NSNumber)?.int32Value ?? -1
|
||||
if idx < 0 { continue }
|
||||
let label: String
|
||||
if i < names.count, let n = names[i] as? String, !n.isEmpty {
|
||||
label = n
|
||||
} else {
|
||||
label = "Audio \(audios.count + 1)"
|
||||
}
|
||||
let rawName: String = {
|
||||
if i < names.count, let n = names[i] as? String, !n.isEmpty { return n }
|
||||
return ""
|
||||
}()
|
||||
let fromId = meta[idx]
|
||||
let fromOrder = audioOrd < audioOrderHints.count ? audioOrderHints[audioOrd] : nil
|
||||
audioOrd += 1
|
||||
let label = Self.displayTrackLabel(
|
||||
rawName: rawName,
|
||||
fallbackIndex: audios.count + 1,
|
||||
kind: .audio,
|
||||
languageHint: fromId?.language ?? fromOrder,
|
||||
descriptionHint: fromId?.description
|
||||
)
|
||||
audios.append(PlayerTrack(id: idx, name: label))
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +224,138 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
currentAudioId = mediaPlayer.currentAudioTrackIndex
|
||||
}
|
||||
|
||||
private struct TrackMeta {
|
||||
var language: String?
|
||||
var description: String?
|
||||
}
|
||||
|
||||
private func trackMetaById() -> [Int32: TrackMeta] {
|
||||
guard let media = mediaPlayer.media else { return [:] }
|
||||
var out: [Int32: TrackMeta] = [:]
|
||||
for info in (media.tracksInformation as? [Any]) ?? [] {
|
||||
guard let dict = info as? [AnyHashable: Any] else { continue }
|
||||
let tid = (dict[VLCMediaTracksInformationId] as? NSNumber)?.int32Value
|
||||
?? (dict["id"] as? NSNumber)?.int32Value
|
||||
guard let tid else { continue }
|
||||
let lang = (dict[VLCMediaTracksInformationLanguage] as? String)
|
||||
?? (dict["language"] as? String)
|
||||
let desc = (dict[VLCMediaTracksInformationDescription] as? String)
|
||||
?? (dict["description"] as? String)
|
||||
out[tid] = TrackMeta(language: lang, description: desc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private enum TrackKind { case subtitle, audio }
|
||||
|
||||
private static func displayTrackLabel(
|
||||
rawName: String,
|
||||
fallbackIndex: Int,
|
||||
kind: TrackKind,
|
||||
languageHint: String?,
|
||||
descriptionHint: String?
|
||||
) -> String {
|
||||
let prettyLang =
|
||||
prettyLanguage(languageHint)
|
||||
?? prettyLanguage(extractLanguageToken(from: descriptionHint))
|
||||
?? prettyLanguage(extractLanguageToken(from: rawName))
|
||||
|
||||
if isGenericTrackLabel(rawName)
|
||||
|| rawName.uppercased().contains("TEXT/")
|
||||
|| rawName.uppercased().hasPrefix("HTTP")
|
||||
{
|
||||
if let prettyLang {
|
||||
if let descriptionHint,
|
||||
!descriptionHint.isEmpty,
|
||||
!isGenericTrackLabel(descriptionHint),
|
||||
!descriptionHint.localizedCaseInsensitiveContains(prettyLang)
|
||||
{
|
||||
return "\(prettyLang) · \(descriptionHint)"
|
||||
}
|
||||
return prettyLang
|
||||
}
|
||||
return kind == .subtitle ? "Ondertitel \(fallbackIndex)" : "Audio \(fallbackIndex)"
|
||||
}
|
||||
|
||||
if let prettyLang, !rawName.localizedCaseInsensitiveContains(prettyLang) {
|
||||
return "\(prettyLang) · \(rawName)"
|
||||
}
|
||||
return rawName
|
||||
}
|
||||
|
||||
private static func isGenericTrackLabel(_ raw: String?) -> Bool {
|
||||
let s = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.isEmpty { return true }
|
||||
let lower = s.lowercased()
|
||||
if lower == "und" || lower == "unknown" || lower == "null" || lower == "sub" || lower == "subtitle" {
|
||||
return true
|
||||
}
|
||||
if lower.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil { return true }
|
||||
if lower.range(of: #"^subtitle\s*\d+$"#, options: .regularExpression) != nil { return true }
|
||||
if lower.range(of: #"^ondertitel\s*\d+$"#, options: .regularExpression) != nil { return true }
|
||||
if lower.range(of: #"^audio\s*\d+$"#, options: .regularExpression) != nil { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func extractLanguageToken(from raw: String?) -> String? {
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
let lower = raw.lowercased()
|
||||
let known = [
|
||||
"nederlands", "dutch", "nl", "dut", "nld",
|
||||
"engels", "english", "en", "eng",
|
||||
"duits", "german", "de", "ger", "deu",
|
||||
"frans", "french", "fr", "fre", "fra",
|
||||
"spaans", "spanish", "es", "spa",
|
||||
"italiaans", "italian", "it", "ita",
|
||||
"portugees", "portuguese", "pt", "por",
|
||||
"pools", "polish", "pl", "pol",
|
||||
"russisch", "russian", "ru", "rus",
|
||||
"japans", "japanese", "ja", "jpn",
|
||||
"koreaans", "korean", "ko", "kor",
|
||||
"chinees", "chinese", "zh", "chi", "zho",
|
||||
]
|
||||
let tokens = lower.split(whereSeparator: { !$0.isLetter }).map(String.init)
|
||||
for token in known where tokens.contains(token) || lower == token {
|
||||
return token
|
||||
}
|
||||
if let match = lower.range(of: #"\b[a-z]{2,3}\b"#, options: .regularExpression) {
|
||||
return String(lower[match])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func prettyLanguage(_ code: String?) -> String? {
|
||||
let c = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !c.isEmpty, c != "und", c != "null", c != "unknown" else { return nil }
|
||||
switch c {
|
||||
case "nl", "dut", "nld", "dutch", "nederlands": return "Nederlands"
|
||||
case "en", "eng", "english", "engels": return "Engels"
|
||||
case "de", "ger", "deu", "german", "duits": return "Duits"
|
||||
case "fr", "fre", "fra", "french", "frans": return "Frans"
|
||||
case "es", "spa", "spanish", "spaans": return "Spaans"
|
||||
case "it", "ita", "italian", "italiaans": return "Italiaans"
|
||||
case "pt", "por", "portuguese", "portugees": return "Portugees"
|
||||
case "pl", "pol", "polish", "pools": return "Pools"
|
||||
case "ru", "rus", "russian", "russisch": return "Russisch"
|
||||
case "ja", "jpn", "japanese", "japans": return "Japans"
|
||||
case "ko", "kor", "korean", "koreaans": return "Koreaans"
|
||||
case "zh", "chi", "zho", "cmn", "chinese", "chinees": return "Chinees"
|
||||
case "ar", "ara", "arabic", "arabisch": return "Arabisch"
|
||||
case "tr", "tur", "turkish", "turks": return "Turks"
|
||||
case "sv", "swe", "swedish", "zweeds": return "Zweeds"
|
||||
case "no", "nor", "nb", "nn", "norwegian", "noors": return "Noors"
|
||||
case "da", "dan", "danish", "deens": return "Deens"
|
||||
case "fi", "fin", "finnish", "fins": return "Fins"
|
||||
default:
|
||||
if c.count <= 3 {
|
||||
let name = Locale(identifier: "nl_NL").localizedString(forLanguageCode: c)
|
||||
if let name, !name.isEmpty, name.lowercased() != c { return name.capitalized }
|
||||
return c.uppercased()
|
||||
}
|
||||
return c.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
tick?.cancel()
|
||||
tick = nil
|
||||
|
|
@ -216,6 +389,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
|||
}
|
||||
}
|
||||
|
||||
func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
private func startTicker() {
|
||||
tick?.cancel()
|
||||
tick = Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ struct SettingsScreen: View {
|
|||
|
||||
Section("Info") {
|
||||
LabeledContent("App", value: "VMC iOS")
|
||||
LabeledContent("Versie", value: "0.4.6")
|
||||
LabeledContent("Versie", value: "0.4.7")
|
||||
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||
}
|
||||
.listRowBackground(McColors.bgElevated)
|
||||
|
|
|
|||
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 { nodeConnectionManager } from "../websocket/manager";
|
||||
import type { Config } from "../config";
|
||||
import type { MediaTrackInfo } from "@media-cluster/protocol";
|
||||
|
||||
type SessionResult = {
|
||||
export type SessionResult = {
|
||||
sessionId: string;
|
||||
streamUrl: string;
|
||||
token: string;
|
||||
nodeId: string;
|
||||
mediaFileId: string;
|
||||
tracks?: MediaTrackInfo[];
|
||||
};
|
||||
|
||||
/** In-memory play tokens so concurrent Stremio probes reuse one session. */
|
||||
const playTokens = new Map<string, { token: string; sessionId: string }>();
|
||||
const playTokens = new Map<string, { token: string; sessionId: string; tracks?: MediaTrackInfo[] }>();
|
||||
const createLocks = new Map<string, Promise<SessionResult>>();
|
||||
|
||||
function sessionKey(addonTokenId: string | undefined, mediaFileId: string): string {
|
||||
|
|
@ -94,6 +96,7 @@ export class PlaybackService {
|
|||
token: cached.token,
|
||||
nodeId: mediaFile.nodeId,
|
||||
mediaFileId: mediaFile.id,
|
||||
tracks: cached.tracks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +143,7 @@ export class PlaybackService {
|
|||
},
|
||||
});
|
||||
|
||||
const acked = await nodeConnectionManager.createPlaybackSessionAcked(mediaFile.nodeId, {
|
||||
const ack = await nodeConnectionManager.createPlaybackSessionAcked(mediaFile.nodeId, {
|
||||
sessionId,
|
||||
tokenHash,
|
||||
localFileId: mediaFile.localFileId,
|
||||
|
|
@ -148,7 +151,7 @@ export class PlaybackService {
|
|||
absoluteExpiresAt: absoluteExpiresAt.toISOString(),
|
||||
});
|
||||
|
||||
if (!acked) {
|
||||
if (!ack?.ok) {
|
||||
await prisma.playbackSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { status: "REVOKED", revoked: true },
|
||||
|
|
@ -156,7 +159,8 @@ export class PlaybackService {
|
|||
throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503);
|
||||
}
|
||||
|
||||
playTokens.set(key, { token, sessionId });
|
||||
const tracks = Array.isArray(ack.tracks) ? ack.tracks : undefined;
|
||||
playTokens.set(key, { token, sessionId, tracks });
|
||||
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
|
||||
|
||||
return {
|
||||
|
|
@ -165,6 +169,7 @@ export class PlaybackService {
|
|||
token,
|
||||
nodeId: mediaFile.nodeId,
|
||||
mediaFileId: mediaFile.id,
|
||||
tracks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2207,6 +2207,7 @@ export class ViewerService {
|
|||
sessionId: session.sessionId,
|
||||
mediaFileId: session.mediaFileId,
|
||||
defaultSubtitle,
|
||||
embeddedTracks: session.tracks ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface NodeConnection {
|
|||
}
|
||||
|
||||
interface PendingAck {
|
||||
resolve: (ok: boolean) => void;
|
||||
resolve: (payload: CreatePlaybackSessionAckPayload) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +454,7 @@ class NodeConnectionManager {
|
|||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingSessionAcks.delete(payload.sessionId);
|
||||
pending.resolve(payload.ok);
|
||||
pending.resolve(payload);
|
||||
}
|
||||
|
||||
private handleImportMediaAck(payload: ImportMediaAckPayload): void {
|
||||
|
|
@ -686,14 +686,14 @@ class NodeConnectionManager {
|
|||
nodeId: string,
|
||||
payload: CreatePlaybackSessionPayload,
|
||||
timeoutMs = 12000
|
||||
): Promise<boolean> {
|
||||
if (!this.isOnline(nodeId)) return false;
|
||||
): Promise<CreatePlaybackSessionAckPayload | null> {
|
||||
if (!this.isOnline(nodeId)) return null;
|
||||
|
||||
const acked = new Promise<boolean>((resolve) => {
|
||||
const acked = new Promise<CreatePlaybackSessionAckPayload | null>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
// Fail closed: never hand Stremio a node URL without a confirmed session.
|
||||
this.pendingSessionAcks.delete(payload.sessionId);
|
||||
resolve(false);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
this.pendingSessionAcks.set(payload.sessionId, { resolve, timer });
|
||||
});
|
||||
|
|
@ -705,7 +705,7 @@ class NodeConnectionManager {
|
|||
clearTimeout(pending.timer);
|
||||
this.pendingSessionAcks.delete(payload.sessionId);
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return acked;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/sthmedia/media-node/internal/database"
|
||||
"github.com/sthmedia/media-node/internal/health"
|
||||
"github.com/sthmedia/media-node/internal/importmedia"
|
||||
"github.com/sthmedia/media-node/internal/mediainfo"
|
||||
"github.com/sthmedia/media-node/internal/scanner"
|
||||
"github.com/sthmedia/media-node/internal/streaming"
|
||||
)
|
||||
|
|
@ -346,11 +347,21 @@ func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
|
|||
} else {
|
||||
log.Printf("Playback session created: %s file=%s", p.SessionID, p.LocalFileID)
|
||||
}
|
||||
ack := controlMessage("CREATE_PLAYBACK_SESSION_ACK", map[string]interface{}{
|
||||
ackPayload := map[string]interface{}{
|
||||
"sessionId": p.SessionID,
|
||||
"ok": ok,
|
||||
"error": errMsg,
|
||||
})
|
||||
}
|
||||
if ok {
|
||||
if file, ferr := c.store.GetFile(p.LocalFileID); ferr == nil && file != nil && file.Path != "" {
|
||||
if tracks, perr := mediainfo.ProbeFile(file.Path); perr == nil && len(tracks) > 0 {
|
||||
ackPayload["tracks"] = tracks
|
||||
} else if perr != nil {
|
||||
log.Printf("track probe failed for %s: %v", file.Path, perr)
|
||||
}
|
||||
}
|
||||
}
|
||||
ack := controlMessage("CREATE_PLAYBACK_SESSION_ACK", ackPayload)
|
||||
if werr := c.write(ack); werr != nil {
|
||||
log.Printf("failed to send session ACK: %v", werr)
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
export interface MediaTrackInfo {
|
||||
type: string;
|
||||
number?: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
}
|
||||
|
||||
export interface CreatePlaybackSessionAckPayload {
|
||||
sessionId: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
tracks?: MediaTrackInfo[];
|
||||
}
|
||||
|
||||
export function createMessage<T>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue