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