Polish Android TV UI and fix player audio/controls.

This commit is contained in:
Jos Vooges | STH 2026-08-31 01:28:55 +02:00
parent 0b107515e4
commit 921fe57bd8
12 changed files with 810 additions and 346 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.2.0"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
}

View file

@ -2,6 +2,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.software.leanback"

View file

@ -1,5 +1,6 @@
package nl.vonas.mediacluster.tv
import android.media.AudioManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -10,6 +11,7 @@ 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 {

View file

@ -157,6 +157,26 @@ class ApiClient(
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 dismissContinue(progressId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss")
.post("{}".toRequestBody(jsonMedia))
.build()
execute(req)
Unit
}
private fun authed(url: String): Request.Builder {
val b = Request.Builder().url(url)
val t = token

View file

@ -0,0 +1,88 @@
package nl.vonas.mediacluster.tv.playback
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MimeTypes
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
/** ExoPlayer tuned for Shield / Android TV direct play + audible output. */
fun buildTvPlayer(context: Context): ExoPlayer {
val trackSelector = DefaultTrackSelector(context).apply {
parameters = buildUponParameters()
.setPreferredAudioMimeTypes(
MimeTypes.AUDIO_E_AC3,
MimeTypes.AUDIO_AC3,
MimeTypes.AUDIO_AAC,
MimeTypes.AUDIO_MPEG,
MimeTypes.AUDIO_OPUS,
)
.setAllowAudioMixedMimeTypeAdaptiveness(true)
.setExceedRendererCapabilitiesIfNecessary(true)
.build()
}
val renderersFactory = DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
return ExoPlayer.Builder(context, renderersFactory)
.setTrackSelector(trackSelector)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setSeekBackIncrementMs(10_000)
.setSeekForwardIncrementMs(30_000)
.build()
.also { player ->
player.volume = 1f
player.playWhenReady = true
}
}
/** Pick the most playable audio track (avoid silent DTS/TrueHD when alternatives exist). */
fun ExoPlayer.selectBestAudioTrack() {
val groups = currentTracks.groups
var bestGroup: androidx.media3.common.Tracks.Group? = null
var bestTrackIndex = -1
var bestScore = -1
for (gi in 0 until groups.size) {
val group = groups[gi]
if (group.type != C.TRACK_TYPE_AUDIO) continue
for (ti in 0 until group.length) {
if (!group.isTrackSupported(ti)) continue
val format = group.getTrackFormat(ti)
val mime = (format.sampleMimeType ?: "").lowercase()
val score = when {
"eac3" in mime || "ec-3" in mime -> 90
"ac3" in mime || "ac-3" in mime -> 80
"mp4a" in mime || "aac" in mime -> 70
"mpeg" in mime -> 60
"opus" in mime -> 50
"dts" in mime || "truehd" in mime || "mlp" in mime -> 15
else -> 35
} + format.channelCount.coerceIn(0, 8)
if (score > bestScore) {
bestScore = score
bestGroup = group
bestTrackIndex = ti
}
}
}
val group = bestGroup ?: return
if (bestTrackIndex < 0) return
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, listOf(bestTrackIndex)))
.build()
volume = 1f
}

View file

@ -8,18 +8,19 @@ 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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -27,40 +28,36 @@ 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.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.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.ApiException
import nl.vonas.mediacluster.tv.data.HomePayload
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.PollResult
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ShelfPref
import org.json.JSONObject
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors
@Composable
fun McNav() {
@ -68,6 +65,7 @@ fun McNav() {
val store = remember { SessionStore(context) }
val api = remember { ApiClient(baseUrl = "") }
val nav = rememberNavController()
val scope = rememberCoroutineScope()
var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) }
@ -80,31 +78,23 @@ fun McNav() {
}
if (!ready) {
Box(Modifier.fillMaxSize().background(Color(0xFF04060A)), contentAlignment = Alignment.Center) {
Text("Media Cluster", color = Color.White, fontSize = 28.sp)
}
Splash()
return
}
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
composable("link") {
LinkScreen(
api = api,
store = store,
onLinked = {
authed = true
nav.navigate("home") {
popUpTo("link") { inclusive = true }
}
},
)
LinkScreen(api, store) {
authed = true
nav.navigate("home") { popUpTo("link") { inclusive = true } }
}
}
composable("home") {
HomeScreen(
api = api,
onPlayMovie = { id -> nav.navigate("movie/$id") },
onPlaySeries = { id -> nav.navigate("series/$id") },
onPlayDirect = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs, _title ->
val m = movieId?.ifBlank { null } ?: "-"
val e = episodeId?.ifBlank { null } ?: "-"
val s = seriesId?.ifBlank { null } ?: "-"
@ -112,41 +102,35 @@ fun McNav() {
},
onSettings = { nav.navigate("settings") },
onLogout = {
kotlinx.coroutines.MainScope().launch {
scope.launch {
store.setToken(null)
api.configure(store.apiBase.first(), null)
authed = false
nav.navigate("link") {
popUpTo(0) { inclusive = true }
}
nav.navigate("link") { popUpTo(0) { inclusive = true } }
}
},
)
}
composable(
"movie/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
DetailScreen(
load = { api.movie(id) },
onPlay = { mediaFileId ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/0")
},
MovieDetailScreen(
api = api,
movieId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, title, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
},
)
}
composable(
"series/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { entry ->
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
SeriesScreen(
load = { api.series(id) },
onPlay = { mediaFileId, episodeId ->
SeriesDetailScreen(
api = api,
seriesId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _title ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
},
onBack = { nav.popBackStack() },
)
}
composable(
@ -173,45 +157,51 @@ fun McNav() {
)
}
composable("settings") {
SettingsScreen(
api = api,
store = store,
onBack = { nav.popBackStack() },
)
SettingsScreen(api = api, onBack = { nav.popBackStack() })
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LinkScreen(
api: ApiClient,
store: SessionStore,
onLinked: () -> Unit,
) {
val scope = rememberCoroutineScope()
private fun Splash() {
Box(
Modifier.fillMaxSize().background(McColors.Bg),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(8.dp))
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) {
var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) }
var status by remember { mutableStateOf("Code ophalen…") }
LaunchedEffect(Unit) {
val base = store.apiBase.first()
api.configure(base, null)
try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
val created = api.createDeviceCode(deviceName.ifBlank { "Android TV" })
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
val created = api.createDeviceCode(deviceName)
code = created.code
linkUrl = created.linkUrl
status = "Wachten op koppeling…"
status = "Wachten tot je de code bevestigt…"
while (true) {
delay((created.pollIntervalSeconds.coerceAtLeast(2)) * 1000L)
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
when (val poll = api.pollDeviceCode(created.code)) {
is PollResult.Linked -> {
store.setToken(poll.refreshToken)
api.configure(base, poll.refreshToken)
status = "Welkom ${poll.viewerName}"
delay(600)
status = "Welkom, ${poll.viewerName}"
delay(700)
onLinked()
break
}
@ -227,29 +217,50 @@ private fun LinkScreen(
}
}
} catch (e: Exception) {
error = e.message ?: "Kon niet verbinden met master"
error = e.message ?: "Kon master niet bereiken"
}
}
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF04060A))
.padding(48.dp),
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF12203A), McColors.Bg),
radius = 900f,
),
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Media Cluster", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text("Koppel dit apparaat", fontSize = 22.sp, color = Color(0xFFB8C0D0))
Spacer(Modifier.height(12.dp))
Text(code ?: "····-····", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = Color(0xFF6EA8FF))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.width(560.dp)
.clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp),
) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
Text("Koppel dit apparaat", color = McColors.Text, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(
"Open de link hieronder op je telefoon of computer, log in en voer de code in.",
color = McColors.TextDim,
fontSize = 15.sp,
)
Spacer(Modifier.height(8.dp))
Text(
code ?: "····-····",
color = McColors.Accent,
fontSize = 52.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 3.sp,
)
if (linkUrl.isNotBlank()) {
Text("Ga naar $linkUrl", fontSize = 18.sp, color = Color.White)
}
Text(status, fontSize = 16.sp, color = Color(0xFF9AA3B5))
if (error != null) {
Text(error!!, color = Color(0xFFFF8B8B), fontSize = 16.sp)
Text(linkUrl, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
}
}
}
@ -258,74 +269,98 @@ private fun LinkScreen(
@Composable
private fun HomeScreen(
api: ApiClient,
onPlayMovie: (String) -> Unit,
onPlaySeries: (String) -> Unit,
onPlayDirect: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long, title: String) -> Unit,
onSettings: () -> Unit,
onLogout: () -> Unit,
) {
var home by remember { mutableStateOf<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
suspend fun reload() {
loading = true
try {
home = api.home()
error = null
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
LaunchedEffect(Unit) { reload() }
val hero = home?.continueWatching?.firstOrNull()
?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
LazyColumn(
Modifier
.fillMaxSize()
.background(Color(0xFF04060A)),
contentPadding = PaddingValues(32.dp),
Modifier.fillMaxSize().background(McColors.Bg),
contentPadding = PaddingValues(bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Media Cluster", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
TvButton("Planken", onSettings)
TvButton("Uitloggen", onLogout)
}
}
HeroHeader(
hero = hero,
onPlayHero = {
val h = hero ?: return@HeroHeader
when {
!h.mediaFileId.isNullOrBlank() -> onPlay(
h.mediaFileId!!,
h.id.takeIf { h.type == "movie" },
h.id.takeIf { h.type == "episode" },
null,
if (h.type == "episode") "EPISODE" else "MOVIE",
h.positionMs,
h.title,
)
h.type == "movie" -> onOpenMovie(h.id)
else -> onOpenSeries(h.id)
}
},
onOpenHero = {
val h = hero ?: return@HeroHeader
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
},
onSettings = onSettings,
onLogout = onLogout,
)
}
if (error != null) {
item { Text(error!!, color = Color(0xFFFF8B8B)) }
item {
Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp))
}
}
if (loading && home == null) {
item {
Text("Bibliotheek laden…", color = McColors.TextDim, modifier = Modifier.padding(40.dp))
}
}
val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) {
item {
ShelfSection("Verder kijken", continueItems) { item ->
val fileId = item.mediaFileId
if (!fileId.isNullOrBlank()) {
onPlayDirect(
fileId,
ShelfBlock("Verder kijken", continueItems) { item ->
when {
!item.mediaFileId.isNullOrBlank() -> onPlay(
item.mediaFileId!!,
item.id.takeIf { item.type == "movie" },
item.id.takeIf { item.type == "episode" },
null,
if (item.type == "episode") "EPISODE" else "MOVIE",
item.positionMs,
item.title,
)
} else if (item.type == "series" || item.type == "episode") {
// fall through
} else {
onPlayMovie(item.id)
item.type == "movie" -> onOpenMovie(item.id)
else -> onOpenSeries(item.id)
}
}
}
}
items(home?.shelves.orEmpty()) { shelf ->
ShelfSection(shelf.title, shelf.items) { item ->
when (item.type) {
"movie" -> onPlayMovie(item.id)
else -> onPlaySeries(item.id)
}
ShelfBlock(shelf.title, shelf.items) { item ->
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
}
}
}
@ -333,11 +368,76 @@ private fun HomeScreen(
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
items(items, key = { "${it.type}:${it.id}" }) { item ->
private fun HeroHeader(
hero: MediaItem?,
onPlayHero: () -> Unit,
onOpenHero: () -> Unit,
onSettings: () -> Unit,
onLogout: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(360.dp),
) {
AsyncImage(
model = hero?.backdropUrl ?: hero?.posterUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
Box(
Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(McColors.Bg, Color.Transparent, Color.Transparent),
),
),
)
Column(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(0.55f)
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
Text(
hero?.title ?: "Welkom terug",
color = McColors.Text,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 44.sp,
)
if (!hero?.subtitle.isNullOrBlank()) {
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (hero != null) {
McButton(if ((hero.positionMs) > 30_000) "Verder kijken" else "Afspelen", onPlayHero, primary = true)
McButton("Details", onOpenHero)
}
McButton("Planken", onSettings)
McButton("Uitloggen", onLogout)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(title, color = McColors.Text, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(end = 40.dp),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(item, onClick = { onClick(item) })
}
}
@ -346,66 +446,27 @@ private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaI
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun PosterCard(item: MediaItem, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF121826),
focusedContainerColor = Color(0xFF1A2438),
),
) {
Column {
AsyncImage(
model = item.posterUrl,
contentDescription = item.title,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(Color(0xFF1A2233)),
contentScale = ContentScale.Crop,
)
Text(
item.title,
modifier = Modifier.padding(8.dp),
color = Color.White,
fontSize = 13.sp,
maxLines = 2,
)
if (!item.subtitle.isNullOrBlank()) {
Text(
item.subtitle,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
color = Color(0xFF9AA3B5),
fontSize = 11.sp,
maxLines = 1,
)
}
Spacer(Modifier.height(8.dp))
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun DetailScreen(
load: suspend () -> JSONObject,
onPlay: (String) -> Unit,
private fun MovieDetailScreen(
api: ApiClient,
movieId: String,
onBack: () -> Unit,
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
) {
var title by remember { mutableStateOf("…") }
var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) }
var year by remember { mutableStateOf<Int?>(null) }
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
LaunchedEffect(movieId) {
try {
val o = load()
val o = api.movie(movieId)
title = o.optString("title")
overview = o.optString("overview")
backdrop = o.optString("backdropUrl").ifBlank { null }
backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } }
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }
val arr = o.optJSONArray("mediaFiles")
val list = mutableListOf<Pair<String, String>>()
if (arr != null) {
@ -414,9 +475,9 @@ private fun DetailScreen(
val label = listOfNotNull(
f.optString("resolution").ifBlank { null },
f.optString("hdrType").ifBlank { null },
f.optString("releaseName").ifBlank { null },
).joinToString(" · ")
list += f.getString("id") to label.ifBlank { "Afspelen" }
f.optString("videoCodec").ifBlank { null },
).joinToString(" · ").ifBlank { "Afspelen" }
list += f.getString("id") to label
}
}
files = list
@ -425,24 +486,41 @@ private fun DetailScreen(
}
}
Box(Modifier.fillMaxSize().background(Color(0xFF04060A))) {
if (backdrop != null) {
AsyncImage(
model = backdrop,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = 0.35f,
)
}
Column(Modifier.padding(40.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
TvButton("Terug", onBack)
Text(title, fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(overview, color = Color(0xFFD0D6E2), fontSize = 16.sp, maxLines = 6)
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(files) { (id, label) ->
TvButton(label.take(48), onClick = { onPlay(id) })
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent))))
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg))))
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth(0.55f)
.padding(40.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
McButton("Terug", onBack)
Spacer(Modifier.height(8.dp))
Text(title, color = McColors.Text, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3)
if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp)
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp)
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
files.firstOrNull()?.let { (id, _) ->
McButton("Afspelen", { onPlay(id, title, 0) }, primary = true)
}
McButton("Als gekeken") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
}
}
}
if (files.size > 1) {
Text("Versies", color = McColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(files) { (id, label) ->
McButton(label.take(40)) { onPlay(id, title, 0) }
}
}
}
}
@ -451,21 +529,26 @@ private fun DetailScreen(
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SeriesScreen(
load: suspend () -> JSONObject,
onPlay: (mediaFileId: String, episodeId: String) -> Unit,
private fun SeriesDetailScreen(
api: ApiClient,
seriesId: String,
onBack: () -> Unit,
onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit,
) {
var title by remember { mutableStateOf("…") }
var episodes by remember { mutableStateOf<List<Triple<String, String, String?>>>(emptyList()) }
var overview by remember { mutableStateOf("") }
var backdrop by remember { mutableStateOf<String?>(null) }
var episodes by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
LaunchedEffect(seriesId) {
try {
val o = load()
val o = api.series(seriesId)
title = o.optString("title")
overview = o.optString("overview")
backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } }
val seasons = o.optJSONArray("seasons")
val list = mutableListOf<Triple<String, String, String?>>()
val list = mutableListOf<Triple<String, String, String>>()
if (seasons != null) {
for (si in 0 until seasons.length()) {
val season = seasons.getJSONObject(si)
@ -473,8 +556,9 @@ private fun SeriesScreen(
for (ei in 0 until eps.length()) {
val e = eps.getJSONObject(ei)
if (!e.optBoolean("available", false)) continue
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} · ${e.optString("title")}"
list += Triple(e.getString("id"), label, e.optString("mediaFileId").ifBlank { null })
val fileId = e.optString("mediaFileId").ifBlank { null } ?: continue
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} ${e.optString("title")}"
list += Triple(e.getString("id"), label, fileId)
}
}
}
@ -484,118 +568,33 @@ private fun SeriesScreen(
}
}
LazyColumn(
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
TvButton("Terug", onBack)
Spacer(Modifier.height(8.dp))
Text(title, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
}
items(episodes) { (id, label, fileId) ->
if (fileId != null) {
TvButton(label, onClick = { onPlay(fileId, id) })
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
LazyColumn(
contentPadding = PaddingValues(40.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
McButton("Terug", onBack)
Spacer(Modifier.height(12.dp))
Text(title, color = McColors.Text, fontSize = 34.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(16.dp))
Text("Afleveringen", color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
private fun PlayerScreen(
api: ApiClient,
mediaFileId: String,
mediaType: String,
movieId: String?,
episodeId: String?,
seriesId: String?,
startMs: Long,
onBack: () -> Unit,
) {
val context = LocalContext.current
var error by remember { mutableStateOf<String?>(null) }
val player = remember {
ExoPlayer.Builder(context).build().apply {
playWhenReady = true
}
}
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
if (startMs > 0) player.seekTo(startMs)
} catch (e: Exception) {
error = e.message
}
}
LaunchedEffect(player) {
while (true) {
delay(15_000)
if (player.playbackState == Player.STATE_READY || player.isPlaying) {
try {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0),
)
} catch (_: ApiException) {
}
items(episodes) { (id, label, fileId) ->
McButton(label) { onPlay(fileId, id, "$title · $label") }
}
}
}
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose {
try {
kotlinx.coroutines.runBlocking {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0),
)
}
} catch (_: Exception) {
}
player.release()
}
}
Box(Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = true
}
},
modifier = Modifier.fillMaxSize(),
)
if (error != null) {
Text(error!!, color = Color(0xFFFF8B8B), modifier = Modifier.align(Alignment.Center))
}
Box(Modifier.padding(24.dp)) {
TvButton("Terug", onBack)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsScreen(
api: ApiClient,
store: SessionStore,
onBack: () -> Unit,
) {
private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) {
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
@ -620,26 +619,30 @@ private fun SettingsScreen(
}
LazyColumn(
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
TvButton("Terug", onBack)
McButton("Terug", onBack)
Spacer(Modifier.height(12.dp))
Text("Planken", color = McColors.Text, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text("Kies wat je op home ziet en in welke volgorde.", color = McColors.TextDim, fontSize = 15.sp)
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(8.dp))
Text("Planken", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text("Zet planken aan/uit en verander de volgorde.", color = Color(0xFF9AA3B5))
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
}
items(prefs.size) { index ->
val p = prefs[index]
itemsIndexed(prefs) { index, p ->
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(McColors.BgElevated)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, color = Color.White, fontSize = 18.sp, modifier = Modifier.weight(1f))
Text(p.name, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
TvButton("Omhoog") {
McButton("↑") {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
@ -648,7 +651,7 @@ private fun SettingsScreen(
persist(next)
}
}
TvButton("Omlaag") {
McButton("↓") {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
@ -670,23 +673,3 @@ private fun SettingsScreen(
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun TvButton(label: String, onClick: () -> Unit) {
Surface(
onClick = onClick,
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A2438),
focusedContainerColor = Color(0xFF2A3A5A),
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.White,
fontSize = 15.sp,
)
}
}

View file

@ -0,0 +1,178 @@
package nl.vonas.mediacluster.tv.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.R
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
import nl.vonas.mediacluster.tv.playback.selectBestAudioTrack
import nl.vonas.mediacluster.tv.ui.theme.McColors
@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
var error by remember { mutableStateOf<String?>(null) }
var buffering by remember { mutableStateOf(true) }
val player = remember {
buildTvPlayer(context)
}
BackHandler(onBack = onBack)
LaunchedEffect(mediaFileId) {
try {
val play = api.play(mediaFileId)
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
player.prepare()
if (startMs > 5_000) player.seekTo(startMs)
player.volume = 1f
player.play()
} catch (e: Exception) {
error = e.message ?: "Afspelen mislukt"
buffering = false
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
buffering = playbackState == Player.STATE_BUFFERING
if (playbackState == Player.STATE_READY) {
player.selectBestAudioTrack()
player.volume = 1f
}
}
override fun onPlayerError(e: PlaybackException) {
error = e.message ?: "Spelerfout"
}
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
player.selectBestAudioTrack()
}
}
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(12_000)
if (player.isPlaying || player.playbackState == Player.STATE_READY) {
try {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
)
} catch (_: Exception) {
}
}
}
}
Box(Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(
factory = { ctx ->
val root = LayoutInflater.from(ctx).inflate(R.layout.mc_player_view, null, false) as PlayerView
root.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
root.player = player
root.controllerShowTimeoutMs = 4_500
root.setShowNextButton(false)
root.setShowPreviousButton(false)
root.setShowFastForwardButton(true)
root.setShowRewindButton(true)
root.setShowSubtitleButton(true)
root.useController = true
root.setControllerHideOnTouch(true)
// Request focus so remote OK toggles controls
root.requestFocus()
root
},
update = { view ->
if (view.player !== player) view.player = player
},
modifier = Modifier.fillMaxSize(),
)
if (buffering && error == null) {
Text(
"Laden…",
color = McColors.TextDim,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center),
)
}
if (error != null) {
Text(
error!!,
color = McColors.Danger,
fontSize = 18.sp,
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp),
)
}
}
}

View file

@ -0,0 +1,148 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.width
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.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.dp
import androidx.compose.ui.unit.sp
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,
onClick: () -> Unit,
modifier: Modifier = Modifier,
primary: Boolean = false,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 8.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Accent else McColors.Surface,
focusedContainerColor = if (primary) Color(0xFF7EB0FF) else McColors.SurfaceFocus,
contentColor = if (primary) Color(0xFF041022) else McColors.Text,
focusedContentColor = if (primary) Color(0xFF041022) else McColors.Text,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun PosterCard(
item: MediaItem,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 148.dp,
height: Dp = 222.dp,
) {
val progress = if (item.durationMs > 0) {
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Surface(
onClick = onClick,
modifier = modifier.width(width),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
glow = ClickableSurfaceDefaults.glow(
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 12.dp),
),
colors = ClickableSurfaceDefaults.colors(
containerColor = McColors.Surface,
focusedContainerColor = McColors.SurfaceFocus,
),
) {
Column {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
.background(McColors.BgElevated),
) {
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()
.height(4.dp)
.background(Color(0x55FFFFFF)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(4.dp)
.background(McColors.Accent),
)
}
}
}
Text(
item.title,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = McColors.Text,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
)
if (!item.subtitle.isNullOrBlank()) {
Text(
item.subtitle,
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
color = McColors.TextDim,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(Modifier.height(4.dp))
}
}
}
}

View file

@ -0,0 +1,24 @@
package nl.vonas.mediacluster.tv.ui.theme
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object McColors {
val Bg = Color(0xFF05070D)
val BgElevated = Color(0xFF0E1420)
val Surface = Color(0xFF151C2B)
val SurfaceFocus = Color(0xFF1E2A42)
val Accent = Color(0xFF5B9DFF)
val AccentSoft = Color(0x335B9DFF)
val Text = Color(0xFFF4F7FC)
val TextDim = Color(0xFF9AA6BD)
val Danger = Color(0xFFFF7B8A)
val Success = Color(0xFF7DFFB3)
val HeroScrim = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color(0xCC05070D), Color(0xFF05070D)),
)
val CardScrim = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color(0xE605070D)),
)
}

View file

@ -1,24 +1,20 @@
package nl.vonas.mediacluster.tv.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
private val Scheme = darkColorScheme(
primary = Color(0xFF6EA8FF),
onPrimary = Color(0xFF001B3D),
surface = Color(0xFF0A0E16),
onSurface = Color(0xFFF2F5FA),
border = Color(0x33FFFFFF),
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,
)
MaterialTheme(colorScheme = Scheme, content = content)
}

View file

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

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:keepScreenOn="true"
app:auto_show="true"
app:hide_on_touch="true"
app:resize_mode="fit"
app:show_buffering="when_playing"
app:show_subtitle_button="true"
app:show_timeout="4500"
app:surface_type="surface_view"
app:use_controller="true" />