From 921fe57bd8dc8b4001d3480731df97794f5f2ce0 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 31 Aug 2026 01:28:55 +0200 Subject: [PATCH] Polish Android TV UI and fix player audio/controls. --- apps/android-tv/app/build.gradle.kts | 4 +- .../app/src/main/AndroidManifest.xml | 2 + .../nl/vonas/mediacluster/tv/MainActivity.kt | 2 + .../vonas/mediacluster/tv/data/ApiClient.kt | 20 + .../mediacluster/tv/playback/PlayerFactory.kt | 88 +++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 651 +++++++++--------- .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 178 +++++ .../tv/ui/components/Components.kt | 148 ++++ .../vonas/mediacluster/tv/ui/theme/Colors.kt | 24 + .../vonas/mediacluster/tv/ui/theme/Theme.kt | 16 +- .../main/res/drawable/player_controls_bg.xml | 7 + .../src/main/res/layout/mc_player_view.xml | 16 + 12 files changed, 810 insertions(+), 346 deletions(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt create mode 100644 apps/android-tv/app/src/main/res/drawable/player_controls_bg.xml create mode 100644 apps/android-tv/app/src/main/res/layout/mc_player_view.xml diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index adc121a..01e8862 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -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\"") } diff --git a/apps/android-tv/app/src/main/AndroidManifest.xml b/apps/android-tv/app/src/main/AndroidManifest.xml index 0995268..ebf02df 100644 --- a/apps/android-tv/app/src/main/AndroidManifest.xml +++ b/apps/android-tv/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + 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 +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index c249212..36af7ba 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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(null) } var linkUrl by remember { mutableStateOf("") } + var status by remember { mutableStateOf("Verbinden…") } var error by remember { mutableStateOf(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(null) } var error by remember { mutableStateOf(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, 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, 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, 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(null) } + var year by remember { mutableStateOf(null) } var files by remember { mutableStateOf>>(emptyList()) } var error by remember { mutableStateOf(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>() 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>>(emptyList()) } + var overview by remember { mutableStateOf("") } + var backdrop by remember { mutableStateOf(null) } + var episodes by remember { mutableStateOf>>(emptyList()) } var error by remember { mutableStateOf(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>() + val list = mutableListOf>() 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(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>(emptyList()) } var error by remember { mutableStateOf(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, - ) - } -} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt new file mode 100644 index 0000000..e6bde2d --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -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(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), + ) + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt new file mode 100644 index 0000000..58af783 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -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)) + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt new file mode 100644 index 0000000..e4e0d2c --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Colors.kt @@ -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)), + ) +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt index d396eca..0fc94a0 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt @@ -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) } diff --git a/apps/android-tv/app/src/main/res/drawable/player_controls_bg.xml b/apps/android-tv/app/src/main/res/drawable/player_controls_bg.xml new file mode 100644 index 0000000..c35cec7 --- /dev/null +++ b/apps/android-tv/app/src/main/res/drawable/player_controls_bg.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/android-tv/app/src/main/res/layout/mc_player_view.xml b/apps/android-tv/app/src/main/res/layout/mc_player_view.xml new file mode 100644 index 0000000..22c4d74 --- /dev/null +++ b/apps/android-tv/app/src/main/res/layout/mc_player_view.xml @@ -0,0 +1,16 @@ + +