Add Android phone/tablet app module alongside the TV client.

Separate applicationId for Play listings; touch bottom-nav and soft-keyboard search while sharing the same cluster API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-04 21:07:27 +02:00
parent 24e56b2343
commit a5e8e18b75
37 changed files with 6172 additions and 6 deletions

View file

@ -3,10 +3,15 @@
/.kotlin/ /.kotlin/
**/build/ **/build/
local.properties local.properties
keystore.properties
keystore/
*.jks
*.keystore
*.iml *.iml
.DS_Store .DS_Store
captures/ captures/
*.apk *.apk
*.aab *.aab
dist/
hs_err_pid* hs_err_pid*
replay_pid* replay_pid*

View file

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

View file

@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="true" />
<application
android:name="nl.vonas.mediacluster.tv.McApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MediaClusterTv"
android:usesCleartextTraffic="false">
<activity
android:name="nl.vonas.mediacluster.tv.MainActivity"
android:exported="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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()
}
}
}
}

View file

@ -0,0 +1,5 @@
package nl.vonas.mediacluster.tv
import android.app.Application
class McApp : Application()

View file

@ -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<MediaItem> = 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<ShelfPref> = 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<ShelfPref>) = 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<IptvCategory> = 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<IptvChannel> = 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<IptvChannel>): Map<Int, String> {
val result = linkedMapOf<Int, String>()
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 <T> 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<MediaItem>,
)
data class HomePayload(
val continueWatching: List<MediaItem>,
val spotlight: List<MediaItem> = emptyList(),
val shelves: List<ShelfRow>,
)
data class ShelfRow(
val id: String,
val title: String,
val name: String = title,
val kind: String,
val slug: String = "",
val items: List<MediaItem>,
) {
/** 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<SubtitleHit>,
)
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<MediaItem> {
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<ShelfRow> {
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(),
)
)
}
}
}

View file

@ -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<String?> = context.dataStore.data.map { it[tokenKey] }
val apiBase: Flow<String> = 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('/') }
}
}

View file

@ -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<Renderer>,
) {
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<Renderer>,
) {
// 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<Renderer>,
) {
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<Renderer>,
) {
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<PlayerTrack> {
val out = mutableListOf<PlayerTrack>()
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
}

View file

@ -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<List<MediaItem>>(emptyList()) }
var total by remember { mutableStateOf(0) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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)
}
},
)
}
}
}
}

View file

@ -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<String> {
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<String>) {
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<String?>(null) }
var poster by remember { mutableStateOf<String?>(null) }
var logoUrl by remember { mutableStateOf<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var runtime by remember { mutableStateOf<Int?>(null) }
var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var cast by remember { mutableStateOf<List<String>>(emptyList()) }
var director by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
var selectedVersionId by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(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<MediaVersion>()
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<String?>(null) }
var logoUrl by remember { mutableStateOf<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var rating by remember { mutableStateOf<String?>(null) }
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by remember { mutableIntStateOf(0) }
var error by remember { mutableStateOf<String?>(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<JSONObject>()
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)
}
}
}
}
}

View file

@ -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<IptvChannel>,
val epgTitles: Map<Int, String>,
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<String?>(null) }
var channelName by remember { mutableStateOf("") }
var logoUrl by remember { mutableStateOf<String?>(null) }
var epg by remember { mutableStateOf<IptvEpg?>(null) }
var infoOverlay by remember { mutableStateOf(true) }
var channelMenuOpen by remember { mutableStateOf(false) }
var fallbackUrl by remember { mutableStateOf<String?>(null) }
var fallbackFormat by remember { mutableStateOf<String?>(null) }
var triedFallback by remember { mutableStateOf(false) }
var menuChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var menuCategories by remember { mutableStateOf<List<IptvCategory>>(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<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
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<Int, String>) {
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<Int, String>()
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<IptvChannel>,
epgNow: Map<Int, String>,
currentStreamId: Int,
refreshing: Boolean,
switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>,
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),
)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<String?>(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<List<PlayerTrack>>(emptyList()) }
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
var menu by remember { mutableStateOf<String?>(null) } // audio | text | os
var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
var osLoading by remember { mutableStateOf(false) }
var osError by remember { mutableStateOf<String?>(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)
}

View file

@ -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<List<MediaItem>>(emptyList()) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
var searchJob by remember { mutableStateOf<Job?>(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)
}
},
)
}
}
}
}

View file

@ -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<List<IptvCategory>>(emptyList()) }
var channels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInList by remember { mutableStateOf(false) }
val channelListState = rememberLazyListState()
val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
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<IptvCategory>,
selectedId: String?,
listState: androidx.compose.foundation.lazy.LazyListState,
categoryFocus: MutableMap<String, FocusRequester>,
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)
}

View file

@ -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),
)
}
}
}
}

View file

@ -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<FeatureLogo>,
) {
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<FeatureLogo> {
if (hay.isBlank()) return emptyList()
val out = linkedSetOf<FeatureLogo>()
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) }
}
}
}
}

View file

@ -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> = MainTab.entries,
subItems: List<SubNavItem> = 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,
)
}
}
}
}
}

View file

@ -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),
),
)
}

View file

@ -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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:endColor="#00000000"
android:startColor="#CC000000" />
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_bg">#0B0B0B</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VMC</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MediaClusterTv" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">#04060A</item>
<item name="android:colorPrimary">#6EA8FF</item>
</style>
</resources>

View file

@ -1,9 +1,17 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android { android {
namespace = "nl.vonas.mediacluster.tv" namespace = "nl.vonas.mediacluster.tv"
compileSdk = 36 compileSdk = 36
@ -11,15 +19,26 @@ android {
defaultConfig { defaultConfig {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 36
versionCode = 41 versionCode = 43
versionName = "0.11.9" versionName = "0.12.1"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")
} }
} }
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 { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
@ -27,6 +46,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }

View file

@ -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.parallel=false
org.gradle.workers.max=1 org.gradle.workers.max=1
org.gradle.caching=true org.gradle.caching=true
org.gradle.daemon=true org.gradle.daemon=false
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=in-process kotlin.compiler.execution.strategy=daemon
kotlin.incremental=true kotlin.incremental=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

View file

@ -14,3 +14,4 @@ dependencyResolutionManagement {
} }
rootProject.name = "VMC" rootProject.name = "VMC"
include(":app") include(":app")
include(":app-mobile")