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/
|
||||
**/build/
|
||||
local.properties
|
||||
keystore.properties
|
||||
keystore/
|
||||
*.jks
|
||||
*.keystore
|
||||
*.iml
|
||||
.DS_Store
|
||||
captures/
|
||||
*.apk
|
||||
*.aab
|
||||
dist/
|
||||
hs_err_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 {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "nl.vonas.mediacluster.tv"
|
||||
compileSdk = 36
|
||||
|
|
@ -11,15 +19,26 @@ android {
|
|||
defaultConfig {
|
||||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 41
|
||||
versionName = "0.11.9"
|
||||
targetSdk = 36
|
||||
versionCode = 43
|
||||
versionName = "0.12.1"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
|
@ -27,6 +46,9 @@ android {
|
|||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:CICompilerCount=1 -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.caching=true
|
||||
org.gradle.daemon=true
|
||||
org.gradle.daemon=false
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
kotlin.compiler.execution.strategy=daemon
|
||||
kotlin.incremental=true
|
||||
|
|
|
|||
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"
|
||||
include(":app")
|
||||
include(":app-mobile")
|
||||
|
|
|
|||