diff --git a/apps/android-tv/.gitignore b/apps/android-tv/.gitignore
index d6b98db..0235913 100644
--- a/apps/android-tv/.gitignore
+++ b/apps/android-tv/.gitignore
@@ -3,10 +3,15 @@
/.kotlin/
**/build/
local.properties
+keystore.properties
+keystore/
+*.jks
+*.keystore
*.iml
.DS_Store
captures/
*.apk
*.aab
+dist/
hs_err_pid*
replay_pid*
diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts
new file mode 100644
index 0000000..6ed3cdc
--- /dev/null
+++ b/apps/android-tv/app-mobile/build.gradle.kts
@@ -0,0 +1,95 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+val keystorePropertiesFile = rootProject.file("keystore.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(keystorePropertiesFile.inputStream())
+}
+
+android {
+ namespace = "nl.vonas.mediacluster"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "nl.vonas.mediacluster"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0.0"
+ buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
+ ndk {
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a")
+ }
+ }
+
+ signingConfigs {
+ if (keystorePropertiesFile.exists()) {
+ create("release") {
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ if (keystorePropertiesFile.exists()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+ // Keep TV Material for shared UI widgets (touch clicks work); phone nav is custom
+ implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
+ implementation("androidx.tv:tv-material:1.0.0")
+
+ val media3 = "1.10.1"
+ implementation("androidx.media3:media3-exoplayer:$media3")
+ implementation("androidx.media3:media3-exoplayer-hls:$media3")
+ implementation("androidx.media3:media3-ui:$media3")
+ implementation("androidx.media3:media3-datasource-okhttp:$media3")
+ implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("io.coil-kt:coil-compose:2.7.0")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+}
diff --git a/apps/android-tv/app-mobile/proguard-rules.pro b/apps/android-tv/app-mobile/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/apps/android-tv/app-mobile/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/apps/android-tv/app-mobile/src/main/AndroidManifest.xml b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9eca756
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt
new file mode 100644
index 0000000..aa6ea44
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt
@@ -0,0 +1,22 @@
+package nl.vonas.mediacluster.tv
+
+import android.media.AudioManager
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import nl.vonas.mediacluster.tv.ui.McNav
+import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ volumeControlStream = AudioManager.STREAM_MUSIC
+ enableEdgeToEdge()
+ setContent {
+ MediaClusterTheme {
+ McNav()
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/McApp.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/McApp.kt
new file mode 100644
index 0000000..b6ce835
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/McApp.kt
@@ -0,0 +1,5 @@
+package nl.vonas.mediacluster.tv
+
+import android.app.Application
+
+class McApp : Application()
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
new file mode 100644
index 0000000..6f55b88
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt
@@ -0,0 +1,587 @@
+package nl.vonas.mediacluster.tv.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+class ApiClient(
+ private var baseUrl: String,
+ private var token: String? = null,
+) {
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(20, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private val jsonMedia = "application/json; charset=utf-8".toMediaType()
+
+ fun configure(baseUrl: String, token: String?) {
+ this.baseUrl = baseUrl.trimEnd('/')
+ this.token = token
+ }
+
+ suspend fun createDeviceCode(deviceName: String): DeviceCodeResponse = withContext(Dispatchers.IO) {
+ val body = JSONObject()
+ .put("deviceName", deviceName)
+ .put("platform", "android")
+ .toString()
+ .toRequestBody(jsonMedia)
+ val req = Request.Builder()
+ .url("$baseUrl/api/v1/client/device/code")
+ .post(body)
+ .build()
+ parseObject(execute(req)) { o ->
+ DeviceCodeResponse(
+ code = o.getString("code"),
+ expiresAt = o.optString("expiresAt"),
+ pollIntervalSeconds = o.optInt("pollIntervalSeconds", 2),
+ linkUrl = o.optString("linkUrl"),
+ qrPayload = o.optString("qrPayload"),
+ )
+ }
+ }
+
+ suspend fun pollDeviceCode(code: String): PollResult = withContext(Dispatchers.IO) {
+ val req = Request.Builder()
+ .url("$baseUrl/api/v1/client/device/poll?code=${java.net.URLEncoder.encode(code, "UTF-8")}")
+ .get()
+ .build()
+ parseObject(execute(req)) { o ->
+ when (o.optString("status")) {
+ "linked" -> PollResult.Linked(
+ refreshToken = o.getString("refreshToken"),
+ viewerName = o.optJSONObject("viewer")?.optString("name")
+ ?: o.optJSONObject("viewer")?.optString("email")
+ ?: "Kijker",
+ )
+ "expired" -> PollResult.Expired
+ "already_linked" -> PollResult.AlreadyLinked
+ else -> PollResult.Pending
+ }
+ }
+ }
+
+ suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/home").get().build()
+ parseObject(execute(req)) { o ->
+ HomePayload(
+ continueWatching = o.optJSONArray("continueWatching").toItems(),
+ spotlight = o.optJSONArray("spotlight").toItems(),
+ shelves = o.optJSONArray("shelves").toShelves(),
+ )
+ }
+ }
+
+ suspend fun search(query: String): List = withContext(Dispatchers.IO) {
+ val q = java.net.URLEncoder.encode(query, "UTF-8")
+ val req = authed("$baseUrl/api/v1/client/search?q=$q&take=60").get().build()
+ parseObject(execute(req)) { o -> o.optJSONArray("results").toItems() }
+ }
+
+ suspend fun browse(catalogId: String, skip: Int = 0, take: Int = 60): BrowsePayload =
+ withContext(Dispatchers.IO) {
+ val id = java.net.URLEncoder.encode(catalogId, "UTF-8")
+ val req = authed("$baseUrl/api/v1/client/catalog/$id?skip=$skip&take=$take").get().build()
+ parseObject(execute(req)) { o ->
+ BrowsePayload(
+ id = o.optString("id"),
+ title = o.optString("title"),
+ kind = o.optString("kind"),
+ total = o.optInt("total"),
+ items = o.optJSONArray("items").toItems(),
+ )
+ }
+ }
+
+ suspend fun movie(id: String): JSONObject = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/movies/$id").get().build()
+ JSONObject(execute(req))
+ }
+
+ suspend fun series(id: String): JSONObject = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/series/$id").get().build()
+ JSONObject(execute(req))
+ }
+
+ suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
+ val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
+ val req = authed("$baseUrl/api/v1/client/play").post(body).build()
+ parseObject(execute(req)) { o ->
+ PlayResult(
+ streamUrl = o.getString("streamUrl"),
+ sessionId = o.optString("sessionId"),
+ mediaFileId = o.optString("mediaFileId"),
+ )
+ }
+ }
+
+ suspend fun postProgress(
+ mediaType: String,
+ movieId: String? = null,
+ episodeId: String? = null,
+ seriesId: String? = null,
+ positionMs: Long,
+ durationMs: Long,
+ ) = withContext(Dispatchers.IO) {
+ val o = JSONObject()
+ .put("mediaType", mediaType)
+ .put("positionMs", positionMs)
+ .put("durationMs", durationMs)
+ if (movieId != null) o.put("movieId", movieId)
+ if (episodeId != null) o.put("episodeId", episodeId)
+ if (seriesId != null) o.put("seriesId", seriesId)
+ val req = authed("$baseUrl/api/v1/client/progress")
+ .post(o.toString().toRequestBody(jsonMedia))
+ .build()
+ execute(req)
+ Unit
+ }
+
+ suspend fun getShelfPrefs(): List = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/shelves/prefs").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("shelves") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val s = arr.getJSONObject(i)
+ add(
+ ShelfPref(
+ shelfId = s.getString("shelfId"),
+ name = s.getString("name"),
+ slug = s.optString("slug"),
+ kind = s.optString("kind"),
+ visible = s.optBoolean("visible", true),
+ sortOrder = s.optInt("sortOrder", i),
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun saveShelfPrefs(prefs: List) = withContext(Dispatchers.IO) {
+ val arr = JSONArray()
+ prefs.forEachIndexed { index, p ->
+ arr.put(
+ JSONObject()
+ .put("shelfId", p.shelfId)
+ .put("visible", p.visible)
+ .put("sortOrder", p.sortOrder.takeIf { it >= 0 } ?: index)
+ )
+ }
+ val body = JSONObject().put("shelves", arr).toString().toRequestBody(jsonMedia)
+ val req = authed("$baseUrl/api/v1/client/shelves/prefs").put(body).build()
+ execute(req)
+ Unit
+ }
+
+ suspend fun markWatched(movieId: String? = null, episodeId: String? = null) =
+ withContext(Dispatchers.IO) {
+ val o = JSONObject()
+ if (movieId != null) o.put("movieId", movieId)
+ if (episodeId != null) o.put("episodeId", episodeId)
+ val req = authed("$baseUrl/api/v1/client/progress/mark-watched")
+ .post(o.toString().toRequestBody(jsonMedia))
+ .build()
+ execute(req)
+ Unit
+ }
+
+ suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
+ withContext(Dispatchers.IO) {
+ val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
+ val langs = java.net.URLEncoder.encode(languages, "UTF-8")
+ val req = authed("$baseUrl/api/v1/client/subtitles/search?mediaFileId=$id&languages=$langs")
+ .get()
+ .build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("results") ?: JSONArray()
+ val results = buildList {
+ for (i in 0 until arr.length()) {
+ val r = arr.getJSONObject(i)
+ add(
+ SubtitleHit(
+ fileId = r.optInt("fileId"),
+ language = r.optString("language"),
+ release = r.optString("release").ifBlank { r.optString("fileName") },
+ fileName = r.optString("fileName"),
+ downloadCount = r.optInt("downloadCount"),
+ hearingImpaired = r.optBoolean("hearingImpaired"),
+ source = r.optString("source").ifBlank { "org" },
+ uploader = r.optString("uploader").takeIf { it.isNotBlank() && it != "null" },
+ )
+ )
+ }
+ }
+ SubtitleSearchResult(
+ configured = o.optBoolean("configured", true),
+ imdbId = o.optString("imdbId").takeIf { it.isNotBlank() && it != "null" },
+ title = o.optString("title").takeIf { it.isNotBlank() && it != "null" },
+ results = results,
+ )
+ }
+ }
+
+ suspend fun fetchSubtitle(fileId: Int, source: String): FetchedSubtitle =
+ withContext(Dispatchers.IO) {
+ val body = JSONObject()
+ .put("fileId", fileId)
+ .put("source", source)
+ .toString()
+ .toRequestBody(jsonMedia)
+ val req = authed("$baseUrl/api/v1/client/subtitles/fetch").post(body).build()
+ parseObject(execute(req)) { o ->
+ FetchedSubtitle(
+ fileId = o.optInt("fileId", fileId),
+ source = o.optString("source").ifBlank { source },
+ fileName = o.optString("fileName").ifBlank { "subtitle.srt" },
+ format = o.optString("format").ifBlank { "srt" },
+ contentBase64 = o.getString("contentBase64"),
+ )
+ }
+ }
+
+ suspend fun dismissContinue(progressId: String) = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss")
+ .post("{}".toRequestBody(jsonMedia))
+ .build()
+ execute(req)
+ Unit
+ }
+
+ suspend fun clientMe(): ClientMe = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/me").get().build()
+ parseObject(execute(req)) { o ->
+ ClientMe(
+ iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
+ )
+ }
+ }
+
+ suspend fun iptvCategories(): List = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/categories").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("categories") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val c = arr.getJSONObject(i)
+ add(
+ IptvCategory(
+ id = c.getString("id"),
+ name = c.getString("name"),
+ channelCount = c.optInt("channelCount"),
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List = withContext(Dispatchers.IO) {
+ val params = buildList {
+ categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") }
+ if (includeEpg) add("epg=1")
+ }
+ val q = if (params.isEmpty()) "" else "?${params.joinToString("&")}"
+ val req = authed("$baseUrl/api/v1/client/iptv/channels$q").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("channels") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val c = arr.getJSONObject(i)
+ add(
+ IptvChannel(
+ streamId = c.getInt("streamId"),
+ name = c.getString("name"),
+ logoUrl = c.optString("logoUrl").ifBlank { null },
+ number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
+ categoryId = c.optString("categoryId").ifBlank { null },
+ nowTitle = c.optString("nowTitle").ifBlank { null },
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun iptvPlayUrl(streamId: Int): IptvPlayInfo = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build()
+ parseObject(execute(req)) { o ->
+ IptvPlayInfo(
+ streamId = o.getInt("streamId"),
+ name = o.getString("name"),
+ logoUrl = o.optString("logoUrl").ifBlank { null },
+ streamUrl = o.getString("streamUrl"),
+ format = o.optString("format").ifBlank { "hls" },
+ fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
+ fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
+ )
+ }
+ }
+
+ suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
+ parseObject(execute(req)) { o ->
+ IptvEpg(
+ streamId = o.getInt("streamId"),
+ channelName = o.getString("channelName"),
+ logoUrl = o.optString("logoUrl").ifBlank { null },
+ now = o.optJSONObject("now")?.toEpgProgram(),
+ next = o.optJSONObject("next")?.toEpgProgram(),
+ )
+ }
+ }
+
+ /** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
+ suspend fun fetchMissingNowTitles(channels: List): Map {
+ val result = linkedMapOf()
+ channels.forEach { ch ->
+ ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
+ }
+ val missing = channels.filter { !result.containsKey(it.streamId) }
+ if (missing.isEmpty()) return result
+
+ coroutineScope {
+ missing.chunked(10).forEach { batch ->
+ batch.map { ch ->
+ async(Dispatchers.IO) {
+ runCatching {
+ iptvEpg(ch.streamId).now?.title?.takeIf { it.isNotBlank() }
+ }.getOrNull()?.let { ch.streamId to it }
+ }
+ }.awaitAll().forEach { pair ->
+ pair?.let { (id, title) -> result[id] = title }
+ }
+ }
+ }
+ return result
+ }
+
+ private fun JSONObject.toEpgProgram(): EpgProgram? {
+ val title = optString("title").ifBlank { return null }
+ return EpgProgram(
+ title = title,
+ start = optString("start").ifBlank { null },
+ end = optString("end").ifBlank { null },
+ )
+ }
+
+ private fun authed(url: String): Request.Builder {
+ val b = Request.Builder().url(url)
+ val t = token
+ if (!t.isNullOrBlank()) b.header("Authorization", "Bearer $t")
+ return b
+ }
+
+ private fun execute(req: Request): String {
+ http.newCall(req).execute().use { res ->
+ val text = res.body?.string().orEmpty()
+ if (!res.isSuccessful) {
+ val msg = runCatching {
+ JSONObject(text).optJSONObject("error")?.optString("message")
+ }.getOrNull() ?: "HTTP ${res.code}"
+ throw ApiException(msg)
+ }
+ return text
+ }
+ }
+
+ private fun parseObject(text: String, block: (JSONObject) -> T): T = block(JSONObject(text))
+}
+
+class ApiException(message: String) : Exception(message)
+
+data class DeviceCodeResponse(
+ val code: String,
+ val expiresAt: String,
+ val pollIntervalSeconds: Int,
+ val linkUrl: String,
+ val qrPayload: String,
+)
+
+sealed class PollResult {
+ data object Pending : PollResult()
+ data object Expired : PollResult()
+ data object AlreadyLinked : PollResult()
+ data class Linked(val refreshToken: String, val viewerName: String) : PollResult()
+}
+
+data class BrowsePayload(
+ val id: String,
+ val title: String,
+ val kind: String,
+ val total: Int,
+ val items: List,
+)
+
+data class HomePayload(
+ val continueWatching: List,
+ val spotlight: List = emptyList(),
+ val shelves: List,
+)
+
+data class ShelfRow(
+ val id: String,
+ val title: String,
+ val name: String = title,
+ val kind: String,
+ val slug: String = "",
+ val items: List,
+) {
+ /** Canonical shelf name for browse / sub-nav (not the homepage label). */
+ val browseTitle: String get() = name.ifBlank { title }
+}
+
+data class MediaItem(
+ val type: String,
+ val id: String,
+ val title: String,
+ val subtitle: String? = null,
+ val year: Int? = null,
+ val posterUrl: String? = null,
+ val backdropUrl: String? = null,
+ val mediaFileId: String? = null,
+ val seriesId: String? = null,
+ val positionMs: Long = 0,
+ val durationMs: Long = 0,
+ val progressId: String? = null,
+ val badge: String? = null,
+)
+
+data class PlayResult(
+ val streamUrl: String,
+ val sessionId: String,
+ val mediaFileId: String,
+)
+
+data class SubtitleHit(
+ val fileId: Int,
+ val language: String,
+ val release: String,
+ val fileName: String,
+ val downloadCount: Int,
+ val hearingImpaired: Boolean,
+ val source: String,
+ val uploader: String? = null,
+)
+
+data class SubtitleSearchResult(
+ val configured: Boolean,
+ val imdbId: String?,
+ val title: String?,
+ val results: List,
+)
+
+data class FetchedSubtitle(
+ val fileId: Int,
+ val source: String,
+ val fileName: String,
+ val format: String,
+ val contentBase64: String,
+)
+
+data class ShelfPref(
+ val shelfId: String,
+ val name: String,
+ val slug: String = "",
+ val kind: String = "",
+ val visible: Boolean,
+ val sortOrder: Int,
+)
+
+data class ClientMe(
+ val iptvEnabled: Boolean,
+)
+
+data class IptvCategory(
+ val id: String,
+ val name: String,
+ val channelCount: Int,
+)
+
+data class IptvChannel(
+ val streamId: Int,
+ val name: String,
+ val logoUrl: String? = null,
+ val number: Int? = null,
+ val categoryId: String? = null,
+ val nowTitle: String? = null,
+)
+
+data class IptvPlayInfo(
+ val streamId: Int,
+ val name: String,
+ val logoUrl: String? = null,
+ val streamUrl: String,
+ val format: String,
+ val fallbackStreamUrl: String? = null,
+ val fallbackFormat: String? = null,
+)
+
+data class EpgProgram(
+ val title: String,
+ val start: String? = null,
+ val end: String? = null,
+)
+
+data class IptvEpg(
+ val streamId: Int,
+ val channelName: String,
+ val logoUrl: String? = null,
+ val now: EpgProgram? = null,
+ val next: EpgProgram? = null,
+)
+
+private fun JSONArray?.toItems(): List {
+ if (this == null) return emptyList()
+ return buildList {
+ for (i in 0 until length()) {
+ val o = getJSONObject(i)
+ add(
+ MediaItem(
+ type = o.optString("type"),
+ id = o.optString("id"),
+ title = o.optString("title"),
+ subtitle = o.optString("subtitle").ifBlank { null },
+ year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
+ posterUrl = o.optString("posterUrl").ifBlank { null },
+ backdropUrl = o.optString("backdropUrl").ifBlank { null },
+ mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null },
+ seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null },
+ positionMs = o.optLong("positionMs"),
+ durationMs = o.optLong("durationMs"),
+ progressId = o.optString("progressId").takeIf { it.isNotBlank() && it != "null" },
+ badge = o.optString("badge").takeIf { it.isNotBlank() && it != "null" },
+ )
+ )
+ }
+ }
+}
+
+private fun JSONArray?.toShelves(): List {
+ if (this == null) return emptyList()
+ return buildList {
+ for (i in 0 until length()) {
+ val o = getJSONObject(i)
+ add(
+ ShelfRow(
+ id = o.optString("id"),
+ title = o.optString("title"),
+ name = o.optString("name").ifBlank { o.optString("title") },
+ kind = o.optString("kind"),
+ slug = o.optString("slug").ifBlank { o.optString("id") },
+ items = o.optJSONArray("items").toItems(),
+ )
+ )
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt
new file mode 100644
index 0000000..1664467
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt
@@ -0,0 +1,31 @@
+package nl.vonas.mediacluster.tv.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import nl.vonas.mediacluster.BuildConfig
+
+private val Context.dataStore by preferencesDataStore("mc_mobile")
+
+class SessionStore(private val context: Context) {
+ private val tokenKey = stringPreferencesKey("refresh_token")
+ private val apiKey = stringPreferencesKey("api_base")
+
+ val refreshToken: Flow = context.dataStore.data.map { it[tokenKey] }
+ val apiBase: Flow = context.dataStore.data.map {
+ it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
+ }
+
+ suspend fun setToken(token: String?) {
+ context.dataStore.edit { prefs ->
+ if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token
+ }
+ }
+
+ suspend fun setApiBase(url: String) {
+ context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
new file mode 100644
index 0000000..554d5d3
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt
@@ -0,0 +1,405 @@
+package nl.vonas.mediacluster.tv.playback
+
+import android.content.Context
+import android.os.Handler
+import androidx.annotation.OptIn
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.TrackSelectionOverride
+import androidx.media3.common.Tracks
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.DefaultLoadControl
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.Renderer
+import androidx.media3.exoplayer.audio.AudioRendererEventListener
+import androidx.media3.exoplayer.audio.AudioSink
+import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
+import androidx.media3.exoplayer.video.VideoRendererEventListener
+import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
+import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
+import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
+import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
+import okhttp3.OkHttpClient
+import java.util.concurrent.TimeUnit
+
+/**
+ * Hardware-video + FFmpeg-audio (prefer).
+ * DTS/TrueHD/E-AC3 falen vaak stil bij HDMI-passthrough naar een TV zonder decoder;
+ * FFmpeg decodeert naar PCM zodat geluid altijd via de TV/speakers werkt.
+ */
+@OptIn(UnstableApi::class)
+private class McRenderersFactory(context: Context) : NextRenderersFactory(context) {
+ init {
+ setEnableDecoderFallback(true)
+ setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
+ }
+
+ override fun buildAudioRenderers(
+ context: Context,
+ extensionRendererMode: Int,
+ mediaCodecSelector: MediaCodecSelector,
+ enableDecoderFallback: Boolean,
+ audioSink: AudioSink,
+ eventHandler: Handler,
+ eventListener: AudioRendererEventListener,
+ out: ArrayList,
+ ) {
+ super.buildAudioRenderers(
+ context,
+ EXTENSION_RENDERER_MODE_PREFER,
+ mediaCodecSelector,
+ enableDecoderFallback,
+ audioSink,
+ eventHandler,
+ eventListener,
+ out,
+ )
+ }
+
+ override fun buildVideoRenderers(
+ context: Context,
+ extensionRendererMode: Int,
+ mediaCodecSelector: MediaCodecSelector,
+ enableDecoderFallback: Boolean,
+ eventHandler: Handler,
+ eventListener: VideoRendererEventListener,
+ allowedVideoJoiningTimeMs: Long,
+ out: ArrayList,
+ ) {
+ // Shield HEVC/AV1: altijd MediaCodec, nooit software-video
+ super.buildVideoRenderers(
+ context,
+ EXTENSION_RENDERER_MODE_OFF,
+ mediaCodecSelector,
+ enableDecoderFallback,
+ eventHandler,
+ eventListener,
+ allowedVideoJoiningTimeMs,
+ out,
+ )
+ }
+}
+
+/**
+ * Live IPTV: hardware-video, audio hardware-first met FFmpeg-fallback (MP2/AC3/E-AC3).
+ * PREFER vermijden — scheelt CPU; ON pakt FFmpeg alleen als MediaCodec faalt.
+ */
+@OptIn(UnstableApi::class)
+private class LiveRenderersFactory(context: Context) : NextRenderersFactory(context) {
+ init {
+ setEnableDecoderFallback(true)
+ setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
+ }
+
+ override fun buildAudioRenderers(
+ context: Context,
+ extensionRendererMode: Int,
+ mediaCodecSelector: MediaCodecSelector,
+ enableDecoderFallback: Boolean,
+ audioSink: AudioSink,
+ eventHandler: Handler,
+ eventListener: AudioRendererEventListener,
+ out: ArrayList,
+ ) {
+ super.buildAudioRenderers(
+ context,
+ EXTENSION_RENDERER_MODE_ON,
+ mediaCodecSelector,
+ enableDecoderFallback,
+ audioSink,
+ eventHandler,
+ eventListener,
+ out,
+ )
+ }
+
+ override fun buildVideoRenderers(
+ context: Context,
+ extensionRendererMode: Int,
+ mediaCodecSelector: MediaCodecSelector,
+ enableDecoderFallback: Boolean,
+ eventHandler: Handler,
+ eventListener: VideoRendererEventListener,
+ allowedVideoJoiningTimeMs: Long,
+ out: ArrayList,
+ ) {
+ super.buildVideoRenderers(
+ context,
+ EXTENSION_RENDERER_MODE_OFF,
+ mediaCodecSelector,
+ enableDecoderFallback,
+ eventHandler,
+ eventListener,
+ allowedVideoJoiningTimeMs,
+ out,
+ )
+ }
+}
+
+@OptIn(UnstableApi::class)
+fun buildTvPlayer(context: Context): ExoPlayer {
+ val trackSelector = DefaultTrackSelector(context).apply {
+ parameters = buildUponParameters()
+ .setExceedRendererCapabilitiesIfNecessary(true)
+ .setAllowAudioMixedMimeTypeAdaptiveness(true)
+ .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
+ .setPreferredAudioLanguage("nl")
+ .setPreferredTextLanguage("nl")
+ .build()
+ }
+
+ val renderersFactory = McRenderersFactory(context.applicationContext)
+
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .build()
+
+ return ExoPlayer.Builder(context.applicationContext, renderersFactory)
+ .setTrackSelector(trackSelector)
+ .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
+ .setHandleAudioBecomingNoisy(true)
+ .setWakeMode(C.WAKE_MODE_NETWORK)
+ .setSeekBackIncrementMs(10_000)
+ .setSeekForwardIncrementMs(30_000)
+ .build()
+ .also { it.volume = 1f }
+}
+
+/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
+private val liveOkHttp: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+}
+
+/** Live IPTV: TS-first, kleine buffer, lage latency. */
+@OptIn(UnstableApi::class)
+fun buildLiveTvPlayer(context: Context): ExoPlayer {
+ val appContext = context.applicationContext
+ val dataSourceFactory = OkHttpDataSource.Factory(liveOkHttp)
+ .setUserAgent("VMC-AndroidTV")
+
+ val trackSelector = DefaultTrackSelector(appContext).apply {
+ parameters = buildUponParameters()
+ .setExceedRendererCapabilitiesIfNecessary(true)
+ .setAllowAudioMixedMimeTypeAdaptiveness(true)
+ .build()
+ }
+
+ val loadControl = DefaultLoadControl.Builder()
+ .setBufferDurationsMs(
+ /* minBufferMs = */ 8_000,
+ /* maxBufferMs = */ 20_000,
+ /* bufferForPlaybackMs = */ 1_800,
+ /* bufferForPlaybackAfterRebufferMs = */ 2_500,
+ )
+ .setPrioritizeTimeOverSizeThresholds(true)
+ .build()
+
+ val renderersFactory = LiveRenderersFactory(appContext)
+
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .build()
+
+ return ExoPlayer.Builder(appContext, renderersFactory)
+ .setTrackSelector(trackSelector)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+ .setLoadControl(loadControl)
+ .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
+ .setHandleAudioBecomingNoisy(true)
+ .setWakeMode(C.WAKE_MODE_NETWORK)
+ .build()
+ .also { it.volume = 1f }
+}
+
+/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */
+fun liveMediaItem(url: String, format: String): MediaItem {
+ val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
+ val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T
+ val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
+ if (isHls) {
+ builder.setLiveConfiguration(
+ MediaItem.LiveConfiguration.Builder()
+ .setTargetOffsetMs(2_000)
+ .setMinOffsetMs(1_000)
+ .setMaxOffsetMs(5_000)
+ .build(),
+ )
+ }
+ return builder.build()
+}
+
+data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
+
+fun ExoPlayer.listTracks(type: Int): List {
+ val out = mutableListOf()
+ val groups = currentTracks.groups
+ 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 label = if (type == C.TRACK_TYPE_TEXT) {
+ formatTextTrackLabel(format, ti)
+ } else {
+ formatAudioTrackLabel(format, ti)
+ }
+ out += PlayerTrack(gi, ti, label, type)
+ }
+ }
+ return out
+}
+
+private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
+ val mime = format.sampleMimeType ?: ""
+ val lang = prettyLanguage(format.language)
+ val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
+ return listOfNotNull(
+ format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
+ lang,
+ shortMime(mime).takeIf { it.isNotBlank() },
+ ch,
+ ).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
+}
+
+private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
+ val lang = prettyLanguage(format.language)
+ val kind = when {
+ format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
+ format.sampleMimeType?.contains("ssa", true) == true ||
+ format.sampleMimeType?.contains("ass", true) == true -> "ASS"
+ format.sampleMimeType?.contains("subrip", true) == true ||
+ format.sampleMimeType?.contains("srt", true) == true -> "SRT"
+ 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)
+ .distinct()
+ .joinToString(" · ")
+ .ifBlank { "Ondertitel ${index + 1}" }
+}
+
+private fun prettyLanguage(code: String?): String? {
+ val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: 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()
+ }
+}
+
+/** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */
+fun ExoPlayer.ensureAudioSelected() {
+ if (currentTracks.hasAudioSelected()) {
+ volume = 1f
+ return
+ }
+ val tracks = listTracks(C.TRACK_TYPE_AUDIO)
+ if (tracks.isEmpty()) return
+ val preferred = tracks.firstOrNull { t ->
+ val l = t.label.lowercase()
+ !l.contains("comment") && !l.contains("description") && !l.contains("ad ")
+ } ?: tracks.first()
+ selectTrack(preferred)
+}
+
+fun ExoPlayer.selectTrack(track: PlayerTrack) {
+ val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
+ trackSelectionParameters = trackSelectionParameters
+ .buildUpon()
+ .clearOverridesOfType(track.type)
+ .setOverrideForType(
+ TrackSelectionOverride(group.mediaTrackGroup, listOf(track.trackIndex)),
+ )
+ .setTrackTypeDisabled(track.type, false)
+ .build()
+ volume = 1f
+}
+
+fun ExoPlayer.disableTrackType(type: Int) {
+ trackSelectionParameters = trackSelectionParameters
+ .buildUpon()
+ .clearOverridesOfType(type)
+ .setTrackTypeDisabled(type, true)
+ .build()
+}
+
+fun ExoPlayer.applySideloadedSubtitle(
+ uri: android.net.Uri,
+ mimeType: String,
+ language: String,
+ label: String,
+) {
+ val current = currentMediaItem ?: return
+ val pos = currentPosition.coerceAtLeast(0)
+ val wasPlaying = playWhenReady
+ val sub = MediaItem.SubtitleConfiguration.Builder(uri)
+ .setMimeType(mimeType)
+ .setLanguage(language)
+ .setLabel(label)
+ .setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
+ .build()
+ val next = current.buildUpon()
+ .setSubtitleConfigurations(listOf(sub))
+ .build()
+ setMediaItem(next, /* startPositionMs = */ pos)
+ trackSelectionParameters = trackSelectionParameters
+ .buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
+ .setPreferredTextLanguage(language)
+ .build()
+ prepare()
+ playWhenReady = wasPlaying
+}
+
+private fun shortMime(mime: String): String = when {
+ MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
+ MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
+ MimeTypes.AUDIO_DTS_HD == mime || mime.contains("dts.hd") || mime.contains("dts-hd") -> "DTS-HD"
+ MimeTypes.AUDIO_DTS == mime || mime.contains("dts") -> "DTS"
+ mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
+ MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
+ mime.contains("vorbis") -> "Vorbis"
+ mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase()
+ else -> ""
+}
+
+fun Tracks.hasAudioSelected(): Boolean {
+ for (i in 0 until groups.size) {
+ val g = groups[i]
+ if (g.type == C.TRACK_TYPE_AUDIO && g.isSelected) return true
+ }
+ return false
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt
new file mode 100644
index 0000000..512c23e
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt
@@ -0,0 +1,193 @@
+package nl.vonas.mediacluster.tv.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+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.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.MediaItem
+import nl.vonas.mediacluster.tv.ui.components.McButton
+import nl.vonas.mediacluster.tv.ui.components.PosterCard
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun BrowseScreen(
+ api: ApiClient,
+ catalogId: String,
+ titleHint: String? = null,
+ onBack: (() -> Unit)? = null,
+ onAtTopBack: (() -> Unit)? = null,
+ onOpenMovie: (String) -> Unit,
+ onOpenSeries: (String) -> Unit,
+ onPlayContinue: ((
+ mediaFileId: String,
+ movieId: String?,
+ episodeId: String?,
+ seriesId: String?,
+ type: String,
+ startMs: Long,
+ ) -> Unit)? = null,
+) {
+ var title by remember { mutableStateOf(titleHint ?: "Bibliotheek") }
+ var items by remember { mutableStateOf>(emptyList()) }
+ var total by remember { mutableStateOf(0) }
+ var loading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var loadingMore by remember { mutableStateOf(false) }
+ val gridState = rememberLazyGridState()
+ val scope = rememberCoroutineScope()
+
+ suspend fun load(reset: Boolean) {
+ if (reset) {
+ loading = true
+ error = null
+ } else {
+ loadingMore = true
+ }
+ try {
+ val skip = if (reset) 0 else items.size
+ val page = api.browse(catalogId, skip = skip, take = 60)
+ title = page.title.ifBlank { titleHint ?: catalogId }
+ total = page.total
+ items = if (reset) {
+ page.items
+ } else {
+ val existing = items.map { "${it.type}:${it.id}" }.toSet()
+ items + page.items.filter { "${it.type}:${it.id}" !in existing }
+ }
+ } catch (e: Exception) {
+ error = e.message
+ } finally {
+ loading = false
+ loadingMore = false
+ }
+ }
+
+ LaunchedEffect(catalogId) { load(reset = true) }
+
+ LaunchedEffect(gridState, items.size, total) {
+ snapshotFlow {
+ val info = gridState.layoutInfo
+ val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
+ last >= items.lastIndex - 6 && items.isNotEmpty()
+ }
+ .distinctUntilChanged()
+ .collect { nearEnd ->
+ if (nearEnd && !loadingMore && !loading && items.size < total) {
+ scope.launch { load(reset = false) }
+ }
+ }
+ }
+
+ val handleBack = onBack != null || onAtTopBack != null
+ if (handleBack) {
+ BackHandler {
+ val scrolled = gridState.firstVisibleItemIndex > 0 ||
+ gridState.firstVisibleItemScrollOffset > 0
+ if (scrolled) {
+ scope.launch { gridState.animateScrollToItem(0) }
+ } else if (onBack != null) {
+ onBack()
+ } else {
+ onAtTopBack?.invoke()
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(McColors.Bg)
+ .padding(horizontal = 36.dp, vertical = 20.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
+ Text(
+ if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ )
+ }
+ if (onBack != null) {
+ McButton("Terug", onClick = onBack)
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+ if (error != null) {
+ Text(error!!, color = McColors.Danger)
+ }
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 140.dp),
+ state = gridState,
+ contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
+ PosterCard(
+ item = item,
+ width = 140.dp,
+ height = 210.dp,
+ modifier = Modifier.padding(6.dp),
+ onClick = {
+ val canResume = onPlayContinue != null &&
+ !item.mediaFileId.isNullOrBlank() &&
+ (item.positionMs > 0 || catalogId == "continue-watching")
+ if (canResume) {
+ onPlayContinue!!(
+ item.mediaFileId!!,
+ item.id.takeIf { item.type == "movie" },
+ item.id.takeIf { item.type == "episode" },
+ item.seriesId,
+ if (item.type == "episode") "EPISODE" else "MOVIE",
+ item.positionMs,
+ )
+ } else if (item.type == "movie") {
+ onOpenMovie(item.id)
+ } else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) {
+ onOpenSeries(item.seriesId!!)
+ } else {
+ onOpenSeries(item.id)
+ }
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt
new file mode 100644
index 0000000..ca315bd
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt
@@ -0,0 +1,733 @@
+package nl.vonas.mediacluster.tv.ui
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.background
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Border
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Glow
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.ui.components.MediaVersion
+import nl.vonas.mediacluster.tv.ui.components.SelectedQualityLine
+import nl.vonas.mediacluster.tv.ui.components.VersionQualityMark
+import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+import org.json.JSONArray
+import org.json.JSONObject
+
+private fun JSONObject.optCleanString(key: String): String? {
+ if (!has(key) || isNull(key)) return null
+ return optString(key).trim().takeIf { it.isNotEmpty() && it != "null" }
+}
+
+private fun JSONArray?.stringList(): List {
+ if (this == null) return emptyList()
+ return buildList {
+ for (i in 0 until length()) {
+ val v = optString(i).trim()
+ if (v.isNotEmpty()) add(v)
+ }
+ }
+}
+
+private fun formatRuntime(minutes: Int?): String? {
+ if (minutes == null || minutes <= 0) return null
+ val h = minutes / 60
+ val m = minutes % 60
+ return when {
+ h > 0 && m > 0 -> "${h}u ${m}m"
+ h > 0 -> "${h}u"
+ else -> "${m}m"
+ }
+}
+
+private fun openYoutubeTrailer(context: android.content.Context, key: String) {
+ val appPackages = listOf(
+ "com.google.android.youtube.tv",
+ "com.google.android.youtube.googletv",
+ "com.google.android.youtube",
+ )
+ val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$key")).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ for (pkg in appPackages) {
+ try {
+ appIntent.setPackage(pkg)
+ context.startActivity(appIntent)
+ return
+ } catch (_: ActivityNotFoundException) {
+ // try next
+ } catch (_: Exception) {
+ // try next
+ }
+ }
+ try {
+ context.startActivity(
+ Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://www.youtube.com/watch?v=$key"),
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ } catch (_: Exception) {
+ // ignore
+ }
+}
+
+@Composable
+private fun DetailBackdrop(url: String?) {
+ Box(Modifier.fillMaxSize().background(McColors.Bg)) {
+ if (!url.isNullOrBlank()) {
+ AsyncImage(
+ model = url,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ Box(
+ Modifier.fillMaxSize().background(
+ Brush.horizontalGradient(
+ 0f to Color(0xF20B0B0B),
+ 0.42f to Color(0xCC0B0B0B),
+ 0.72f to Color(0x660B0B0B),
+ 1f to Color(0x220B0B0B),
+ ),
+ ),
+ )
+ Box(
+ Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ 0f to Color(0x660B0B0B),
+ 0.35f to Color.Transparent,
+ 0.7f to Color(0xAA0B0B0B),
+ 1f to Color(0xFF0B0B0B),
+ ),
+ ),
+ )
+ }
+}
+
+@Composable
+private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
+ if (!logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = logoUrl,
+ contentDescription = title,
+ modifier = Modifier
+ .widthIn(max = 420.dp)
+ .heightIn(max = 120.dp)
+ .fillMaxWidth(0.85f),
+ contentScale = ContentScale.Fit,
+ alignment = Alignment.CenterStart,
+ )
+ } else {
+ Text(
+ title,
+ color = Color.White,
+ fontSize = 40.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 46.sp,
+ )
+ }
+}
+
+@Composable
+private fun MetaChips(parts: List) {
+ if (parts.isEmpty()) return
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 2.dp),
+ ) {
+ parts.forEachIndexed { index, part ->
+ if (index > 0) {
+ Text("·", color = Color(0xFF666666), fontSize = 14.sp)
+ }
+ Text(
+ part,
+ color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
+ fontSize = 14.sp,
+ fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun DetailBackButton(onClick: () -> Unit) {
+ Surface(
+ onClick = onClick,
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
+ glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color.Transparent,
+ focusedContainerColor = Color(0x33FFFFFF),
+ contentColor = Color(0xFFCFCFCF),
+ focusedContentColor = Color.White,
+ ),
+ border = ClickableSurfaceDefaults.border(
+ focusedBorder = Border(
+ border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
+ shape = RoundedCornerShape(4.dp),
+ ),
+ ),
+ ) {
+ Text(
+ "Terug",
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ color = Color.Unspecified,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun DetailActionButton(
+ label: String,
+ primary: Boolean = false,
+ focusRequester: FocusRequester? = null,
+ onClick: () -> Unit,
+) {
+ val bg = if (primary) Color.White else Color(0xE6181818)
+ val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White
+ val fg = if (primary) Color.Black else Color.White
+ Surface(
+ onClick = onClick,
+ modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
+ glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = bg,
+ focusedContainerColor = bgFocus,
+ contentColor = fg,
+ focusedContentColor = Color.Black,
+ ),
+ border = if (primary) {
+ ClickableSurfaceDefaults.border()
+ } else {
+ ClickableSurfaceDefaults.border(
+ border = Border(
+ border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
+ shape = RoundedCornerShape(4.dp),
+ ),
+ focusedBorder = Border(
+ border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
+ shape = RoundedCornerShape(4.dp),
+ ),
+ )
+ },
+ ) {
+ Text(
+ text = label,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ color = Color.Unspecified,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun MovieDetailScreen(
+ api: ApiClient,
+ movieId: String,
+ onBack: () -> Unit,
+ onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
+) {
+ val context = LocalContext.current
+ var title by remember { mutableStateOf("…") }
+ var overview by remember { mutableStateOf("") }
+ var backdrop by remember { mutableStateOf(null) }
+ var poster by remember { mutableStateOf(null) }
+ var logoUrl by remember { mutableStateOf(null) }
+ var year by remember { mutableStateOf(null) }
+ var runtime by remember { mutableStateOf(null) }
+ var rating by remember { mutableStateOf(null) }
+ var genres by remember { mutableStateOf>(emptyList()) }
+ var cast by remember { mutableStateOf>(emptyList()) }
+ var director by remember { mutableStateOf>(emptyList()) }
+ var trailerKey by remember { mutableStateOf(null) }
+ var versions by remember { mutableStateOf>(emptyList()) }
+ var selectedVersionId by remember { mutableStateOf(null) }
+ var error by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+ val playFocus = remember { FocusRequester() }
+ val scroll = rememberScrollState()
+
+ LaunchedEffect(movieId) {
+ try {
+ val o = api.movie(movieId)
+ title = o.optString("title")
+ overview = o.optString("overview")
+ backdrop = o.optCleanString("backdropUrl") ?: o.optCleanString("posterUrl")
+ poster = o.optCleanString("posterUrl")
+ logoUrl = o.optCleanString("logoUrl")
+ year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }
+ runtime = o.optInt("runtime").takeIf { o.has("runtime") && !o.isNull("runtime") && o.optInt("runtime") > 0 }
+ rating = o.optCleanString("rating")
+ genres = o.optJSONArray("genres").stringList()
+ cast = o.optJSONArray("cast").stringList()
+ director = o.optJSONArray("director").stringList()
+ trailerKey = o.optCleanString("trailerYoutubeKey")
+ val arr = o.optJSONArray("mediaFiles")
+ val list = mutableListOf()
+ if (arr != null) {
+ for (i in 0 until arr.length()) {
+ val f = arr.getJSONObject(i)
+ list += parseMediaVersion(
+ id = f.getString("id"),
+ resolution = f.optCleanString("resolution"),
+ hdrType = f.optCleanString("hdrType"),
+ audioCodec = f.optCleanString("audioCodec"),
+ videoCodec = f.optCleanString("videoCodec"),
+ releaseName = f.optCleanString("releaseName"),
+ label = f.optCleanString("label"),
+ )
+ }
+ }
+ versions = list.sortedByDescending { it.rank }
+ selectedVersionId = versions.firstOrNull()?.id
+ } catch (e: Exception) {
+ error = e.message
+ }
+ }
+
+ LaunchedEffect(versions) {
+ if (versions.isNotEmpty()) {
+ delay(80)
+ runCatching { playFocus.requestFocus() }
+ }
+ }
+
+ val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull()
+ val metaParts = buildList {
+ year?.let { add(it.toString()) }
+ formatRuntime(runtime)?.let(::add)
+ genres.take(2).forEach(::add)
+ rating?.let { add("★ $it") }
+ }
+ val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
+
+ BackHandler(onBack = onBack)
+ Box(Modifier.fillMaxSize()) {
+ DetailBackdrop(backdrop)
+ Column(
+ Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(0.58f)
+ .padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
+ .verticalScroll(scroll),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ DetailBackButton(onClick = onBack)
+ Spacer(Modifier.height(4.dp))
+ DetailLogoOrTitle(logoUrl, title)
+ MetaChips(metaParts)
+ SelectedQualityLine(selected)
+ if (director.isNotEmpty()) {
+ Text(
+ "Regie: ${director.take(2).joinToString(", ")}",
+ color = Color(0xFF9A9A9A),
+ fontSize = 13.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (overview.isNotBlank()) {
+ Text(
+ overview,
+ color = McColors.TextDim,
+ fontSize = 15.sp,
+ maxLines = 4,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 21.sp,
+ )
+ }
+ if (cast.isNotEmpty()) {
+ Text(
+ "Met: ${cast.take(4).joinToString(", ")}",
+ color = Color(0xFF8A8A8A),
+ fontSize = 13.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (error != null) Text(error!!, color = McColors.Danger)
+ Spacer(Modifier.height(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ selected?.let { ver ->
+ DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
+ onPlay(ver.id, title, 0)
+ }
+ }
+ if (!trailerKey.isNullOrBlank()) {
+ DetailActionButton("Trailer") {
+ openYoutubeTrailer(context, trailerKey!!)
+ }
+ }
+ DetailActionButton("Als gekeken") {
+ scope.launch {
+ runCatching { api.markWatched(movieId = movieId) }
+ onBack()
+ }
+ }
+ }
+ if (versions.size > 1) {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ "Kwaliteit",
+ color = Color(0xFF8F8F8F),
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.6.sp,
+ )
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
+ ) {
+ items(versions, key = { it.id }) { ver ->
+ VersionPickChip(
+ version = ver,
+ selected = ver.id == selected?.id,
+ onClick = { selectedVersionId = ver.id },
+ )
+ }
+ }
+ }
+ }
+
+ if (!poster.isNullOrBlank()) {
+ AsyncImage(
+ model = poster,
+ contentDescription = null,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 56.dp)
+ .width(200.dp)
+ .height(300.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF1A1A1A)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun VersionPickChip(
+ version: MediaVersion,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val shape = RoundedCornerShape(5.dp)
+ Surface(
+ onClick = onClick,
+ shape = ClickableSurfaceDefaults.shape(shape = shape),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
+ glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (selected) Color(0xEE222222) else Color(0x99101010),
+ focusedContainerColor = Color(0xFF2C2C2C),
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ border = ClickableSurfaceDefaults.border(
+ border = Border(
+ border = androidx.compose.foundation.BorderStroke(
+ if (selected) 1.5.dp else 1.dp,
+ if (selected) Color.White else Color(0x33FFFFFF),
+ ),
+ shape = shape,
+ ),
+ focusedBorder = Border(
+ border = androidx.compose.foundation.BorderStroke(1.5.dp, Color.White),
+ shape = shape,
+ ),
+ ),
+ ) {
+ VersionQualityMark(version = version, selected = selected)
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun SeriesDetailScreen(
+ api: ApiClient,
+ seriesId: String,
+ onBack: () -> Unit,
+ onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit,
+) {
+ val context = LocalContext.current
+ var title by remember { mutableStateOf("…") }
+ var overview by remember { mutableStateOf("") }
+ var backdrop by remember { mutableStateOf(null) }
+ var logoUrl by remember { mutableStateOf(null) }
+ var year by remember { mutableStateOf(null) }
+ var rating by remember { mutableStateOf(null) }
+ var genres by remember { mutableStateOf>(emptyList()) }
+ var trailerKey by remember { mutableStateOf(null) }
+ var seasons by remember { mutableStateOf>(emptyList()) }
+ var selectedSeason by remember { mutableIntStateOf(0) }
+ var error by remember { mutableStateOf(null) }
+ val firstEpFocus = remember { FocusRequester() }
+
+ LaunchedEffect(seriesId) {
+ try {
+ val o = api.series(seriesId)
+ title = o.optString("title")
+ overview = o.optString("overview")
+ backdrop = o.optCleanString("backdropUrl") ?: o.optCleanString("posterUrl")
+ logoUrl = o.optCleanString("logoUrl")
+ year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }
+ rating = o.optCleanString("rating")
+ genres = o.optJSONArray("genres").stringList()
+ trailerKey = o.optCleanString("trailerYoutubeKey")
+ val arr = o.optJSONArray("seasons")
+ val list = mutableListOf()
+ if (arr != null) {
+ for (i in 0 until arr.length()) list += arr.getJSONObject(i)
+ }
+ seasons = list
+ selectedSeason = list.firstOrNull()?.optInt("seasonNumber") ?: 0
+ } catch (e: Exception) {
+ error = e.message
+ }
+ }
+
+ val episodes = remember(seasons, selectedSeason) {
+ val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason }
+ val eps = season?.optJSONArray("episodes") ?: return@remember emptyList()
+ buildList {
+ for (i in 0 until eps.length()) {
+ val e = eps.getJSONObject(i)
+ if (!e.optBoolean("available", false)) continue
+ val fileId = e.optCleanString("mediaFileId") ?: continue
+ add(
+ EpisodeRow(
+ id = e.getString("id"),
+ fileId = fileId,
+ season = e.optInt("seasonNumber"),
+ number = e.optInt("episodeNumber"),
+ title = e.optString("title").ifBlank { "Aflevering ${e.optInt("episodeNumber")}" },
+ overview = e.optString("overview"),
+ stillUrl = e.optCleanString("stillUrl"),
+ runtime = e.optInt("runtime").takeIf { e.has("runtime") && !e.isNull("runtime") && e.optInt("runtime") > 0 },
+ )
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(episodes) {
+ if (episodes.isNotEmpty()) {
+ delay(80)
+ runCatching { firstEpFocus.requestFocus() }
+ }
+ }
+
+ val metaParts = buildList {
+ year?.let { add(it.toString()) }
+ genres.take(3).forEach(::add)
+ rating?.let { add("★ $it") }
+ }
+
+ BackHandler(onBack = onBack)
+ Box(Modifier.fillMaxSize()) {
+ DetailBackdrop(backdrop)
+ LazyColumn(
+ contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ DetailBackButton(onClick = onBack)
+ Spacer(Modifier.height(10.dp))
+ DetailLogoOrTitle(logoUrl, title)
+ MetaChips(metaParts)
+ if (overview.isNotBlank()) {
+ Text(
+ overview,
+ color = McColors.TextDim,
+ fontSize = 15.sp,
+ maxLines = 4,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 22.sp,
+ modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
+ )
+ }
+ if (error != null) Text(error!!, color = McColors.Danger)
+ Spacer(Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ episodes.firstOrNull()?.let { ep ->
+ DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) {
+ onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}")
+ }
+ }
+ if (!trailerKey.isNullOrBlank()) {
+ DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
+ }
+ }
+ if (seasons.size > 1) {
+ Spacer(Modifier.height(8.dp))
+ Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(4.dp))
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(seasons) { s ->
+ val n = s.optInt("seasonNumber")
+ DetailActionButton(
+ "Seizoen $n",
+ primary = n == selectedSeason,
+ ) { selectedSeason = n }
+ }
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ }
+ itemsIndexed(episodes) { index, ep ->
+ EpisodeCard(
+ episode = ep,
+ focusRequester = null,
+ onClick = {
+ onPlay(ep.fileId, ep.id, "$title · S${ep.season}E${ep.number}")
+ },
+ )
+ }
+ }
+ }
+}
+
+private data class EpisodeRow(
+ val id: String,
+ val fileId: String,
+ val season: Int,
+ val number: Int,
+ val title: String,
+ val overview: String,
+ val stillUrl: String?,
+ val runtime: Int?,
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun EpisodeCard(
+ episode: EpisodeRow,
+ focusRequester: FocusRequester?,
+ onClick: () -> Unit,
+) {
+ Surface(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color(0xCC141414),
+ focusedContainerColor = Color(0xFF222222),
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(10.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = episode.stillUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .width(160.dp)
+ .height(90.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color(0xFF1A1A1A)),
+ contentScale = ContentScale.Crop,
+ )
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ "S${episode.season}E${episode.number} · ${episode.title}",
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (episode.overview.isNotBlank()) {
+ Text(
+ episode.overview,
+ color = McColors.TextDim,
+ fontSize = 13.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 18.sp,
+ )
+ }
+ formatRuntime(episode.runtime)?.let {
+ Text(it, color = Color(0xFF888888), fontSize = 12.sp)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt
new file mode 100644
index 0000000..abe2ed8
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt
@@ -0,0 +1,760 @@
+package nl.vonas.mediacluster.tv.ui
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateMapOf
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.IptvCategory
+import nl.vonas.mediacluster.tv.data.IptvChannel
+import nl.vonas.mediacluster.tv.data.IptvEpg
+import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
+import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
+import nl.vonas.mediacluster.tv.playback.liveMediaItem
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+private data class MenuCategoryCache(
+ val channels: List,
+ val epgTitles: Map,
+ val loadedAt: Long,
+)
+
+private const val MENU_CACHE_TTL_MS = 90_000L
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun LiveTvPlayerScreen(
+ api: ApiClient,
+ streamId: Int,
+ categoryId: String?,
+ onCategoryChange: (String) -> Unit,
+ onStreamChange: (Int) -> Unit,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val player = remember { buildLiveTvPlayer(context) }
+ val playerFocus = remember { FocusRequester() }
+ val menuScope = rememberCoroutineScope()
+
+ var currentStreamId by remember { mutableIntStateOf(streamId) }
+ var loading by remember { mutableStateOf(true) }
+ var switching by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var channelName by remember { mutableStateOf("") }
+ var logoUrl by remember { mutableStateOf(null) }
+ var epg by remember { mutableStateOf(null) }
+ var infoOverlay by remember { mutableStateOf(true) }
+ var channelMenuOpen by remember { mutableStateOf(false) }
+ var fallbackUrl by remember { mutableStateOf(null) }
+ var fallbackFormat by remember { mutableStateOf(null) }
+ var triedFallback by remember { mutableStateOf(false) }
+
+ var menuChannels by remember { mutableStateOf>(emptyList()) }
+ var menuCategories by remember { mutableStateOf>(emptyList()) }
+ var menuCategoryId by remember { mutableStateOf(categoryId) }
+ var menuCategoryName by remember { mutableStateOf("Zenders") }
+ var menuRefreshing by remember { mutableStateOf(false) }
+ var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
+ var menuLoadGen by remember { mutableIntStateOf(0) }
+ val menuCache = remember { mutableStateMapOf() }
+ val menuEpgNow = remember { mutableStateMapOf() }
+ val menuListState = rememberLazyListState()
+ val menuChannelFocus = remember { mutableStateMapOf() }
+
+ fun startStream(url: String, format: String) {
+ player.setMediaItem(liveMediaItem(url, format))
+ player.prepare()
+ player.playWhenReady = true
+ }
+
+ suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) {
+ if (isSwitch) switching = true else loading = true
+ error = null
+ triedFallback = false
+ try {
+ val play = api.iptvPlayUrl(streamIdToPlay)
+ channelName = play.name
+ logoUrl = play.logoUrl
+ val tsUrl = when {
+ play.format.equals("ts", ignoreCase = true) -> play.streamUrl
+ play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
+ else -> null
+ }
+ val hlsUrl = when {
+ play.format.equals("hls", ignoreCase = true) -> play.streamUrl
+ play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
+ else -> null
+ }
+ if (!tsUrl.isNullOrBlank()) {
+ fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
+ fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
+ if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
+ }
+ startStream(tsUrl, "ts")
+ } else {
+ fallbackUrl = play.fallbackStreamUrl
+ fallbackFormat = play.fallbackFormat
+ startStream(play.streamUrl, play.format)
+ }
+ onStreamChange(streamIdToPlay)
+ } catch (e: Exception) {
+ error = e.message ?: "Kon zender niet starten"
+ } finally {
+ loading = false
+ switching = false
+ }
+ }
+
+ LaunchedEffect(categoryId) {
+ menuCategoryId = categoryId
+ }
+
+ LaunchedEffect(streamId) {
+ currentStreamId = streamId
+ }
+
+ LaunchedEffect(currentStreamId) {
+ epg = null
+ loadAndPlay(currentStreamId, isSwitch = currentStreamId != streamId)
+ }
+
+ LaunchedEffect(currentStreamId) {
+ delay(2_500)
+ runCatching { epg = api.iptvEpg(currentStreamId) }
+ }
+
+ LaunchedEffect(channelMenuOpen) {
+ if (!channelMenuOpen) return@LaunchedEffect
+ if (menuCategories.isEmpty()) {
+ runCatching { menuCategories = api.iptvCategories() }
+ }
+ }
+
+ fun applyMenuEpg(titles: Map) {
+ for ((id, title) in titles) {
+ menuEpgNow[id] = title
+ }
+ }
+
+ fun cacheKey(catId: String?) = catId?.takeIf { it.isNotBlank() } ?: "__all__"
+
+ suspend fun loadMenuEpgInBackground(catId: String?, gen: Int) {
+ val key = cacheKey(catId)
+ try {
+ val withEpg = if (!catId.isNullOrBlank()) {
+ api.iptvChannels(catId, includeEpg = true)
+ } else {
+ api.iptvChannels(includeEpg = true)
+ }
+ if (gen != menuLoadGen) return
+ val titles = linkedMapOf()
+ withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } }
+ applyMenuEpg(titles)
+ menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis())
+ } catch (_: Exception) {
+ // zenderlijst blijft bruikbaar zonder EPG
+ }
+ }
+
+ fun prefetchAdjacentCategories(centerCatId: String?) {
+ val cats = menuCategories
+ if (cats.isEmpty()) return
+ val idx = cats.indexOfFirst { it.id == centerCatId }.let { if (it < 0) 0 else it }
+ for (offset in listOf(-1, 1)) {
+ val cat = cats[(idx + offset + cats.size) % cats.size]
+ val key = cacheKey(cat.id)
+ if (menuCache.containsKey(key)) continue
+ menuScope.launch {
+ runCatching {
+ val loaded = api.iptvChannels(cat.id, includeEpg = false)
+ menuCache[key] = MenuCategoryCache(loaded, emptyMap(), System.currentTimeMillis())
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(channelMenuOpen, menuCategoryId) {
+ if (!channelMenuOpen) return@LaunchedEffect
+ menuLoadGen++
+ val gen = menuLoadGen
+ val catId = menuCategoryId
+ val key = cacheKey(catId)
+ menuCategoryName = menuCategories.firstOrNull { it.id == catId }?.name ?: "Zenders"
+
+ val cached = menuCache[key]
+ val cacheFresh = cached != null && System.currentTimeMillis() - cached.loadedAt < MENU_CACHE_TTL_MS
+ if (cached != null) {
+ menuChannels = cached.channels
+ applyMenuEpg(cached.epgTitles)
+ menuRefreshing = false
+ if (cacheFresh) {
+ prefetchAdjacentCategories(catId)
+ if (cached.epgTitles.isNotEmpty()) return@LaunchedEffect
+ menuScope.launch { loadMenuEpgInBackground(catId, gen) }
+ return@LaunchedEffect
+ }
+ }
+
+ menuRefreshing = menuChannels.isEmpty()
+ try {
+ val loaded = if (!catId.isNullOrBlank()) {
+ api.iptvChannels(catId, includeEpg = false)
+ } else {
+ api.iptvChannels(includeEpg = false)
+ }
+ if (gen != menuLoadGen) return@LaunchedEffect
+ menuChannels = loaded
+ val staleTitles = cached?.epgTitles.orEmpty()
+ if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles)
+ menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis())
+ } catch (_: Exception) {
+ if (gen == menuLoadGen) menuChannels = emptyList()
+ } finally {
+ if (gen == menuLoadGen) menuRefreshing = false
+ }
+
+ menuScope.launch {
+ loadMenuEpgInBackground(catId, gen)
+ if (gen == menuLoadGen) prefetchAdjacentCategories(catId)
+ }
+ }
+
+ fun shiftMenuCategory(delta: Int) {
+ val now = System.currentTimeMillis()
+ if (now - lastCategoryShiftAt < 320) return
+ lastCategoryShiftAt = now
+ val cats = menuCategories
+ if (cats.isEmpty()) return
+ val idx = cats.indexOfFirst { it.id == menuCategoryId }.let { if (it < 0) 0 else it }
+ val next = cats[(idx + delta).mod(cats.size)]
+ menuCategoryId = next.id
+ onCategoryChange(next.id)
+ }
+
+ LaunchedEffect(channelMenuOpen, menuCategoryId, menuChannels) {
+ if (!channelMenuOpen || menuChannels.isEmpty()) return@LaunchedEffect
+ delay(80)
+ val targetId = menuChannels.firstOrNull { it.streamId == currentStreamId }?.streamId
+ ?: menuChannels.first().streamId
+ val idx = menuChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
+ menuListState.scrollToItem(idx)
+ delay(80)
+ menuChannelFocus[targetId]?.let { runCatching { it.requestFocus() } }
+ }
+
+ LaunchedEffect(infoOverlay) {
+ if (infoOverlay && !channelMenuOpen) {
+ delay(5000)
+ infoOverlay = false
+ }
+ }
+
+ DisposableEffect(Unit) {
+ val listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(state: Int) {
+ if (state == Player.STATE_READY) player.ensureAudioSelected()
+ }
+
+ override fun onPlayerError(e: PlaybackException) {
+ val fb = fallbackUrl
+ val fbFmt = fallbackFormat
+ if (!triedFallback && !fb.isNullOrBlank()) {
+ triedFallback = true
+ error = null
+ startStream(fb, fbFmt ?: "hls")
+ return
+ }
+ error = e.message ?: "Afspeelfout"
+ }
+ }
+ player.addListener(listener)
+ onDispose {
+ player.removeListener(listener)
+ player.release()
+ }
+ }
+
+ BackHandler(enabled = channelMenuOpen) {
+ channelMenuOpen = false
+ }
+ BackHandler(enabled = !channelMenuOpen) {
+ onBack()
+ }
+
+ fun selectChannel(newStreamId: Int) {
+ if (newStreamId == currentStreamId) return
+ currentStreamId = newStreamId
+ infoOverlay = true
+ }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .focusRequester(playerFocus)
+ .focusable()
+ .onPreviewKeyEvent { ev ->
+ if (channelMenuOpen) return@onPreviewKeyEvent false
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.Back, Key.Escape -> {
+ onBack()
+ true
+ }
+ Key.DirectionUp -> {
+ channelMenuOpen = true
+ true
+ }
+ Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
+ infoOverlay = !infoOverlay
+ true
+ }
+ else -> {
+ infoOverlay = true
+ false
+ }
+ }
+ },
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ useController = false
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
+ this.player = player
+ }
+ },
+ update = { it.player = player },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ LaunchedEffect(Unit) {
+ delay(200)
+ runCatching { playerFocus.requestFocus() }
+ }
+
+ LaunchedEffect(channelMenuOpen) {
+ if (!channelMenuOpen) {
+ delay(80)
+ runCatching { playerFocus.requestFocus() }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = infoOverlay && !channelMenuOpen && error == null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ listOf(Color(0xCC000000), Color.Transparent, Color(0xCC000000)),
+ ),
+ ),
+ ) {
+ Row(
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(32.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (!logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = logoUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0x22FFFFFF)),
+ contentScale = ContentScale.Fit,
+ )
+ }
+ Column {
+ Text("LIVE", color = McColors.Danger, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text(
+ channelName.ifBlank { "Live TV" },
+ color = Color.White,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ epg?.let { info ->
+ Column(
+ Modifier
+ .align(Alignment.BottomStart)
+ .padding(32.dp),
+ ) {
+ info.now?.let { now ->
+ Text(
+ "Nu · ${now.title}",
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ info.next?.let { next ->
+ Text(
+ "Straks · ${next.title}",
+ color = McColors.TextDim,
+ fontSize = 15.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = channelMenuOpen,
+ enter = fadeIn(tween(180)) + slideInHorizontally(tween(220)) { it },
+ exit = fadeOut(tween(150)) + slideOutHorizontally(tween(200)) { it },
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.4f)),
+ )
+ LiveChannelSwitcher(
+ categoryName = menuCategoryName,
+ categoryIndex = menuCategories.indexOfFirst { it.id == menuCategoryId }.coerceAtLeast(0),
+ categoryCount = menuCategories.size,
+ channels = menuChannels,
+ epgNow = menuEpgNow,
+ currentStreamId = currentStreamId,
+ refreshing = menuRefreshing,
+ switching = switching,
+ listState = menuListState,
+ channelFocus = menuChannelFocus,
+ onSelect = { selectChannel(it) },
+ onCategoryShift = { shiftMenuCategory(it) },
+ modifier = Modifier.align(Alignment.CenterEnd),
+ )
+ }
+ }
+
+ if (loading && !channelMenuOpen) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Zender laden…", color = Color.White, fontSize = 18.sp)
+ }
+ }
+
+ if (error != null) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xEE000000)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Kan zender niet afspelen", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(error!!, color = McColors.Danger, fontSize = 15.sp)
+ Text("Druk op Terug om terug te gaan", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun LiveChannelSwitcher(
+ categoryName: String,
+ categoryIndex: Int,
+ categoryCount: Int,
+ channels: List,
+ epgNow: Map,
+ currentStreamId: Int,
+ refreshing: Boolean,
+ switching: Boolean,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ channelFocus: MutableMap,
+ onSelect: (Int) -> Unit,
+ onCategoryShift: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val glassShape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
+ Box(
+ modifier
+ .padding(top = 28.dp, bottom = 28.dp, end = 0.dp)
+ .width(380.dp)
+ .fillMaxHeight()
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.DirectionLeft -> {
+ onCategoryShift(-1)
+ true
+ }
+ Key.DirectionRight -> {
+ onCategoryShift(1)
+ true
+ }
+ else -> false
+ }
+ }
+ .clip(glassShape)
+ .background(
+ Brush.horizontalGradient(
+ listOf(Color(0xF0181820), Color(0xE8101018)),
+ ),
+ )
+ .border(
+ width = 1.dp,
+ brush = Brush.verticalGradient(
+ listOf(Color.White.copy(alpha = 0.18f), Color.White.copy(alpha = 0.06f)),
+ ),
+ shape = glassShape,
+ ),
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp, vertical = 14.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("‹", color = Color(0xFF666666), fontSize = 16.sp)
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
+ Text(
+ "Zenders",
+ color = McColors.TextDim,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 1.sp,
+ )
+ Text(
+ categoryName,
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (categoryCount > 1) {
+ Text(
+ "${categoryIndex + 1} / $categoryCount · ← → categorie",
+ color = Color(0xFF666666),
+ fontSize = 10.sp,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+ Text("›", color = Color(0xFF666666), fontSize = 16.sp)
+ }
+ if (switching) {
+ Text("Wisselen…", color = McColors.Brand, fontSize = 12.sp, modifier = Modifier.padding(top = 4.dp))
+ } else if (refreshing && channels.isEmpty()) {
+ Text("Laden…", color = Color(0xFF666666), fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp))
+ }
+ }
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Color.White.copy(alpha = 0.08f)),
+ )
+ when {
+ channels.isEmpty() && refreshing -> {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("Laden…", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ channels.isEmpty() -> {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ else -> {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(channels.size, key = { channels[it].streamId }) { index ->
+ val ch = channels[index]
+ val isCurrent = ch.streamId == currentStreamId
+ val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
+ Surface(
+ onClick = { onSelect(ch.streamId) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 10.dp)
+ .focusRequester(fr),
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (isCurrent) Color(0x33E50914) else Color.Transparent,
+ focusedContainerColor = Color(0x28FFFFFF),
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(54.dp)
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier
+ .width(3.dp)
+ .height(28.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(if (isCurrent) McColors.Brand else Color.Transparent),
+ )
+ Spacer(Modifier.width(8.dp))
+ Box(
+ Modifier
+ .size(32.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color(0xFF0A0A0A)),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (!ch.logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = ch.logoUrl,
+ contentDescription = ch.name,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(3.dp),
+ contentScale = ContentScale.Fit,
+ )
+ }
+ }
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ch.number?.let { num ->
+ Text(
+ "$num",
+ color = McColors.TextDim,
+ fontSize = 12.sp,
+ modifier = Modifier.width(30.dp),
+ )
+ }
+ Text(
+ ch.name,
+ color = if (isCurrent) Color.White else Color(0xFFDDDDDD),
+ fontSize = 14.sp,
+ fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Text(
+ epgNow[ch.streamId] ?: ch.nowTitle.orEmpty(),
+ color = McColors.TextDim,
+ fontSize = 12.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(14.dp),
+ ) {
+ Text(
+ "Terug om te sluiten",
+ color = Color(0xFF666666),
+ fontSize = 11.sp,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt
new file mode 100644
index 0000000..614c806
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt
@@ -0,0 +1,1055 @@
+package nl.vonas.mediacluster.tv.ui
+
+import android.app.Activity
+import android.os.Build
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+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.fillMaxHeight
+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.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.DisposableEffect
+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.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Switch
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.HomePayload
+import nl.vonas.mediacluster.tv.data.MediaItem
+import nl.vonas.mediacluster.tv.data.PollResult
+import nl.vonas.mediacluster.tv.data.SessionStore
+import nl.vonas.mediacluster.tv.data.ShelfPref
+import nl.vonas.mediacluster.tv.ui.components.MainTab
+import nl.vonas.mediacluster.tv.ui.components.McButton
+import nl.vonas.mediacluster.tv.ui.components.PosterCard
+import nl.vonas.mediacluster.tv.ui.components.SubNavItem
+import nl.vonas.mediacluster.tv.ui.components.TopNavBar
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@Composable
+fun McNav() {
+ val context = LocalContext.current
+ val store = remember { SessionStore(context) }
+ val api = remember { ApiClient(baseUrl = "") }
+ val nav = rememberNavController()
+ val scope = rememberCoroutineScope()
+ var ready by remember { mutableStateOf(false) }
+ var authed by remember { mutableStateOf(false) }
+ // Blijft behouden als main uit compositie gaat (bijv. live player)
+ var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
+ var tvCategoryId by rememberSaveable { mutableStateOf(null) }
+ var tvLastStreamId by rememberSaveable { mutableStateOf(null) }
+ var tvFocusNonce by remember { mutableIntStateOf(0) }
+ val hubTab = remember(hubTabName) {
+ MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
+ }
+
+ LaunchedEffect(Unit) {
+ val base = store.apiBase.first()
+ val token = store.refreshToken.first()
+ api.configure(base, token)
+ authed = !token.isNullOrBlank()
+ ready = true
+ }
+
+ if (!ready) {
+ Splash()
+ return
+ }
+
+ NavHost(navController = nav, startDestination = if (authed) "main" else "link") {
+ composable("link") {
+ LinkScreen(api, store) {
+ authed = true
+ nav.navigate("main") { popUpTo("link") { inclusive = true } }
+ }
+ }
+ composable("main") {
+ MainHub(
+ api = api,
+ tab = hubTab,
+ onTabChange = { hubTabName = it.name },
+ tvCategoryId = tvCategoryId,
+ onTvCategoryChange = { tvCategoryId = it },
+ tvLastStreamId = tvLastStreamId,
+ onTvLastStreamChange = { tvLastStreamId = it },
+ tvFocusNonce = tvFocusNonce,
+ onTvFocusContent = { tvFocusNonce++ },
+ onOpenMovie = { nav.navigate("movie/$it") },
+ onOpenSeries = { nav.navigate("series/$it") },
+ onBrowse = { catalogId, _title ->
+ nav.navigate("browse/$catalogId")
+ },
+ onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
+ val m = movieId?.ifBlank { null } ?: "-"
+ val e = episodeId?.ifBlank { null } ?: "-"
+ val s = seriesId?.ifBlank { null } ?: "-"
+ nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
+ },
+ onPlayLive = { streamId ->
+ tvLastStreamId = streamId
+ if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
+ nav.navigate("live/$streamId")
+ },
+ onLogout = {
+ scope.launch {
+ store.setToken(null)
+ api.configure(store.apiBase.first(), null)
+ authed = false
+ nav.navigate("link") { popUpTo(0) { inclusive = true } }
+ }
+ },
+ )
+ }
+ composable(
+ "browse/{catalogId}",
+ arguments = listOf(navArgument("catalogId") { type = NavType.StringType }),
+ ) { entry ->
+ val catalogId = entry.arguments?.getString("catalogId").orEmpty()
+ BrowseScreen(
+ api = api,
+ catalogId = catalogId,
+ onBack = { nav.popBackStack() },
+ onOpenMovie = { nav.navigate("movie/$it") },
+ onOpenSeries = { nav.navigate("series/$it") },
+ onPlayContinue = if (catalogId == "continue-watching") {
+ { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
+ val m = movieId?.ifBlank { null } ?: "-"
+ val e = episodeId?.ifBlank { null } ?: "-"
+ val s = seriesId?.ifBlank { null } ?: "-"
+ nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
+ }
+ } else {
+ null
+ },
+ )
+ }
+ composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
+ val id = entry.arguments?.getString("id") ?: return@composable
+ MovieDetailScreen(
+ api = api,
+ movieId = id,
+ onBack = { nav.popBackStack() },
+ onPlay = { mediaFileId, _, startMs ->
+ nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
+ },
+ )
+ }
+ composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
+ val id = entry.arguments?.getString("id") ?: return@composable
+ SeriesDetailScreen(
+ api = api,
+ seriesId = id,
+ onBack = { nav.popBackStack() },
+ onPlay = { mediaFileId, episodeId, _ ->
+ nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
+ },
+ )
+ }
+ composable(
+ "player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
+ arguments = listOf(
+ navArgument("mediaFileId") { type = NavType.StringType },
+ navArgument("type") { type = NavType.StringType },
+ navArgument("movieId") { type = NavType.StringType },
+ navArgument("episodeId") { type = NavType.StringType },
+ navArgument("seriesId") { type = NavType.StringType },
+ navArgument("startMs") { type = NavType.LongType },
+ ),
+ ) { entry ->
+ fun dash(v: String?) = v?.takeIf { it.isNotBlank() && it != "-" }
+ PlayerScreen(
+ api = api,
+ mediaFileId = entry.arguments?.getString("mediaFileId").orEmpty(),
+ mediaType = entry.arguments?.getString("type") ?: "MOVIE",
+ movieId = dash(entry.arguments?.getString("movieId")),
+ episodeId = dash(entry.arguments?.getString("episodeId")),
+ seriesId = dash(entry.arguments?.getString("seriesId")),
+ startMs = entry.arguments?.getLong("startMs") ?: 0L,
+ onBack = { nav.popBackStack() },
+ )
+ }
+ composable(
+ "live/{streamId}",
+ arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
+ ) { entry ->
+ val streamId = entry.arguments?.getInt("streamId") ?: return@composable
+ DisposableEffect(Unit) {
+ onDispose { tvFocusNonce++ }
+ }
+ LiveTvPlayerScreen(
+ api = api,
+ streamId = streamId,
+ categoryId = tvCategoryId,
+ onCategoryChange = { tvCategoryId = it },
+ onStreamChange = { tvLastStreamId = it },
+ onBack = { nav.popBackStack() },
+ )
+ }
+ }
+}
+
+@Composable
+private fun MainHub(
+ api: ApiClient,
+ tab: MainTab,
+ onTabChange: (MainTab) -> Unit,
+ tvCategoryId: String?,
+ onTvCategoryChange: (String?) -> Unit,
+ tvLastStreamId: Int?,
+ onTvLastStreamChange: (Int?) -> Unit,
+ tvFocusNonce: Int,
+ onTvFocusContent: () -> Unit,
+ onOpenMovie: (String) -> Unit,
+ onOpenSeries: (String) -> Unit,
+ onBrowse: (catalogId: String, title: String) -> Unit,
+ onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
+ onPlayLive: (Int) -> Unit,
+ onLogout: () -> Unit,
+) {
+ var iptvEnabled by remember { mutableStateOf(false) }
+ var navFocused by remember { mutableStateOf(false) }
+ var exitConfirm by remember { mutableStateOf(false) }
+ val navFocus = remember { FocusRequester() }
+ val homeListState = rememberLazyListState()
+ val hubScope = rememberCoroutineScope()
+ val activity = LocalContext.current as? Activity
+ var shelfPrefs by remember { mutableStateOf>(emptyList()) }
+ var movieCatalog by remember { mutableStateOf("movies") }
+ var seriesCatalog by remember { mutableStateOf("series") }
+ var movieSubId by remember { mutableStateOf("movies") }
+ var seriesSubId by remember { mutableStateOf("series") }
+
+ LaunchedEffect(Unit) {
+ runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
+ runCatching { iptvEnabled = api.clientMe().iptvEnabled }
+ }
+
+ val visibleTabs = remember(iptvEnabled) {
+ MainTab.entries.filter { it != MainTab.Tv || iptvEnabled }
+ }
+
+ val movieSubs = remember(shelfPrefs) {
+ buildList {
+ add(SubNavItem("movies", "Alles"))
+ add(SubNavItem("recent-movies", "Recent"))
+ add(SubNavItem("4k-movies", "4K"))
+ val reserved = setOf("movies", "recent-movies", "4k-movies", "__search__")
+ shelfPrefs.filter { it.kind.equals("MOVIE", true) && it.visible }.forEach { p ->
+ val slug = p.slug.ifBlank { p.shelfId }
+ val catalog = if (slug in reserved) p.shelfId else slug
+ add(SubNavItem(id = "shelf-${p.shelfId}", label = p.name, catalogId = catalog))
+ }
+ add(SubNavItem("__search__", "Zoeken"))
+ }
+ }
+ val seriesSubs = remember(shelfPrefs) {
+ buildList {
+ add(SubNavItem("series", "Alles"))
+ add(SubNavItem("recent-series", "Recent"))
+ val reserved = setOf("series", "recent-series", "__search__")
+ shelfPrefs.filter { it.kind.equals("SERIES", true) && it.visible }.forEach { p ->
+ val slug = p.slug.ifBlank { p.shelfId }
+ val catalog = if (slug in reserved) p.shelfId else slug
+ add(SubNavItem(id = "shelf-${p.shelfId}", label = p.name, catalogId = catalog))
+ }
+ add(SubNavItem("__search__", "Zoeken"))
+ }
+ }
+
+ val subItems = when (tab) {
+ MainTab.Movies -> movieSubs
+ MainTab.Series -> seriesSubs
+ else -> emptyList()
+ }
+ val selectedSubId = when (tab) {
+ MainTab.Movies -> movieSubId
+ MainTab.Series -> seriesSubId
+ else -> null
+ }
+
+ fun focusTopNav() {
+ navFocused = true
+ exitConfirm = false
+ hubScope.launch {
+ delay(40)
+ runCatching { navFocus.requestFocus() }
+ }
+ }
+
+ // Exit-dialog: terug annuleert
+ BackHandler(enabled = exitConfirm) {
+ exitConfirm = false
+ }
+ // Home + top-nav: vraag om afsluiten
+ BackHandler(enabled = tab == MainTab.Home && navFocused && !exitConfirm) {
+ exitConfirm = true
+ }
+ // Home content: eerst scroll omhoog, anders top-nav
+ BackHandler(enabled = tab == MainTab.Home && !navFocused && !exitConfirm) {
+ if (homeListState.firstVisibleItemIndex > 0 || homeListState.firstVisibleItemScrollOffset > 0) {
+ hubScope.launch { homeListState.animateScrollToItem(0) }
+ } else {
+ focusTopNav()
+ }
+ }
+ // Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home
+ BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) {
+ onTabChange(MainTab.Home)
+ focusTopNav()
+ }
+ // Settings (geen eigen scroll-back): terug naar home
+ BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) {
+ onTabChange(MainTab.Home)
+ focusTopNav()
+ }
+ // TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
+ BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
+ focusTopNav()
+ }
+
+ LaunchedEffect(navFocused) {
+ if (navFocused) {
+ delay(40)
+ runCatching { navFocus.requestFocus() }
+ }
+ }
+
+ LaunchedEffect(tvFocusNonce) {
+ if (tab == MainTab.Tv) {
+ navFocused = false
+ }
+ }
+
+ Box(Modifier.fillMaxSize().background(McColors.Bg)) {
+ Column(Modifier.fillMaxSize()) {
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ when (tab) {
+ MainTab.Home -> HomeScreen(
+ api = api,
+ listState = homeListState,
+ onContentFocused = { navFocused = false },
+ onOpenMovie = onOpenMovie,
+ onOpenSeries = onOpenSeries,
+ onSeeAll = onBrowse,
+ onPlay = onPlay,
+ )
+ MainTab.Movies -> BrowseScreen(
+ api = api,
+ catalogId = movieCatalog,
+ titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
+ onAtTopBack = { focusTopNav() },
+ onOpenMovie = onOpenMovie,
+ onOpenSeries = onOpenSeries,
+ )
+ MainTab.Series -> BrowseScreen(
+ api = api,
+ catalogId = seriesCatalog,
+ titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
+ onAtTopBack = { focusTopNav() },
+ onOpenMovie = onOpenMovie,
+ onOpenSeries = onOpenSeries,
+ )
+ MainTab.Tv -> TvScreen(
+ api = api,
+ selectedCategoryId = tvCategoryId,
+ onCategoryChange = onTvCategoryChange,
+ lastStreamId = tvLastStreamId,
+ onLastStreamChange = onTvLastStreamChange,
+ focusContentNonce = tvFocusNonce,
+ onAtTopBack = { focusTopNav() },
+ onPlayChannel = onPlayLive,
+ onContentFocused = { navFocused = false },
+ )
+ MainTab.Search -> SearchScreen(
+ api = api,
+ onOpenMovie = onOpenMovie,
+ onOpenSeries = onOpenSeries,
+ onAtTopBack = { focusTopNav() },
+ )
+ MainTab.Settings -> SettingsScreen(
+ api = api,
+ onBack = {
+ onTabChange(MainTab.Home)
+ focusTopNav()
+ },
+ onLogout = onLogout,
+ )
+ }
+ }
+ Box(Modifier.focusRequester(navFocus)) {
+ TopNavBar(
+ selected = tab,
+ visibleTabs = visibleTabs,
+ onSelect = {
+ if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
+ onTabChange(it)
+ exitConfirm = false
+ if (it == MainTab.Tv) {
+ navFocused = false
+ onTvFocusContent()
+ } else {
+ focusTopNav()
+ }
+ when (it) {
+ MainTab.Movies -> {
+ if (movieSubId == "__search__") {
+ movieSubId = "movies"
+ movieCatalog = "movies"
+ }
+ }
+ MainTab.Series -> {
+ if (seriesSubId == "__search__") {
+ seriesSubId = "series"
+ seriesCatalog = "series"
+ }
+ }
+ else -> Unit
+ }
+ },
+ subItems = subItems,
+ selectedSubId = selectedSubId,
+ onSelectSub = { item ->
+ if (item.id == "__search__") {
+ onTabChange(MainTab.Search)
+ focusTopNav()
+ } else {
+ when (tab) {
+ MainTab.Movies -> {
+ movieSubId = item.id
+ movieCatalog = item.catalogId
+ }
+ MainTab.Series -> {
+ seriesSubId = item.id
+ seriesCatalog = item.catalogId
+ }
+ else -> Unit
+ }
+ }
+ },
+ )
+ }
+ }
+
+ if (exitConfirm) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xCC000000)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(420.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color(0xFF1A1A1A))
+ .padding(28.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "Weet je zeker dat je de app wilt sluiten?",
+ color = McColors.TextDim,
+ fontSize = 15.sp,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ McButton("Annuleren") { exitConfirm = false }
+ McButton("Afsluiten", primary = true) { activity?.finish() }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun Splash() {
+ Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("VMC", color = McColors.Accent, fontSize = 42.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
+ Spacer(Modifier.height(6.dp))
+ Text("Vonas Media Cluster", color = McColors.TextDim, fontSize = 14.sp, letterSpacing = 1.sp)
+ Spacer(Modifier.height(12.dp))
+ Text("Laden…", color = McColors.TextDim, fontSize = 15.sp)
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) {
+ var code by remember { mutableStateOf(null) }
+ var linkUrl by remember { mutableStateOf("") }
+ var status by remember { mutableStateOf("Verbinden…") }
+ var error by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ val base = store.apiBase.first()
+ api.configure(base, null)
+ try {
+ val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
+ val created = api.createDeviceCode(deviceName)
+ code = created.code
+ linkUrl = created.linkUrl
+ status = "Wachten tot je de code bevestigt…"
+ while (true) {
+ delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
+ when (val poll = api.pollDeviceCode(created.code)) {
+ is PollResult.Linked -> {
+ store.setToken(poll.refreshToken)
+ api.configure(base, poll.refreshToken)
+ status = "Welkom, ${poll.viewerName}"
+ delay(700)
+ onLinked()
+ break
+ }
+ PollResult.Expired -> {
+ status = "Code verlopen — herstart de app"
+ break
+ }
+ PollResult.AlreadyLinked -> {
+ status = "Al gekoppeld — herstart de app"
+ break
+ }
+ PollResult.Pending -> Unit
+ }
+ }
+ } catch (e: Exception) {
+ error = e.message ?: "Kon master niet bereiken"
+ }
+ }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .clip(RoundedCornerShape(24.dp))
+ .background(McColors.BgElevated.copy(alpha = 0.92f))
+ .padding(24.dp),
+ ) {
+ Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
+ Text("Koppel dit apparaat", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
+ Text(
+ "Open de link hieronder in je browser (of op een andere telefoon), log in en voer de code in.",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ )
+ Text(code ?: "····-····", color = McColors.Accent, fontSize = 40.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
+ if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
+ Text(status, color = McColors.TextDim, fontSize = 14.sp)
+ if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun HomeScreen(
+ api: ApiClient,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ onContentFocused: () -> Unit,
+ onOpenMovie: (String) -> Unit,
+ onOpenSeries: (String) -> Unit,
+ onSeeAll: (catalogId: String, title: String) -> Unit,
+ onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
+) {
+ var home by remember { mutableStateOf(null) }
+ var error by remember { mutableStateOf(null) }
+ var continueMenu by remember { mutableStateOf(null) }
+ var menuReady by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ val menuFocus = remember { FocusRequester() }
+
+ suspend fun reloadHome() {
+ home = api.home()
+ }
+
+ LaunchedEffect(Unit) {
+ try {
+ reloadHome()
+ } catch (e: Exception) {
+ error = e.message
+ }
+ }
+
+ LaunchedEffect(continueMenu) {
+ if (continueMenu != null) {
+ menuReady = false
+ delay(500)
+ menuReady = true
+ runCatching { menuFocus.requestFocus() }
+ } else {
+ menuReady = false
+ }
+ }
+
+ val spotlight = home?.spotlight.orEmpty().ifEmpty {
+ home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
+ }
+
+ Box(Modifier.fillMaxSize()) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 56.dp),
+ verticalArrangement = Arrangement.spacedBy(28.dp),
+ ) {
+ item {
+ SpotlightBillboard(
+ items = spotlight,
+ onPlay = { item ->
+ onContentFocused()
+ when {
+ !item.mediaFileId.isNullOrBlank() -> onPlay(
+ item.mediaFileId!!,
+ item.id.takeIf { item.type == "movie" },
+ item.id.takeIf { item.type == "episode" },
+ null,
+ if (item.type == "episode") "EPISODE" else "MOVIE",
+ item.positionMs,
+ )
+ item.type == "movie" -> onOpenMovie(item.id)
+ else -> onOpenSeries(item.id)
+ }
+ },
+ onOpen = { item ->
+ onContentFocused()
+ if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
+ },
+ )
+ }
+ if (error != null) {
+ item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) }
+ }
+ val continueItems = home?.continueWatching.orEmpty()
+ if (continueItems.isNotEmpty()) {
+ val preview = continueItems.take(5)
+ item {
+ ShelfBlock(
+ title = "Verder kijken",
+ items = preview,
+ onSeeAll = if (continueItems.size > 5) {
+ { onSeeAll("continue-watching", "Verder kijken") }
+ } else {
+ null
+ },
+ onClick = { item ->
+ onContentFocused()
+ when {
+ !item.mediaFileId.isNullOrBlank() -> onPlay(
+ item.mediaFileId!!,
+ item.id.takeIf { item.type == "movie" },
+ item.id.takeIf { item.type == "episode" },
+ item.seriesId,
+ if (item.type == "episode") "EPISODE" else "MOVIE",
+ item.positionMs,
+ )
+ item.type == "movie" -> onOpenMovie(item.id)
+ item.type == "episode" && !item.seriesId.isNullOrBlank() -> onOpenSeries(item.seriesId!!)
+ else -> onOpenSeries(item.id)
+ }
+ },
+ onLongClick = { item -> continueMenu = item },
+ )
+ }
+ }
+ items(home?.shelves.orEmpty()) { shelf ->
+ val catalogId = when (shelf.slug) {
+ "recent-movies", "recent-series", "4k-movies" -> shelf.slug
+ else -> shelf.slug.ifBlank { shelf.id }
+ }
+ ShelfBlock(
+ title = shelf.title,
+ items = shelf.items,
+ onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
+ onClick = { item ->
+ onContentFocused()
+ if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
+ },
+ )
+ }
+ }
+
+ val menuItem = continueMenu
+ if (menuItem != null) {
+ BackHandler { continueMenu = null }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xCC000000)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(440.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color(0xFF1A1A1A))
+ .padding(28.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
+ if (!menuItem.subtitle.isNullOrBlank()) {
+ Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
+ }
+ Spacer(Modifier.height(4.dp))
+ McButton("Markeren als gezien") {
+ if (!menuReady) return@McButton
+ scope.launch {
+ runCatching {
+ if (menuItem.type == "movie") {
+ api.markWatched(movieId = menuItem.id)
+ } else {
+ api.markWatched(episodeId = menuItem.id)
+ }
+ reloadHome()
+ }.onFailure { error = it.message }
+ continueMenu = null
+ }
+ }
+ McButton("Verbergen") {
+ if (!menuReady) return@McButton
+ scope.launch {
+ val pid = menuItem.progressId
+ if (!pid.isNullOrBlank()) {
+ runCatching {
+ api.dismissContinue(pid)
+ reloadHome()
+ }.onFailure { error = it.message }
+ }
+ continueMenu = null
+ }
+ }
+ McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun SpotlightBillboard(
+ items: List,
+ onPlay: (MediaItem) -> Unit,
+ onOpen: (MediaItem) -> Unit,
+) {
+ var index by remember { mutableStateOf(0) }
+ val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
+ val current = safeItems[index.coerceIn(0, safeItems.lastIndex)]
+
+ LaunchedEffect(safeItems.size) {
+ if (safeItems.size <= 1) return@LaunchedEffect
+ while (true) {
+ delay(6500)
+ index = (index + 1) % safeItems.size
+ }
+ }
+
+ Box(Modifier.fillMaxWidth().height(480.dp)) {
+ AsyncImage(
+ model = current.backdropUrl ?: current.posterUrl,
+ contentDescription = current.title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ Box(
+ Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)),
+ ),
+ ),
+ )
+ Box(
+ Modifier.fillMaxSize().background(
+ Brush.horizontalGradient(
+ listOf(Color(0xE60B0B0B), Color(0x880B0B0B), Color.Transparent),
+ ),
+ ),
+ )
+
+ // Peek van volgende posters rechts
+ if (safeItems.size > 1) {
+ Row(
+ Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 36.dp),
+ horizontalArrangement = Arrangement.spacedBy((-28).dp),
+ ) {
+ val peek = listOf(1, 2).mapNotNull { off ->
+ safeItems.getOrNull((index + off) % safeItems.size)
+ }
+ peek.forEachIndexed { i, item ->
+ AsyncImage(
+ model = item.posterUrl ?: item.backdropUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .width(110.dp)
+ .height(165.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color(0xFF1A1A1A))
+ .graphicsLayer { alpha = if (i == 0) 0.9f else 0.5f },
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth(0.55f)
+ .padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "VMC",
+ color = McColors.Brand,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Black,
+ letterSpacing = 2.sp,
+ )
+ if (!current.badge.isNullOrBlank()) {
+ Text(
+ current.badge!!.uppercase(),
+ color = Color.White,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(3.dp))
+ .background(McColors.Brand)
+ .padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+ }
+ Text(
+ current.title,
+ color = Color.White,
+ fontSize = 46.sp,
+ fontWeight = FontWeight.Black,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 50.sp,
+ )
+ val meta = listOfNotNull(
+ current.year?.toString(),
+ if (current.type == "series") "Serie" else "Film",
+ current.subtitle?.takeIf { it.isNotBlank() },
+ ).joinToString(" · ")
+ if (meta.isNotBlank()) {
+ Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (current.id.isNotBlank()) {
+ McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
+ McButton("Meer info", onClick = { onOpen(current) })
+ }
+ }
+ if (safeItems.size > 1) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) {
+ safeItems.indices.forEach { i ->
+ Box(
+ Modifier
+ .height(4.dp)
+ .width(if (i == index) 22.dp else 8.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(if (i == index) Color.White else Color(0x55FFFFFF)),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ShelfBlock(
+ title: String,
+ items: List,
+ onSeeAll: (() -> Unit)?,
+ onClick: (MediaItem) -> Unit,
+ onLongClick: ((MediaItem) -> Unit)? = null,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 48.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ title,
+ color = Color(0xFFE5E5E5),
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ if (onSeeAll != null) {
+ McButton("Alles") { onSeeAll() }
+ }
+ }
+ // contentPadding i.p.v. Column-startpadding: focus-scale mag in de marge tekenen
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp),
+ ) {
+ items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
+ PosterCard(
+ item = item,
+ onClick = { onClick(item) },
+ onLongClick = onLongClick?.let { handler -> { handler(item) } },
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun SettingsScreen(
+ api: ApiClient,
+ onBack: () -> Unit,
+ onLogout: () -> Unit = {},
+) {
+ var prefs by remember { mutableStateOf>(emptyList()) }
+ var error by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ try {
+ prefs = api.getShelfPrefs().sortedBy { it.sortOrder }
+ } catch (e: Exception) {
+ error = e.message
+ }
+ }
+
+ fun persist(next: List) {
+ prefs = next
+ scope.launch {
+ try {
+ api.saveShelfPrefs(next.mapIndexed { i, p -> p.copy(sortOrder = i) })
+ } catch (e: Exception) {
+ error = e.message
+ }
+ }
+ }
+
+ LazyColumn(
+ Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
+ Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
+ if (error != null) Text(error!!, color = McColors.Danger)
+ Spacer(Modifier.height(8.dp))
+ McButton("Uitloggen", onClick = onLogout)
+ Spacer(Modifier.height(8.dp))
+ }
+ itemsIndexed(prefs) { index, p ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(McColors.BgElevated)
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ McButton("↑") {
+ if (index > 0) {
+ val next = prefs.toMutableList()
+ val tmp = next[index - 1]
+ next[index - 1] = next[index]
+ next[index] = tmp
+ persist(next)
+ }
+ }
+ McButton("↓") {
+ if (index < prefs.lastIndex) {
+ val next = prefs.toMutableList()
+ val tmp = next[index + 1]
+ next[index + 1] = next[index]
+ next[index] = tmp
+ persist(next)
+ }
+ }
+ Switch(
+ checked = p.visible,
+ onCheckedChange = { checked ->
+ val next = prefs.toMutableList()
+ next[index] = p.copy(visible = checked)
+ persist(next)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+/** Android JSONObject.optString returns the literal "null" for JSON null — filter that. */
+private fun org.json.JSONObject.optCleanString(key: String): String? {
+ if (!has(key) || isNull(key)) return null
+ val raw = optString(key).trim()
+ if (raw.isEmpty() || raw.equals("null", ignoreCase = true) || raw.equals("undefined", ignoreCase = true)) {
+ return null
+ }
+ return raw
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
new file mode 100644
index 0000000..95a3ce2
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt
@@ -0,0 +1,706 @@
+package nl.vonas.mediacluster.tv.ui
+
+import android.view.ViewGroup.LayoutParams
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.fillMaxHeight
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem as ExoMediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.SubtitleHit
+import nl.vonas.mediacluster.tv.playback.PlayerTrack
+import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle
+import nl.vonas.mediacluster.tv.playback.buildTvPlayer
+import nl.vonas.mediacluster.tv.playback.disableTrackType
+import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
+import nl.vonas.mediacluster.tv.playback.listTracks
+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)
+@Composable
+fun PlayerScreen(
+ api: ApiClient,
+ mediaFileId: String,
+ mediaType: String,
+ movieId: String?,
+ episodeId: String?,
+ seriesId: String?,
+ startMs: Long,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val player = remember { buildTvPlayer(context) }
+ val controlsFocus = remember { FocusRequester() }
+ val catcherFocus = remember { FocusRequester() }
+ val scope = rememberCoroutineScope()
+
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(true) }
+ var controls by remember { mutableStateOf(true) }
+ var playing by remember { mutableStateOf(true) }
+ var positionMs by remember { mutableLongStateOf(0L) }
+ var durationMs by remember { mutableLongStateOf(0L) }
+ var audioTracks by remember { mutableStateOf>(emptyList()) }
+ var textTracks by remember { mutableStateOf>(emptyList()) }
+ var menu by remember { mutableStateOf(null) } // audio | text | os
+ var osHits by remember { mutableStateOf>(emptyList()) }
+ var osLoading by remember { mutableStateOf(false) }
+ var osError by remember { mutableStateOf(null) }
+ var osBusy by remember { mutableStateOf(false) }
+
+ fun showControls() {
+ controls = true
+ }
+
+ fun hideControls() {
+ if (menu == null) controls = false
+ }
+
+ fun togglePlay() {
+ if (player.isPlaying) player.pause() else player.play()
+ showControls()
+ }
+
+ fun openOpenSubtitles() {
+ player.pause()
+ menu = "os"
+ osError = null
+ osHits = emptyList()
+ osLoading = true
+ showControls()
+ scope.launch {
+ try {
+ val res = api.searchSubtitles(mediaFileId)
+ if (!res.configured) {
+ osError = "OpenSubtitles staat uit in de admin"
+ } else if (res.results.isEmpty()) {
+ osError = "Geen ondertitels gevonden"
+ }
+ osHits = res.results
+ } catch (e: Exception) {
+ osError = e.message ?: "Zoeken mislukt"
+ } finally {
+ osLoading = false
+ }
+ }
+ }
+
+ fun pickOpenSubtitle(hit: SubtitleHit) {
+ if (osBusy) return
+ osBusy = true
+ osError = null
+ scope.launch {
+ try {
+ val fetched = api.fetchSubtitle(hit.fileId, hit.source)
+ val dir = File(context.cacheDir, "subs").also { it.mkdirs() }
+ val ext = when (fetched.format.lowercase()) {
+ "vtt" -> "vtt"
+ "ass", "ssa" -> "ass"
+ else -> "srt"
+ }
+ val file = File(dir, "os-${fetched.source}-${fetched.fileId}.$ext")
+ file.writeBytes(Base64.decode(fetched.contentBase64, Base64.DEFAULT))
+ val mime = when (ext) {
+ "vtt" -> MimeTypes.TEXT_VTT
+ "ass" -> MimeTypes.TEXT_SSA
+ else -> MimeTypes.APPLICATION_SUBRIP
+ }
+ val lang = hit.language.ifBlank { "nl" }
+ val label = buildList {
+ add(langLabel(lang))
+ hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(40)) }
+ if (hit.hearingImpaired) add("SDH")
+ }.joinToString(" · ")
+ player.applySideloadedSubtitle(
+ uri = android.net.Uri.fromFile(file),
+ mimeType = mime,
+ language = lang,
+ label = label,
+ )
+ menu = null
+ player.play()
+ showControls()
+ } catch (e: Exception) {
+ osError = e.message ?: "Download mislukt"
+ } finally {
+ osBusy = false
+ }
+ }
+ }
+
+ BackHandler {
+ when {
+ menu == "os" -> menu = "text"
+ menu != null -> menu = null
+ controls -> {
+ menu = null
+ controls = false
+ }
+ else -> onBack()
+ }
+ }
+
+ LaunchedEffect(mediaFileId) {
+ try {
+ val play = api.play(mediaFileId)
+ player.volume = 1f
+ player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
+ player.prepare()
+ if (startMs > 5_000) player.seekTo(startMs)
+ player.play()
+ } catch (e: Exception) {
+ error = e.message ?: "Afspelen mislukt"
+ loading = false
+ }
+ }
+
+ DisposableEffect(player) {
+ val listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(state: Int) {
+ loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
+ if (state == Player.STATE_READY) {
+ player.volume = 1f
+ player.ensureAudioSelected()
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ }
+ }
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ playing = isPlaying
+ }
+
+ override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
+ audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
+ textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
+ player.ensureAudioSelected()
+ player.volume = 1f
+ }
+
+ override fun onPlayerError(e: PlaybackException) {
+ error = e.message ?: "Spelerfout"
+ }
+ }
+ player.addListener(listener)
+ onDispose {
+ try {
+ runBlocking {
+ api.postProgress(
+ mediaType = mediaType,
+ movieId = movieId,
+ episodeId = episodeId,
+ seriesId = seriesId,
+ positionMs = player.currentPosition.coerceAtLeast(0),
+ durationMs = player.duration.coerceAtLeast(0),
+ )
+ }
+ } catch (_: Exception) {
+ }
+ player.removeListener(listener)
+ player.release()
+ }
+ }
+
+ LaunchedEffect(player) {
+ while (true) {
+ delay(400)
+ positionMs = player.currentPosition.coerceAtLeast(0)
+ durationMs = player.duration.coerceAtLeast(0)
+ }
+ }
+
+ LaunchedEffect(player) {
+ while (true) {
+ delay(12_000)
+ if (player.isPlaying) {
+ try {
+ api.postProgress(
+ mediaType = mediaType,
+ movieId = movieId,
+ episodeId = episodeId,
+ seriesId = seriesId,
+ positionMs = player.currentPosition.coerceAtLeast(0),
+ durationMs = player.duration.coerceAtLeast(0),
+ )
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(controls, menu, playing) {
+ if (controls && menu == null && playing) {
+ delay(4_500)
+ hideControls()
+ }
+ }
+
+ LaunchedEffect(controls, menu) {
+ delay(40)
+ if (menu != null) return@LaunchedEffect
+ if (controls) {
+ runCatching { controlsFocus.requestFocus() }
+ } else {
+ runCatching { catcherFocus.requestFocus() }
+ }
+ }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
+ if (menu != null) return@onPreviewKeyEvent false
+ if (!controls) {
+ // Netflix: OK toont bediening én pauzeert/hervat
+ togglePlay()
+ true
+ } else {
+ false
+ }
+ }
+ Key.DirectionDown -> {
+ if (menu != null) return@onPreviewKeyEvent false
+ if (!controls) {
+ showControls()
+ true
+ } else {
+ false
+ }
+ }
+ Key.DirectionLeft -> {
+ if (menu != null || controls) return@onPreviewKeyEvent false
+ player.seekBack()
+ showControls()
+ true
+ }
+ Key.DirectionRight -> {
+ if (menu != null || controls) return@onPreviewKeyEvent false
+ player.seekForward()
+ showControls()
+ true
+ }
+ Key.DirectionUp, Key.Menu -> {
+ if (menu != null) return@onPreviewKeyEvent false
+ showControls()
+ true
+ }
+ else -> false
+ }
+ },
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ this.player = player
+ useController = false
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
+ setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
+ keepScreenOn = true
+ isFocusable = false
+ isFocusableInTouchMode = false
+ descendantFocusability = FrameLayout.FOCUS_BLOCK_DESCENDANTS
+ }
+ },
+ update = { it.player = player },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ // Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet
+ if (!controls && menu == null && error == null) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .focusRequester(catcherFocus)
+ .focusable(),
+ )
+ }
+
+ if (loading && error == null) {
+ Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
+ }
+ if (error != null) {
+ Column(
+ Modifier.align(Alignment.Center),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(error!!, color = McColors.Danger, fontSize = 18.sp)
+ McButton("Terug", onClick = onBack)
+ }
+ }
+
+ AnimatedVisibility(
+ visible = controls && error == null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ // Top / bottom scrim zoals Netflix
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ listOf(
+ Color(0xCC000000),
+ Color.Transparent,
+ Color.Transparent,
+ Color(0xF2000000),
+ ),
+ ),
+ ),
+ )
+
+ // Terug linksboven
+ Row(
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(28.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NetflixTextButton("← Terug", onClick = onBack)
+ }
+
+ // Grote play/pause in het midden als gepauzeerd
+ if (!playing) {
+ Surface(
+ onClick = { togglePlay() },
+ modifier = Modifier.align(Alignment.Center),
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color(0x55FFFFFF),
+ focusedContainerColor = Color.White,
+ contentColor = Color.White,
+ focusedContentColor = Color.Black,
+ ),
+ ) {
+ Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) {
+ Text("▶", color = Color.Unspecified, fontSize = 36.sp)
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .padding(horizontal = 48.dp, vertical = 28.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(3.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(Color(0x44FFFFFF)),
+ ) {
+ val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
+ Box(
+ Modifier
+ .fillMaxWidth(pct)
+ .height(3.dp)
+ .background(McColors.Brand),
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
+ Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
+ }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .focusRequester(controlsFocus),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
+ NetflixIconButton("↺ 10") {
+ player.seekBack()
+ showControls()
+ }
+ NetflixIconButton("10 ↻") {
+ player.seekForward()
+ showControls()
+ }
+ Spacer(Modifier.weight(1f))
+ NetflixTextButton("Audio") {
+ menu = "audio"
+ showControls()
+ }
+ NetflixTextButton("Ondertiteling") {
+ menu = "text"
+ showControls()
+ }
+ }
+ }
+ }
+ }
+
+ if (menu != null) {
+ val isAudio = menu == "audio"
+ val isOs = menu == "os"
+ val tracks = if (isAudio) audioTracks else textTracks
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0x99000000)),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ Column(
+ Modifier
+ .fillMaxHeight()
+ .width(420.dp)
+ .background(Color(0xF20A0A0A))
+ .padding(horizontal = 28.dp, vertical = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ when {
+ isAudio -> "Audio"
+ isOs -> "OpenSubtitles"
+ else -> "Ondertiteling"
+ },
+ color = Color.White,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(12.dp))
+ if (isOs) {
+ if (osLoading) {
+ Text("Zoeken…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
+ }
+ if (osError != null) {
+ Text(osError!!, color = Color(0xFFFF6B6B), fontSize = 14.sp)
+ }
+ if (osBusy) {
+ Text("Bezig met downloaden…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
+ }
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(osHits, key = { "${it.source}-${it.fileId}" }) { hit ->
+ val rowLabel = buildList {
+ add(langLabel(hit.language))
+ hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(36)) }
+ if (hit.hearingImpaired) add("SDH")
+ add(hit.source.uppercase())
+ if (hit.downloadCount > 0) add("${hit.downloadCount}×")
+ }.joinToString(" · ")
+ NetflixTrackRow(rowLabel, selected = false) {
+ pickOpenSubtitle(hit)
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ NetflixTextButton("Terug") { menu = "text" }
+ } else {
+ if (!isAudio) {
+ NetflixTrackRow("Uit", selected = false) {
+ player.disableTrackType(C.TRACK_TYPE_TEXT)
+ menu = null
+ }
+ NetflixTrackRow("Zoeken op OpenSubtitles…", selected = false) {
+ openOpenSubtitles()
+ }
+ }
+ if (tracks.isEmpty() && isAudio) {
+ Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
+ }
+ if (tracks.isEmpty() && !isAudio) {
+ Text("Geen ingebouwde ondertitels", color = Color(0xFF8A8A8A), fontSize = 14.sp)
+ }
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
+ NetflixTrackRow(track.label, selected = false) {
+ player.selectTrack(track)
+ menu = null
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ NetflixTextButton("Sluiten") { menu = null }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun langLabel(code: String): String {
+ val c = code.trim().lowercase()
+ return when (c) {
+ "nl", "dut", "nld" -> "Nederlands"
+ "en", "eng" -> "Engels"
+ "de", "ger", "deu" -> "Duits"
+ "fr", "fre", "fra" -> "Frans"
+ "es", "spa" -> "Spaans"
+ else -> c.uppercase().ifBlank { "ONB" }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun NetflixIconButton(
+ label: String,
+ primary: Boolean = false,
+ onClick: () -> Unit,
+) {
+ Surface(
+ onClick = onClick,
+ shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.15f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (primary) Color.White else Color(0x22FFFFFF),
+ focusedContainerColor = Color.White,
+ contentColor = if (primary) Color.Black else Color.White,
+ focusedContentColor = Color.Black,
+ ),
+ ) {
+ Box(
+ Modifier.size(52.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun NetflixTextButton(label: String, onClick: () -> Unit) {
+ Surface(
+ onClick = onClick,
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color.Transparent,
+ focusedContainerColor = Color(0x33FFFFFF),
+ contentColor = Color(0xFFE5E5E5),
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Text(
+ label,
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
+ color = Color.Unspecified,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) {
+ Surface(
+ onClick = onClick,
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (selected) Color(0x22FFFFFF) else Color.Transparent,
+ focusedContainerColor = Color(0x33FFFFFF),
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ if (selected) "✓ " else " ",
+ color = McColors.Brand,
+ fontSize = 15.sp,
+ )
+ Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+}
+
+private fun formatTime(ms: Long): String {
+ if (ms <= 0) return "0:00"
+ val total = ms / 1000
+ val h = total / 3600
+ val m = (total % 3600) / 60
+ val s = total % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt
new file mode 100644
index 0000000..27e499b
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt
@@ -0,0 +1,159 @@
+package nl.vonas.mediacluster.tv.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+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.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.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.runtime.Composable
+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.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+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.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.MediaItem
+import nl.vonas.mediacluster.tv.ui.components.PosterCard
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun SearchScreen(
+ api: ApiClient,
+ onOpenMovie: (String) -> Unit,
+ onOpenSeries: (String) -> Unit,
+ onAtTopBack: (() -> Unit)? = null,
+) {
+ var query by remember { mutableStateOf("") }
+ var results by remember { mutableStateOf>(emptyList()) }
+ var busy by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+ var searchJob by remember { mutableStateOf(null) }
+ val gridState = rememberLazyGridState()
+ val focusManager = LocalFocusManager.current
+
+ if (onAtTopBack != null) {
+ BackHandler {
+ val scrolled = gridState.firstVisibleItemIndex > 0 ||
+ gridState.firstVisibleItemScrollOffset > 0
+ if (scrolled) {
+ scope.launch { gridState.animateScrollToItem(0) }
+ } else {
+ onAtTopBack()
+ }
+ }
+ }
+
+ fun scheduleSearch(next: String) {
+ query = next
+ searchJob?.cancel()
+ searchJob = scope.launch {
+ delay(250)
+ if (next.isBlank()) {
+ results = emptyList()
+ return@launch
+ }
+ busy = true
+ error = null
+ try {
+ results = api.search(next)
+ } catch (e: Exception) {
+ error = e.message
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(McColors.Bg)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ "Zoeken",
+ color = Color.White,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(10.dp))
+ OutlinedTextField(
+ value = query,
+ onValueChange = { scheduleSearch(it) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ placeholder = { Text("Typ een titel…", color = McColors.TextDim) },
+ shape = RoundedCornerShape(12.dp),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = McColors.Brand,
+ unfocusedBorderColor = Color(0x44FFFFFF),
+ cursorColor = McColors.Brand,
+ focusedContainerColor = Color(0x22FFFFFF),
+ unfocusedContainerColor = Color(0x14FFFFFF),
+ ),
+ )
+ Spacer(Modifier.height(12.dp))
+ when {
+ error != null -> Text(error!!, color = McColors.Danger, fontSize = 14.sp)
+ busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = 14.sp)
+ query.isNotBlank() && results.isEmpty() ->
+ Text("Geen resultaten", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 110.dp),
+ state = gridState,
+ contentPadding = PaddingValues(bottom = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(results, key = { "${it.type}-${it.id}" }) { item ->
+ PosterCard(
+ item = item,
+ width = 110.dp,
+ height = 165.dp,
+ onClick = {
+ when (item.type) {
+ "movie" -> onOpenMovie(item.id)
+ else -> onOpenSeries(item.id)
+ }
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt
new file mode 100644
index 0000000..13e2f54
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt
@@ -0,0 +1,621 @@
+package nl.vonas.mediacluster.tv.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+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.fillMaxHeight
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.EpgProgram
+import nl.vonas.mediacluster.tv.data.IptvCategory
+import nl.vonas.mediacluster.tv.data.IptvChannel
+import nl.vonas.mediacluster.tv.data.IptvEpg
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+private val TvRowBg = Color(0xFF141414)
+private val TvRowFocus = Color(0xFF242424)
+private val TvRowActive = Color(0xFF1C1C1C)
+private val TvDivider = Color(0xFF2A2A2A)
+private val TvPanelBg = Color(0xFF101010)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun TvScreen(
+ api: ApiClient,
+ selectedCategoryId: String?,
+ onCategoryChange: (String?) -> Unit,
+ lastStreamId: Int? = null,
+ onLastStreamChange: (Int?) -> Unit = {},
+ focusContentNonce: Int = 0,
+ onAtTopBack: () -> Unit,
+ onPlayChannel: (streamId: Int) -> Unit,
+ onContentFocused: () -> Unit = {},
+) {
+ var categories by remember { mutableStateOf>(emptyList()) }
+ var channels by remember { mutableStateOf>(emptyList()) }
+ var loading by remember { mutableStateOf(true) }
+ var loadingChannels by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var focusedEpg by remember { mutableStateOf(null) }
+ var focusedStreamId by remember { mutableStateOf(null) }
+ var epgJob by remember { mutableStateOf(null) }
+ var focusInList by remember { mutableStateOf(false) }
+ val channelListState = rememberLazyListState()
+ val categoryRowState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+ val categoryFocus = remember { mutableStateMapOf() }
+ val channelFocus = remember { mutableStateMapOf() }
+ val epgNowTitles = remember { mutableStateMapOf() }
+
+ LaunchedEffect(Unit) {
+ loading = true
+ error = null
+ try {
+ val cats = api.iptvCategories()
+ categories = cats
+ val restored = selectedCategoryId?.takeIf { id -> cats.any { it.id == id } }
+ if (restored != null) {
+ onCategoryChange(restored)
+ } else {
+ onCategoryChange(cats.firstOrNull()?.id)
+ }
+ } catch (e: Exception) {
+ error = e.message ?: "Kon TV niet laden"
+ } finally {
+ loading = false
+ }
+ }
+
+ LaunchedEffect(selectedCategoryId) {
+ val catId = selectedCategoryId ?: return@LaunchedEffect
+ loadingChannels = true
+ focusedEpg = null
+ focusedStreamId = null
+ epgNowTitles.clear()
+ try {
+ val loaded = api.iptvChannels(catId, includeEpg = true)
+ val titles = api.fetchMissingNowTitles(loaded)
+ channels = loaded
+ titles.forEach { (id, title) -> epgNowTitles[id] = title }
+ val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
+ ?: loaded.firstOrNull()?.streamId
+ focusId?.let { id ->
+ focusedStreamId = id
+ runCatching { focusedEpg = api.iptvEpg(id) }
+ }
+ } catch (e: Exception) {
+ error = e.message
+ channels = emptyList()
+ } finally {
+ loadingChannels = false
+ }
+ }
+
+ LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) {
+ if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect
+ delay(100)
+ val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
+ ?: channels.first().streamId
+ val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
+ channelListState.scrollToItem(idx)
+ delay(80)
+ channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
+ focusedStreamId = targetId
+ }
+
+ LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
+ if (loading || categories.isEmpty()) return@LaunchedEffect
+ delay(120)
+ val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
+ if (channelTarget != null) {
+ val idx = channels.indexOfFirst { it.streamId == channelTarget }
+ if (idx >= 0) channelListState.scrollToItem(idx)
+ channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } }
+ focusInList = true
+ focusedStreamId = channelTarget
+ runCatching {
+ val epg = api.iptvEpg(channelTarget)
+ focusedEpg = epg
+ epg.now?.title?.let { epgNowTitles[channelTarget] = it }
+ }
+ } else {
+ val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
+ val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
+ categoryRowState.scrollToItem(catIdx)
+ categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
+ focusInList = false
+ }
+ }
+
+ BackHandler(enabled = focusInList) {
+ val catId = selectedCategoryId ?: categories.firstOrNull()?.id
+ if (catId != null) {
+ categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
+ focusInList = false
+ }
+ }
+ BackHandler(enabled = !focusInList) {
+ onAtTopBack()
+ }
+
+ fun loadEpg(streamId: Int) {
+ focusedStreamId = streamId
+ epgJob?.cancel()
+ epgJob = scope.launch {
+ delay(200)
+ runCatching {
+ val epg = api.iptvEpg(streamId)
+ focusedEpg = epg
+ epg.now?.title?.let { epgNowTitles[streamId] = it }
+ }
+ }
+ }
+
+ Box(Modifier.fillMaxSize().background(McColors.Bg)) {
+ when {
+ loading -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("TV laden…", color = McColors.TextDim, fontSize = 15.sp)
+ }
+ }
+ error != null && categories.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("TV niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
+ Text(error!!, color = McColors.Danger, fontSize = 14.sp)
+ }
+ }
+ }
+ else -> {
+ Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) {
+ CategoryTabRow(
+ categories = categories,
+ selectedId = selectedCategoryId,
+ listState = categoryRowState,
+ categoryFocus = categoryFocus,
+ onSelect = {
+ onCategoryChange(it)
+ focusInList = true
+ },
+ onFocused = {
+ focusInList = false
+ onContentFocused()
+ },
+ )
+
+ Spacer(Modifier.height(8.dp))
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(TvDivider),
+ )
+ Spacer(Modifier.height(4.dp))
+
+ Row(Modifier.weight(1f).fillMaxWidth()) {
+ Column(Modifier.weight(0.58f).fillMaxHeight()) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
+ Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
+ }
+ when {
+ loadingChannels -> {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ channels.isEmpty() -> {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ else -> {
+ LazyColumn(
+ state = channelListState,
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(bottom = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ ) {
+ items(channels, key = { it.streamId }) { ch ->
+ val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
+ ChannelRow(
+ channel = ch,
+ nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
+ isFocused = focusedStreamId == ch.streamId,
+ isLastWatched = ch.streamId == lastStreamId,
+ modifier = Modifier.focusRequester(fr),
+ onClick = {
+ onLastStreamChange(ch.streamId)
+ onPlayChannel(ch.streamId)
+ },
+ onFocused = {
+ focusInList = true
+ onContentFocused()
+ onLastStreamChange(ch.streamId)
+ loadEpg(ch.streamId)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Box(
+ Modifier
+ .width(1.dp)
+ .fillMaxHeight()
+ .padding(vertical = 4.dp)
+ .background(TvDivider),
+ )
+
+ ChannelDetailPanel(
+ epg = focusedEpg,
+ modifier = Modifier
+ .weight(0.42f)
+ .fillMaxHeight()
+ .padding(start = 16.dp, end = 8.dp, top = 4.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun CategoryTabRow(
+ categories: List,
+ selectedId: String?,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ categoryFocus: MutableMap,
+ onSelect: (String) -> Unit,
+ onFocused: () -> Unit,
+) {
+ LazyRow(
+ state = listState,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = PaddingValues(vertical = 4.dp),
+ ) {
+ items(categories, key = { it.id }) { cat ->
+ val active = cat.id == selectedId
+ val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
+ val textColor by animateColorAsState(
+ if (active) Color.White else Color(0xFF888888),
+ label = "tabText",
+ )
+ Surface(
+ onClick = { onSelect(cat.id) },
+ modifier = Modifier
+ .focusRequester(fr)
+ .onFocusChanged { if (it.isFocused) onFocused() },
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color.Transparent,
+ focusedContainerColor = Color(0x18FFFFFF),
+ contentColor = textColor,
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Column(
+ Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ cat.name,
+ color = Color.Unspecified,
+ fontSize = 14.sp,
+ fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(5.dp))
+ Box(
+ Modifier
+ .height(2.dp)
+ .width(if (active) 24.dp else 0.dp)
+ .clip(RoundedCornerShape(1.dp))
+ .background(if (active) McColors.Brand else Color.Transparent),
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ChannelRow(
+ channel: IptvChannel,
+ nowTitle: String?,
+ isFocused: Boolean,
+ isLastWatched: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onFocused: () -> Unit,
+) {
+ val bg by animateColorAsState(
+ when {
+ isFocused -> TvRowFocus
+ isLastWatched -> TvRowActive
+ else -> TvRowBg
+ },
+ label = "rowBg",
+ )
+ Surface(
+ onClick = onClick,
+ modifier = modifier.onFocusChanged { state ->
+ if (state.isFocused) onFocused()
+ },
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = bg,
+ focusedContainerColor = TvRowFocus,
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier
+ .width(3.dp)
+ .height(28.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(
+ when {
+ isFocused -> McColors.Brand
+ isLastWatched -> McColors.Brand.copy(alpha = 0.45f)
+ else -> Color.Transparent
+ },
+ ),
+ )
+ Spacer(Modifier.width(6.dp))
+ Box(
+ Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color(0xFF0A0A0A)),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (!channel.logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = channel.logoUrl,
+ contentDescription = channel.name,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(4.dp),
+ contentScale = ContentScale.Fit,
+ )
+ } else {
+ Text(
+ channel.name.take(2).uppercase(),
+ color = McColors.TextDim,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ Spacer(Modifier.width(10.dp))
+ channel.number?.let { num ->
+ Text(
+ "$num",
+ color = McColors.TextDim,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.width(36.dp),
+ )
+ }
+ Text(
+ channel.name,
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.width(140.dp),
+ )
+ Text(
+ nowTitle ?: "",
+ color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim,
+ fontSize = 13.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ChannelDetailPanel(
+ epg: IptvEpg?,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier
+ .clip(RoundedCornerShape(6.dp))
+ .background(TvPanelBg)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (epg == null) {
+ Spacer(Modifier.weight(1f))
+ Text(
+ "Selecteer een zender",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ Spacer(Modifier.weight(1f))
+ return
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (!epg.logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = epg.logoUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color(0xFF0A0A0A)),
+ contentScale = ContentScale.Fit,
+ )
+ }
+ Column {
+ Text(
+ epg.channelName,
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
+
+ epg.now?.let { now ->
+ EpgBlock(label = "Nu", program = now, highlight = true)
+ val progress = epgProgress(now)
+ if (progress > 0f) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(2.dp)
+ .clip(RoundedCornerShape(1.dp))
+ .background(Color(0xFF333333)),
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth(progress)
+ .height(2.dp)
+ .background(McColors.Brand),
+ )
+ }
+ }
+ }
+
+ epg.next?.let { next ->
+ EpgBlock(label = "Straks", program = next, highlight = false)
+ }
+
+ if (epg.now == null && epg.next == null) {
+ Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp)
+ }
+
+ Spacer(Modifier.weight(1f))
+ Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp)
+ }
+}
+
+@Composable
+private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ label.uppercase(),
+ color = if (highlight) McColors.Brand else McColors.TextDim,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ )
+ formatEpgTime(program.start, program.end)?.let { time ->
+ Text(time, color = McColors.TextDim, fontSize = 12.sp)
+ }
+ }
+ Text(
+ program.title,
+ color = if (highlight) Color.White else Color(0xFFBBBBBB),
+ fontSize = if (highlight) 15.sp else 14.sp,
+ fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 20.sp,
+ )
+ }
+}
+
+private fun formatEpgTime(start: String?, end: String?): String? {
+ val fmt = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
+ val s = start?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() }
+ val e = end?.let { runCatching { fmt.format(Instant.parse(it)) }.getOrNull() }
+ return when {
+ s != null && e != null -> "$s – $e"
+ s != null -> s
+ else -> null
+ }
+}
+
+private fun epgProgress(program: EpgProgram): Float {
+ val start = program.start?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f
+ val end = program.end?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } ?: return 0f
+ if (end <= start) return 0f
+ val now = System.currentTimeMillis()
+ return ((now - start).toFloat() / (end - start).toFloat()).coerceIn(0f, 1f)
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt
new file mode 100644
index 0000000..36b2566
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt
@@ -0,0 +1,177 @@
+package nl.vonas.mediacluster.tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Border
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Glow
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import nl.vonas.mediacluster.tv.data.MediaItem
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun McButton(
+ label: String,
+ modifier: Modifier = Modifier,
+ primary: Boolean = false,
+ focusRequester: FocusRequester? = null,
+ onClick: () -> Unit,
+) {
+ val bg = if (primary) Color.White else Color(0x33FFFFFF)
+ val bgFocus = if (primary) Color.White else Color.White
+ val fg = if (primary) Color.Black else Color.White
+ val fgFocus = Color.Black
+ Surface(
+ onClick = onClick,
+ modifier = modifier.then(
+ if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
+ ),
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
+ glow = ClickableSurfaceDefaults.glow(
+ focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
+ ),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = bg,
+ focusedContainerColor = bgFocus,
+ contentColor = fg,
+ focusedContentColor = fgFocus,
+ ),
+ ) {
+ Text(
+ text = label.ifBlank { "—" },
+ modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
+ color = Color.Unspecified,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+private fun Key.isTvSelectKey(): Boolean =
+ this == Key.DirectionCenter || this == Key.Enter || this == Key.NumPadEnter
+
+@Composable
+private fun Modifier.tvShortOrLongPress(
+ onClick: () -> Unit,
+ onLongPress: () -> Unit,
+): Modifier {
+ var downAt by remember { mutableLongStateOf(0L) }
+ return this.onPreviewKeyEvent { ev ->
+ when {
+ ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
+ downAt = System.currentTimeMillis()
+ false
+ }
+ ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
+ val held = System.currentTimeMillis() - downAt
+ if (held >= 450) onLongPress() else onClick()
+ true
+ }
+ else -> false
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun PosterCard(
+ item: MediaItem,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ width: Dp = 110.dp,
+ height: Dp = 165.dp,
+ onLongClick: (() -> Unit)? = null,
+) {
+ val progress = if (item.durationMs > 0) {
+ (item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
+ } else {
+ 0f
+ }
+
+ val useKeyHandler = onLongClick != null
+ Surface(
+ onClick = if (useKeyHandler) ({}) else onClick,
+ onLongClick = null,
+ modifier = modifier
+ .width(width)
+ .then(
+ if (useKeyHandler) {
+ Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!)
+ } else {
+ Modifier
+ },
+ ),
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
+ border = ClickableSurfaceDefaults.border(
+ focusedBorder = Border(
+ border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),
+ shape = RoundedCornerShape(4.dp),
+ ),
+ ),
+ glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color(0xFF1A1A1A),
+ focusedContainerColor = Color(0xFF1A1A1A),
+ ),
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(height)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color(0xFF1A1A1A)),
+ ) {
+ AsyncImage(
+ model = item.posterUrl ?: item.backdropUrl,
+ contentDescription = item.title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ if (progress > 0.02f) {
+ Box(
+ Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth(progress)
+ .height(4.dp)
+ .background(McColors.Brand),
+ )
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt
new file mode 100644
index 0000000..23665f8
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/QualityBadges.kt
@@ -0,0 +1,338 @@
+package nl.vonas.mediacluster.tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+
+/** Resolutie als primaire “logo”-identiteit van een versie. */
+enum class ResolutionLogo {
+ UHD_4K,
+ FHD_1080,
+ HD_720,
+ SD,
+}
+
+/** Extra beeld/geluid — alleen herkenbare logo’s, geen codecs. */
+enum class FeatureLogo {
+ DOLBY_VISION,
+ HDR,
+ ATMOS,
+ DTS_X,
+}
+
+data class MediaVersion(
+ val id: String,
+ val resolution: ResolutionLogo?,
+ val features: List,
+) {
+ val shortLabel: String
+ get() = when (resolution) {
+ ResolutionLogo.UHD_4K -> "4K"
+ ResolutionLogo.FHD_1080 -> "1080p"
+ ResolutionLogo.HD_720 -> "720p"
+ ResolutionLogo.SD -> "SD"
+ null -> if (features.isNotEmpty()) features.first().let {
+ when (it) {
+ FeatureLogo.ATMOS -> "Atmos"
+ FeatureLogo.DTS_X -> "DTS:X"
+ FeatureLogo.DOLBY_VISION -> "Vision"
+ FeatureLogo.HDR -> "HDR"
+ }
+ } else "Versie"
+ }
+
+ val rank: Int
+ get() = when (resolution) {
+ ResolutionLogo.UHD_4K -> 400
+ ResolutionLogo.FHD_1080 -> 300
+ ResolutionLogo.HD_720 -> 200
+ ResolutionLogo.SD -> 100
+ null -> 0
+ } + features.size * 10
+}
+
+fun parseMediaVersion(
+ id: String,
+ resolution: String? = null,
+ hdrType: String? = null,
+ audioCodec: String? = null,
+ videoCodec: String? = null,
+ releaseName: String? = null,
+ label: String? = null,
+): MediaVersion {
+ val hay = listOf(resolution, hdrType, audioCodec, videoCodec, releaseName, label)
+ .mapNotNull { it?.trim()?.takeIf(String::isNotEmpty) }
+ .joinToString(" ")
+
+ return MediaVersion(
+ id = id,
+ resolution = parseResolutionLogo(hay),
+ features = parseFeatureLogos(hay),
+ )
+}
+
+fun parseResolutionLogo(hay: String): ResolutionLogo? {
+ if (hay.isBlank()) return null
+ return when {
+ Regex("""(?:^|[^\d])(2160\s*p?|3840\s*[x×]\s*2160|4\s*k|uhd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE)
+ .containsMatchIn(hay) -> ResolutionLogo.UHD_4K
+ Regex("""(?:^|[^\d])(1080\s*p?|1920\s*[x×]\s*1080|fhd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE)
+ .containsMatchIn(hay) -> ResolutionLogo.FHD_1080
+ Regex("""(?:^|[^\d])(720\s*p?|1280\s*[x×]\s*720)(?:[^\d]|$)""", RegexOption.IGNORE_CASE)
+ .containsMatchIn(hay) -> ResolutionLogo.HD_720
+ Regex("""(?:^|[^\d])(480\s*p?|576\s*p?|sd)(?:[^\d]|$)""", RegexOption.IGNORE_CASE)
+ .containsMatchIn(hay) -> ResolutionLogo.SD
+ else -> null
+ }
+}
+
+fun parseFeatureLogos(hay: String): List {
+ if (hay.isBlank()) return emptyList()
+ val out = linkedSetOf()
+ when {
+ Regex("""dolby\s*vision|\bdovi\b|\bdv\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) ->
+ out += FeatureLogo.DOLBY_VISION
+ Regex("""hdr10\+?|\bhdr\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) ->
+ out += FeatureLogo.HDR
+ }
+ when {
+ Regex("""atmos""", RegexOption.IGNORE_CASE).containsMatchIn(hay) ->
+ out += FeatureLogo.ATMOS
+ Regex("""dts[\s.:_-]?x\b""", RegexOption.IGNORE_CASE).containsMatchIn(hay) ->
+ out += FeatureLogo.DTS_X
+ }
+ return out.toList()
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun SelectedQualityLine(
+ version: MediaVersion?,
+ modifier: Modifier = Modifier,
+) {
+ if (version == null) return
+ val logos = buildList {
+ version.resolution?.let(::add)
+ addAll(version.features)
+ }
+ if (logos.isEmpty()) return
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ version.resolution?.let { ResolutionLogoChip(it, compact = true) }
+ version.features.forEach { FeatureLogoChip(it, compact = true) }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun ResolutionLogoChip(
+ logo: ResolutionLogo,
+ compact: Boolean = false,
+ emphasized: Boolean = false,
+) {
+ val h = if (compact) 20.dp else 26.dp
+ val shape = RoundedCornerShape(3.dp)
+ when (logo) {
+ ResolutionLogo.UHD_4K -> LogoPlate(
+ text = "4K",
+ height = h,
+ bg = if (emphasized) Color.White else Color(0xFFF2F2F2),
+ fg = Color.Black,
+ weight = FontWeight.Black,
+ size = if (compact) 11.sp else 13.sp,
+ tracking = 0.8.sp,
+ shape = shape,
+ minWidth = if (compact) 32.dp else 40.dp,
+ )
+ ResolutionLogo.FHD_1080 -> LogoPlate(
+ text = "1080p",
+ height = h,
+ bg = Color(0xFF1A1A1A),
+ fg = Color.White,
+ weight = FontWeight.Bold,
+ size = if (compact) 10.sp else 12.sp,
+ tracking = 0.2.sp,
+ shape = shape,
+ border = Color(0xAAFFFFFF),
+ minWidth = if (compact) 44.dp else 54.dp,
+ )
+ ResolutionLogo.HD_720 -> LogoPlate(
+ text = "720p",
+ height = h,
+ bg = Color.Transparent,
+ fg = Color(0xFFE0E0E0),
+ weight = FontWeight.SemiBold,
+ size = if (compact) 10.sp else 12.sp,
+ tracking = 0.2.sp,
+ shape = shape,
+ border = Color(0x77FFFFFF),
+ minWidth = if (compact) 40.dp else 48.dp,
+ )
+ ResolutionLogo.SD -> LogoPlate(
+ text = "SD",
+ height = h,
+ bg = Color.Transparent,
+ fg = Color(0xFFBDBDBD),
+ weight = FontWeight.SemiBold,
+ size = if (compact) 10.sp else 12.sp,
+ tracking = 0.4.sp,
+ shape = shape,
+ border = Color(0x55FFFFFF),
+ minWidth = if (compact) 28.dp else 34.dp,
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun FeatureLogoChip(
+ logo: FeatureLogo,
+ compact: Boolean = false,
+) {
+ val h = if (compact) 20.dp else 22.dp
+ val shape = RoundedCornerShape(3.dp)
+ when (logo) {
+ FeatureLogo.DOLBY_VISION -> Box(
+ Modifier
+ .height(h)
+ .widthIn(min = if (compact) 40.dp else 48.dp)
+ .clip(shape)
+ .background(Brush.horizontalGradient(listOf(Color(0xFF4A1FFF), Color(0xFFB44CFF))))
+ .padding(horizontal = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ "VISION",
+ color = Color.White,
+ fontSize = if (compact) 8.sp else 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.7.sp,
+ )
+ }
+ FeatureLogo.HDR -> LogoPlate(
+ text = "HDR",
+ height = h,
+ bg = Color(0xFFE8A317),
+ fg = Color.Black,
+ weight = FontWeight.Black,
+ size = if (compact) 9.sp else 10.sp,
+ tracking = 0.5.sp,
+ shape = shape,
+ minWidth = if (compact) 34.dp else 38.dp,
+ )
+ FeatureLogo.ATMOS -> LogoPlate(
+ text = "ATMOS",
+ height = h,
+ bg = Color(0xFF0E0E0E),
+ fg = Color.White,
+ weight = FontWeight.Bold,
+ size = if (compact) 8.sp else 9.sp,
+ tracking = 1.0.sp,
+ shape = shape,
+ border = Color(0xFF9A9A9A),
+ minWidth = if (compact) 46.dp else 52.dp,
+ )
+ FeatureLogo.DTS_X -> LogoPlate(
+ text = "DTS:X",
+ height = h,
+ bg = Color(0xFF0B3D91),
+ fg = Color.White,
+ weight = FontWeight.Bold,
+ size = if (compact) 8.sp else 9.sp,
+ tracking = 0.2.sp,
+ shape = shape,
+ minWidth = if (compact) 40.dp else 46.dp,
+ )
+ }
+}
+
+@Composable
+private fun LogoPlate(
+ text: String,
+ height: Dp,
+ bg: Color,
+ fg: Color,
+ weight: FontWeight,
+ size: TextUnit,
+ tracking: TextUnit,
+ shape: RoundedCornerShape,
+ border: Color? = null,
+ minWidth: Dp = 0.dp,
+) {
+ Box(
+ Modifier
+ .height(height)
+ .widthIn(min = minWidth)
+ .clip(shape)
+ .background(bg)
+ .then(
+ if (border != null) Modifier.border(1.dp, border, shape) else Modifier,
+ )
+ .padding(horizontal = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text,
+ color = fg,
+ fontSize = size,
+ fontWeight = weight,
+ letterSpacing = tracking,
+ maxLines = 1,
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun VersionQualityMark(
+ version: MediaVersion,
+ selected: Boolean,
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ if (version.resolution != null) {
+ ResolutionLogoChip(version.resolution, compact = false, emphasized = selected)
+ } else {
+ Text(
+ "?",
+ color = Color(0xFFAAAAAA),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ if (version.features.isNotEmpty()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ version.features.take(2).forEach { FeatureLogoChip(it, compact = true) }
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt
new file mode 100644
index 0000000..b21f90c
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt
@@ -0,0 +1,150 @@
+package nl.vonas.mediacluster.tv.ui.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.graphics.vector.ImageVector
+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 nl.vonas.mediacluster.tv.ui.theme.McColors
+
+enum class MainTab(val label: String) {
+ Home("Home"),
+ Movies("Films"),
+ Series("Series"),
+ Tv("TV"),
+ Search("Zoeken"),
+ Settings("Instellingen"),
+}
+
+data class SubNavItem(
+ val id: String,
+ val label: String,
+ val catalogId: String = id,
+)
+
+private fun MainTab.icon(): ImageVector = when (this) {
+ MainTab.Home -> Icons.Filled.Home
+ MainTab.Movies -> Icons.Filled.PlayArrow
+ MainTab.Series -> Icons.Filled.Menu
+ MainTab.Tv -> Icons.Filled.Star
+ MainTab.Search -> Icons.Filled.Search
+ MainTab.Settings -> Icons.Filled.Settings
+}
+
+/** Phone bottom navigation (kept name TopNavBar for McNav compatibility). */
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun TopNavBar(
+ selected: MainTab,
+ onSelect: (MainTab) -> Unit,
+ visibleTabs: List = MainTab.entries,
+ subItems: List = emptyList(),
+ selectedSubId: String? = null,
+ onSelectSub: (SubNavItem) -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier
+ .fillMaxWidth()
+ .background(McColors.BgElevated)
+ .navigationBarsPadding(),
+ ) {
+ if (subItems.isNotEmpty()) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ subItems.forEach { item ->
+ val active = item.id == selectedSubId
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(if (active) Color.White else Color(0x22FFFFFF))
+ .clickable { onSelectSub(item) }
+ .padding(horizontal = 14.dp, vertical = 8.dp),
+ ) {
+ Text(
+ item.label,
+ color = if (active) Color.Black else Color(0xFFE0E0E0),
+ fontSize = 13.sp,
+ fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
+ )
+ }
+ }
+ }
+ }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ visibleTabs.forEach { tab ->
+ val active = selected == tab
+ val color by animateColorAsState(
+ if (active) McColors.Brand else Color(0xFF808080),
+ label = "tabColor",
+ )
+ Column(
+ Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(10.dp))
+ .clickable { onSelect(tab) }
+ .padding(vertical = 6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = tab.icon(),
+ contentDescription = tab.label,
+ tint = color,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ tab.label,
+ color = color,
+ fontSize = 10.sp,
+ fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt
new file mode 100644
index 0000000..b0a9501
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt
@@ -0,0 +1,25 @@
+package nl.vonas.mediacluster.tv.ui.theme
+
+object McColors {
+ // Netflix-achtige basis: bijna zwart, weinig blauw “dashboard”
+ val Bg = androidx.compose.ui.graphics.Color(0xFF0B0B0B)
+ val BgElevated = androidx.compose.ui.graphics.Color(0xFF141414)
+ val Surface = androidx.compose.ui.graphics.Color(0xFF1F1F1F)
+ val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFFFFFFFF)
+ val Accent = androidx.compose.ui.graphics.Color(0xFFE50914)
+ val AccentSoft = androidx.compose.ui.graphics.Color(0x33E50914)
+ val Brand = androidx.compose.ui.graphics.Color(0xFFE50914)
+ val Text = androidx.compose.ui.graphics.Color(0xFFF5F5F5)
+ val TextDim = androidx.compose.ui.graphics.Color(0xFFB3B3B3)
+ val Danger = androidx.compose.ui.graphics.Color(0xFFFF6B6B)
+ val Success = androidx.compose.ui.graphics.Color(0xFF46D369)
+
+ val HeroScrim = androidx.compose.ui.graphics.Brush.verticalGradient(
+ colors = listOf(
+ androidx.compose.ui.graphics.Color.Transparent,
+ androidx.compose.ui.graphics.Color(0x660B0B0B),
+ androidx.compose.ui.graphics.Color(0xEE0B0B0B),
+ androidx.compose.ui.graphics.Color(0xFF0B0B0B),
+ ),
+ )
+}
diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt
new file mode 100644
index 0000000..0fc94a0
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt
@@ -0,0 +1,20 @@
+package nl.vonas.mediacluster.tv.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.darkColorScheme
+
+private val Scheme = darkColorScheme(
+ primary = McColors.Accent,
+ onPrimary = McColors.Bg,
+ surface = McColors.BgElevated,
+ onSurface = McColors.Text,
+ border = McColors.AccentSoft,
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun MediaClusterTheme(content: @Composable () -> Unit) {
+ MaterialTheme(colorScheme = Scheme, content = content)
+}
diff --git a/apps/android-tv/app-mobile/src/main/res/drawable/app_banner.png b/apps/android-tv/app-mobile/src/main/res/drawable/app_banner.png
new file mode 100644
index 0000000..8fe269b
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/drawable/app_banner.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/drawable/ic_launcher_fg.png b/apps/android-tv/app-mobile/src/main/res/drawable/ic_launcher_fg.png
new file mode 100644
index 0000000..2e75431
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/drawable/ic_launcher_fg.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/drawable/player_controls_bg.xml b/apps/android-tv/app-mobile/src/main/res/drawable/player_controls_bg.xml
new file mode 100644
index 0000000..c35cec7
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/res/drawable/player_controls_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/apps/android-tv/app-mobile/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android-tv/app-mobile/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..4bfc9e8
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android-tv/app-mobile/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..9fde83d
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android-tv/app-mobile/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..1ca99c0
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android-tv/app-mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..7228414
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android-tv/app-mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..2e75431
Binary files /dev/null and b/apps/android-tv/app-mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/apps/android-tv/app-mobile/src/main/res/values/colors.xml b/apps/android-tv/app-mobile/src/main/res/values/colors.xml
new file mode 100644
index 0000000..00d8956
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #0B0B0B
+
diff --git a/apps/android-tv/app-mobile/src/main/res/values/strings.xml b/apps/android-tv/app-mobile/src/main/res/values/strings.xml
new file mode 100644
index 0000000..458e427
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ VMC
+
diff --git a/apps/android-tv/app-mobile/src/main/res/values/themes.xml b/apps/android-tv/app-mobile/src/main/res/values/themes.xml
new file mode 100644
index 0000000..376d16b
--- /dev/null
+++ b/apps/android-tv/app-mobile/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts
index c7e0f2c..e30c29f 100644
--- a/apps/android-tv/app/build.gradle.kts
+++ b/apps/android-tv/app/build.gradle.kts
@@ -1,9 +1,17 @@
+import java.util.Properties
+
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
+val keystorePropertiesFile = rootProject.file("keystore.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(keystorePropertiesFile.inputStream())
+}
+
android {
namespace = "nl.vonas.mediacluster.tv"
compileSdk = 36
@@ -11,15 +19,26 @@ android {
defaultConfig {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
- targetSdk = 35
- versionCode = 41
- versionName = "0.11.9"
+ targetSdk = 36
+ versionCode = 43
+ versionName = "0.12.1"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
+ signingConfigs {
+ if (keystorePropertiesFile.exists()) {
+ create("release") {
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+ }
+
buildTypes {
release {
isMinifyEnabled = false
@@ -27,6 +46,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ if (keystorePropertiesFile.exists()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
}
}
diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties
index 8600c3a..071f00d 100644
--- a/apps/android-tv/gradle.properties
+++ b/apps/android-tv/gradle.properties
@@ -1,10 +1,10 @@
-org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:CICompilerCount=1 -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.parallel=false
org.gradle.workers.max=1
org.gradle.caching=true
-org.gradle.daemon=true
+org.gradle.daemon=false
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
-kotlin.compiler.execution.strategy=in-process
+kotlin.compiler.execution.strategy=daemon
kotlin.incremental=true
diff --git a/apps/android-tv/play-store/play-feature-graphic-1024x500.png b/apps/android-tv/play-store/play-feature-graphic-1024x500.png
new file mode 100644
index 0000000..9c5efdc
Binary files /dev/null and b/apps/android-tv/play-store/play-feature-graphic-1024x500.png differ
diff --git a/apps/android-tv/play-store/play-icon-512.png b/apps/android-tv/play-store/play-icon-512.png
new file mode 100644
index 0000000..4aa657d
Binary files /dev/null and b/apps/android-tv/play-store/play-icon-512.png differ
diff --git a/apps/android-tv/settings.gradle.kts b/apps/android-tv/settings.gradle.kts
index cc173c6..9385549 100644
--- a/apps/android-tv/settings.gradle.kts
+++ b/apps/android-tv/settings.gradle.kts
@@ -14,3 +14,4 @@ dependencyResolutionManagement {
}
rootProject.name = "VMC"
include(":app")
+include(":app-mobile")