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>
5
apps/android-tv/.gitignore
vendored
|
|
@ -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*
|
||||||
|
|
|
||||||
95
apps/android-tv/app-mobile/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
1
apps/android-tv/app-mobile/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
33
apps/android-tv/app-mobile/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nl.vonas.mediacluster.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class McApp : Application()
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('/') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
BIN
apps/android-tv/app-mobile/src/main/res/drawable/app_banner.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_bg">#0B0B0B</color>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">VMC</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
apps/android-tv/play-store/play-feature-graphic-1024x500.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
apps/android-tv/play-store/play-icon-512.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
|
@ -14,3 +14,4 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
rootProject.name = "VMC"
|
rootProject.name = "VMC"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":app-mobile")
|
||||||
|
|
|
||||||