diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index 6ed3cdc..b579e19 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.0.3" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 512c23e..0ce1123 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -11,6 +11,7 @@ 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.statusBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -28,7 +29,6 @@ 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 @@ -38,6 +38,7 @@ 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 +import nl.vonas.mediacluster.tv.ui.theme.McDimens @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -66,6 +67,7 @@ fun BrowseScreen( var loadingMore by remember { mutableStateOf(false) } val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() + val minCell = McDimens.gridMinCell() suspend fun load(reset: Boolean) { if (reset) { @@ -128,43 +130,48 @@ fun BrowseScreen( Modifier .fillMaxSize() .background(McColors.Bg) - .padding(horizontal = 36.dp, vertical = 20.dp), + .statusBarsPadding() + .padding(horizontal = McDimens.screenPad), ) { + Spacer(Modifier.height(8.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column { - Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Column(Modifier.weight(1f)) { + Text( + title, + color = Color.White, + fontSize = McDimens.titleScreen, + fontWeight = FontWeight.Bold, + ) Text( if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten", color = McColors.TextDim, - fontSize = 14.sp, + fontSize = McDimens.meta, ) } if (onBack != null) { - McButton("Terug", onClick = onBack) + McButton("Terug", compact = true, onClick = onBack) } } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) if (error != null) { - Text(error!!, color = McColors.Danger) + Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) } LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 140.dp), + columns = GridCells.Adaptive(minSize = minCell), 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), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), + verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), 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), + fillCell = true, onClick = { val canResume = onPlayContinue != null && !item.mediaFileId.isNullOrBlank() && diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index ca315bd..a5d8082 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -1,9 +1,16 @@ 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.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.heightIn +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.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.background @@ -17,15 +24,11 @@ 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.foundation.layout.size +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,11 +50,7 @@ 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 @@ -62,6 +61,7 @@ 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 nl.vonas.mediacluster.tv.ui.theme.McDimens import org.json.JSONArray import org.json.JSONObject @@ -164,8 +164,8 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) { model = logoUrl, contentDescription = title, modifier = Modifier - .widthIn(max = 420.dp) - .heightIn(max = 120.dp) + .widthIn(max = 280.dp) + .heightIn(max = 72.dp) .fillMaxWidth(0.85f), contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, @@ -174,11 +174,11 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) { Text( title, color = Color.White, - fontSize = 40.sp, + fontSize = McDimens.titleHero, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis, - lineHeight = 46.sp, + lineHeight = 30.sp, ) } } @@ -198,7 +198,7 @@ private fun MetaChips(parts: List) { Text( part, color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim, - fontSize = 14.sp, + fontSize = McDimens.meta, fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium, ) } @@ -208,28 +208,15 @@ private fun MetaChips(parts: List) { @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), - ), - ), + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text( "Terug", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - color = Color.Unspecified, + color = Color(0xFFCFCFCF), fontSize = 13.sp, fontWeight = FontWeight.Medium, ) @@ -245,39 +232,18 @@ private fun DetailActionButton( 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), - ), - ) - }, + Box( + modifier = Modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .clip(RoundedCornerShape(4.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text( text = label, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - color = Color.Unspecified, + color = fg, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, @@ -368,26 +334,49 @@ fun MovieDetailScreen( val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen" BackHandler(onBack = onBack) - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { DetailBackdrop(backdrop) Column( Modifier - .fillMaxHeight() - .fillMaxWidth(0.58f) - .padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp) - .verticalScroll(scroll), + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(scroll) + .padding(horizontal = McDimens.screenPad, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { DetailBackButton(onClick = onBack) - Spacer(Modifier.height(4.dp)) - DetailLogoOrTitle(logoUrl, title) - MetaChips(metaParts) - SelectedQualityLine(selected) + + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, + ) { + if (!poster.isNullOrBlank()) { + AsyncImage( + model = poster, + contentDescription = null, + modifier = Modifier + .width(McDimens.detailPosterW) + .height(McDimens.detailPosterH) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF1A1A1A)), + contentScale = ContentScale.Crop, + ) + } + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + DetailLogoOrTitle(logoUrl, title) + MetaChips(metaParts) + SelectedQualityLine(selected) + } + } + if (director.isNotEmpty()) { Text( "Regie: ${director.take(2).joinToString(", ")}", color = Color(0xFF9A9A9A), - fontSize = 13.sp, + fontSize = McDimens.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -396,24 +385,27 @@ fun MovieDetailScreen( Text( overview, color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 4, + fontSize = McDimens.bodySmall, + maxLines = 6, overflow = TextOverflow.Ellipsis, - lineHeight = 21.sp, + lineHeight = 18.sp, ) } if (cast.isNotEmpty()) { Text( "Met: ${cast.take(4).joinToString(", ")}", color = Color(0xFF8A8A8A), - fontSize = 13.sp, - maxLines = 1, + fontSize = McDimens.meta, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } - if (error != null) Text(error!!, color = McColors.Danger) - Spacer(Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { selected?.let { ver -> DetailActionButton(playLabel, primary = true, focusRequester = playFocus) { onPlay(ver.id, title, 0) @@ -432,17 +424,16 @@ fun MovieDetailScreen( } } if (versions.size > 1) { - Spacer(Modifier.height(2.dp)) Text( "Kwaliteit", color = Color(0xFF8F8F8F), - fontSize = 11.sp, + fontSize = McDimens.caption, fontWeight = FontWeight.Medium, letterSpacing = 0.6.sp, ) LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp), + contentPadding = PaddingValues(bottom = 16.dp), ) { items(versions, key = { it.id }) { ver -> VersionPickChip( @@ -452,23 +443,10 @@ fun MovieDetailScreen( ) } } + } else { + Spacer(Modifier.height(24.dp)) } } - - 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, - ) - } } } @@ -480,30 +458,12 @@ private fun VersionPickChip( 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, - ), - ), + Box( + Modifier + .clip(shape) + .background(if (selected) Color(0xEE222222) else Color(0x99101010)) + .clickable(onClick = onClick) + .padding(1.dp), ) { VersionQualityMark(version = version, selected = selected) } @@ -592,30 +552,36 @@ fun SeriesDetailScreen( } BackHandler(onBack = onBack) - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(McColors.Bg)) { DetailBackdrop(backdrop) LazyColumn( - contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues( + start = McDimens.screenPad, + end = McDimens.screenPad, + top = 12.dp, + bottom = 28.dp, + ), + modifier = Modifier.statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { DetailBackButton(onClick = onBack) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(6.dp)) DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) if (overview.isNotBlank()) { Text( overview, color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 4, + fontSize = McDimens.bodySmall, + maxLines = 5, overflow = TextOverflow.Ellipsis, - lineHeight = 22.sp, - modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp), + lineHeight = 18.sp, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), ) } - if (error != null) Text(error!!, color = McColors.Danger) - Spacer(Modifier.height(6.dp)) + if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { episodes.firstOrNull()?.let { ep -> DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) { @@ -627,8 +593,8 @@ fun SeriesDetailScreen( } } if (seasons.size > 1) { - Spacer(Modifier.height(8.dp)) - Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(6.dp)) + Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(4.dp)) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(seasons) { s -> @@ -641,9 +607,9 @@ fun SeriesDetailScreen( } } Spacer(Modifier.height(4.dp)) - Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text("Afleveringen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold) } - itemsIndexed(episodes) { index, ep -> + itemsIndexed(episodes) { _, ep -> EpisodeCard( episode = ep, focusRequester = null, @@ -674,59 +640,48 @@ private fun EpisodeCard( focusRequester: FocusRequester?, onClick: () -> Unit, ) { - Surface( - onClick = onClick, - modifier = Modifier + Row( + 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, - ), + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xCC141414)) + .clickable(onClick = onClick) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - 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, + AsyncImage( + model = episode.stillUrl, + contentDescription = null, + modifier = Modifier + .width(McDimens.episodeStillW) + .height(McDimens.episodeStillH) + .clip(RoundedCornerShape(5.dp)) + .background(Color(0xFF1A1A1A)), + contentScale = ContentScale.Crop, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "S${episode.season}E${episode.number} · ${episode.title}", + color = Color.White, + fontSize = McDimens.bodySmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (episode.overview.isNotBlank()) { Text( - "S${episode.season}E${episode.number} · ${episode.title}", - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + episode.overview, + color = McColors.TextDim, + fontSize = McDimens.meta, + maxLines = 2, overflow = TextOverflow.Ellipsis, + lineHeight = 15.sp, ) - 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) - } + } + formatRuntime(episode.runtime)?.let { + Text(it, color = Color(0xFF888888), fontSize = McDimens.caption) } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index abe2ed8..9df48e8 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -11,7 +11,9 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -62,9 +64,7 @@ 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 @@ -397,6 +397,18 @@ fun LiveTvPlayerScreen( modifier = Modifier.fillMaxSize(), ) + // Tik op scherm: info-overlay tonen/verbergen (mobiel) + if (!channelMenuOpen && error == null) { + Box( + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { infoOverlay = !infoOverlay }, + ) + } + LaunchedEffect(Unit) { delay(200) runCatching { playerFocus.requestFocus() } @@ -426,7 +438,37 @@ fun LiveTvPlayerScreen( Row( Modifier .align(Alignment.TopStart) - .padding(32.dp), + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0x55FFFFFF)) + .clickable(onClick = onBack) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text("← Terug", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0x55FFFFFF)) + .clickable { + infoOverlay = true + channelMenuOpen = true + } + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text("Zenders", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + } + + Row( + Modifier + .align(Alignment.TopStart) + .padding(start = 24.dp, top = 72.dp, end = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -493,7 +535,11 @@ fun LiveTvPlayerScreen( Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { channelMenuOpen = false }, ) LiveChannelSwitcher( categoryName = menuCategoryName, @@ -660,28 +706,18 @@ private fun LiveChannelSwitcher( val ch = channels[index] val isCurrent = ch.streamId == currentStreamId val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } - Surface( - onClick = { onSelect(ch.streamId) }, - modifier = Modifier + Row( + 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, - ), + .focusRequester(fr) + .clip(RoundedCornerShape(8.dp)) + .background(if (isCurrent) Color(0x33E50914) else Color.Transparent) + .clickable { onSelect(ch.streamId) } + .height(54.dp) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - Modifier - .fillMaxWidth() - .height(54.dp) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { Box( Modifier .width(3.dp) @@ -737,7 +773,6 @@ private fun LiveChannelSwitcher( overflow = TextOverflow.Ellipsis, ) } - } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 614c806..068b8af 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -15,6 +15,7 @@ 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.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -39,7 +40,6 @@ 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.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -70,6 +70,7 @@ import nl.vonas.mediacluster.tv.ui.components.PosterCard import nl.vonas.mediacluster.tv.ui.components.SubNavItem import nl.vonas.mediacluster.tv.ui.components.TopNavBar import nl.vonas.mediacluster.tv.ui.theme.McColors +import nl.vonas.mediacluster.tv.ui.theme.McDimens @Composable fun McNav() { @@ -646,8 +647,8 @@ private fun HomeScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 56.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), ) { item { SpotlightBillboard( @@ -674,7 +675,14 @@ private fun HomeScreen( ) } if (error != null) { - item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } + item { + Text( + error!!, + color = McColors.Danger, + fontSize = McDimens.bodySmall, + modifier = Modifier.padding(horizontal = McDimens.screenPad), + ) + } } val continueItems = home?.continueWatching.orEmpty() if (continueItems.isNotEmpty()) { @@ -736,15 +744,15 @@ private fun HomeScreen( ) { Column( Modifier - .width(440.dp) + .fillMaxWidth(0.9f) .clip(RoundedCornerShape(12.dp)) .background(Color(0xFF1A1A1A)) - .padding(28.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2) + Text(menuItem.title, color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold, maxLines = 2) if (!menuItem.subtitle.isNullOrBlank()) { - Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) + Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall) } Spacer(Modifier.height(4.dp)) McButton("Markeren als gezien") { @@ -774,7 +782,7 @@ private fun HomeScreen( continueMenu = null } } - McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null } + McButton("Annuleren", compact = true, focusRequester = menuFocus) { continueMenu = null } } } } @@ -791,6 +799,7 @@ private fun SpotlightBillboard( var index by remember { mutableStateOf(0) } val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) } val current = safeItems[index.coerceIn(0, safeItems.lastIndex)] + val billboardH = McDimens.billboardHeight() LaunchedEffect(safeItems.size) { if (safeItems.size <= 1) return@LaunchedEffect @@ -800,7 +809,7 @@ private fun SpotlightBillboard( } } - Box(Modifier.fillMaxWidth().height(480.dp)) { + Box(Modifier.fillMaxWidth().height(billboardH)) { AsyncImage( model = current.backdropUrl ?: current.posterUrl, contentDescription = current.title, @@ -810,81 +819,47 @@ private fun SpotlightBillboard( Box( Modifier.fillMaxSize().background( Brush.verticalGradient( - listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)), + listOf(Color(0x22000000), Color(0x66000000), Color(0xF20B0B0B)), ), ), ) - Box( - Modifier.fillMaxSize().background( - Brush.horizontalGradient( - listOf(Color(0xE60B0B0B), Color(0x880B0B0B), Color.Transparent), - ), - ), - ) - - // Peek van volgende posters rechts - if (safeItems.size > 1) { - Row( - Modifier - .align(Alignment.CenterEnd) - .padding(end = 36.dp), - horizontalArrangement = Arrangement.spacedBy((-28).dp), - ) { - val peek = listOf(1, 2).mapNotNull { off -> - safeItems.getOrNull((index + off) % safeItems.size) - } - peek.forEachIndexed { i, item -> - AsyncImage( - model = item.posterUrl ?: item.backdropUrl, - contentDescription = null, - modifier = Modifier - .width(110.dp) - .height(165.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFF1A1A1A)) - .graphicsLayer { alpha = if (i == 0) 0.9f else 0.5f }, - contentScale = ContentScale.Crop, - ) - } - } - } Column( Modifier .align(Alignment.BottomStart) - .fillMaxWidth(0.55f) - .padding(start = 48.dp, end = 24.dp, bottom = 36.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .fillMaxWidth() + .padding(horizontal = McDimens.screenPad, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Text( "VMC", color = McColors.Brand, - fontSize = 13.sp, + fontSize = 11.sp, fontWeight = FontWeight.Black, - letterSpacing = 2.sp, + letterSpacing = 1.5.sp, ) if (!current.badge.isNullOrBlank()) { Text( current.badge!!.uppercase(), color = Color.White, - fontSize = 12.sp, + fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier .clip(RoundedCornerShape(3.dp)) .background(McColors.Brand) - .padding(horizontal = 8.dp, vertical = 3.dp), + .padding(horizontal = 6.dp, vertical = 2.dp), ) } } Text( current.title, color = Color.White, - fontSize = 46.sp, + fontSize = McDimens.titleHero, fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis, - lineHeight = 50.sp, + lineHeight = 30.sp, ) val meta = listOfNotNull( current.year?.toString(), @@ -892,21 +867,21 @@ private fun SpotlightBillboard( current.subtitle?.takeIf { it.isNotBlank() }, ).joinToString(" · ") if (meta.isNotBlank()) { - Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp) + Text(meta, color = Color(0xFFCFCFCF), fontSize = McDimens.meta) } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (current.id.isNotBlank()) { - McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) }) - McButton("Meer info", onClick = { onOpen(current) }) + McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) }) + McButton("Meer info", compact = true, onClick = { onOpen(current) }) } } if (safeItems.size > 1) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.padding(top = 2.dp)) { safeItems.indices.forEach { i -> Box( Modifier - .height(4.dp) - .width(if (i == index) 22.dp else 8.dp) + .height(3.dp) + .width(if (i == index) 16.dp else 6.dp) .clip(RoundedCornerShape(2.dp)) .background(if (i == index) Color.White else Color(0x55FFFFFF)), ) @@ -926,30 +901,36 @@ private fun ShelfBlock( onClick: (MediaItem) -> Unit, onLongClick: ((MediaItem) -> Unit)? = null, ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + val posterW = McDimens.shelfPosterWidth() + val posterH = McDimens.posterHeight(posterW) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( - Modifier.fillMaxWidth().padding(horizontal = 48.dp), + Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( title, color = Color(0xFFE5E5E5), - fontSize = 20.sp, + fontSize = McDimens.titleShelf, fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) if (onSeeAll != null) { - McButton("Alles") { onSeeAll() } + McButton("Alles", compact = true) { onSeeAll() } } } - // contentPadding i.p.v. Column-startpadding: focus-scale mag in de marge tekenen LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp), + horizontalArrangement = Arrangement.spacedBy(McDimens.shelfGap), + contentPadding = PaddingValues(horizontal = McDimens.screenPad, vertical = 2.dp), ) { items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> PosterCard( item = item, + width = posterW, + height = posterH, onClick = { onClick(item) }, onLongClick = onLongClick?.let { handler -> { handler(item) } }, ) @@ -989,30 +970,34 @@ private fun SettingsScreen( } LazyColumn( - Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Modifier + .fillMaxSize() + .background(McColors.Bg) + .statusBarsPadding() + .padding(horizontal = McDimens.screenPad, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { - Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) - Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp) - if (error != null) Text(error!!, color = McColors.Danger) - Spacer(Modifier.height(8.dp)) - McButton("Uitloggen", onClick = onLogout) + Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold) + Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall) + if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) Spacer(Modifier.height(8.dp)) + McButton("Uitloggen", compact = true, onClick = onLogout) + Spacer(Modifier.height(4.dp)) } itemsIndexed(prefs) { index, p -> Row( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(10.dp)) .background(McColors.BgElevated) - .padding(16.dp), + .padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - McButton("↑") { + Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + McButton("↑", compact = true) { if (index > 0) { val next = prefs.toMutableList() val tmp = next[index - 1] @@ -1021,7 +1006,7 @@ private fun SettingsScreen( persist(next) } } - McButton("↓") { + McButton("↓", compact = true) { if (index < prefs.lastIndex) { val next = prefs.toMutableList() val tmp = next[index + 1] diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 95a3ce2..38f343f 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -6,18 +6,21 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import android.util.Base64 import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource 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.fillMaxHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -31,6 +34,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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 @@ -56,10 +60,9 @@ 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 java.io.File import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -74,9 +77,6 @@ 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 @@ -372,13 +372,17 @@ fun PlayerScreen( modifier = Modifier.fillMaxSize(), ) - // Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet + // Touch + D-pad catcher wanneer controls weg zijn if (!controls && menu == null && error == null) { Box( Modifier .fillMaxSize() .focusRequester(catcherFocus) - .focusable(), + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { showControls() }, ) } @@ -401,7 +405,15 @@ fun PlayerScreen( enter = fadeIn(), exit = fadeOut(), ) { - Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = menu == null, + ) { hideControls() }, + ) { // Top / bottom scrim zoals Netflix Box( Modifier @@ -430,21 +442,16 @@ fun PlayerScreen( // 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 + .align(Alignment.Center) + .size(88.dp) + .clip(CircleShape) + .background(Color(0x55FFFFFF)) + .clickable { togglePlay() }, + contentAlignment = Alignment.Center, ) { - Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) { - Text("▶", color = Color.Unspecified, fontSize = 36.sp) - } + Text("▶", color = Color.White, fontSize = 36.sp) } } @@ -621,44 +628,35 @@ private fun NetflixIconButton( 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) + .clip(CircleShape) + .background(if (primary) Color.White else Color(0x22FFFFFF)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, ) { - Box( - Modifier.size(52.dp), - contentAlignment = Alignment.Center, - ) { - Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold) - } + Text( + label, + color = if (primary) Color.Black else Color.White, + 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, - ), + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), ) { Text( label, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - color = Color.Unspecified, + color = Color(0xFFE5E5E5), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, ) @@ -668,31 +666,21 @@ private fun NetflixTextButton(label: String, onClick: () -> Unit) { @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() + .clip(RoundedCornerShape(4.dp)) + .background(if (selected) Color(0x22FFFFFF) else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, ) { - 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) - } + Text( + if (selected) "✓ " else " ", + color = McColors.Brand, + fontSize = 15.sp, + ) + Text(label, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium) } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt index 27e499b..4e23fd8 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt @@ -3,7 +3,6 @@ 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 @@ -11,6 +10,7 @@ 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.statusBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -26,14 +26,12 @@ 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 @@ -43,6 +41,7 @@ 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 +import nl.vonas.mediacluster.tv.ui.theme.McDimens @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -60,6 +59,7 @@ fun SearchScreen( var searchJob by remember { mutableStateOf(null) } val gridState = rememberLazyGridState() val focusManager = LocalFocusManager.current + val minCell = McDimens.gridMinCell() if (onAtTopBack != null) { BackHandler { @@ -98,12 +98,14 @@ fun SearchScreen( Modifier .fillMaxSize() .background(McColors.Bg) - .padding(horizontal = 16.dp, vertical = 8.dp), + .statusBarsPadding() + .padding(horizontal = McDimens.screenPad), ) { + Spacer(Modifier.height(8.dp)) Text( "Zoeken", color = Color.White, - fontSize = 22.sp, + fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(10.dp)) @@ -112,8 +114,10 @@ fun SearchScreen( onValueChange = { scheduleSearch(it) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - placeholder = { Text("Typ een titel…", color = McColors.TextDim) }, - shape = RoundedCornerShape(12.dp), + placeholder = { + androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim) + }, + shape = RoundedCornerShape(10.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), colors = OutlinedTextFieldDefaults.colors( @@ -128,24 +132,23 @@ fun SearchScreen( ) 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) + error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall) query.isNotBlank() && results.isEmpty() -> - Text("Geen resultaten", color = McColors.TextDim, fontSize = 14.sp) + Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall) } LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 110.dp), + columns = GridCells.Adaptive(minSize = minCell), state = gridState, - contentPadding = PaddingValues(bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), + verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), modifier = Modifier.fillMaxSize(), ) { items(results, key = { "${it.type}-${it.id}" }) { item -> PosterCard( item = item, - width = 110.dp, - height = 165.dp, + fillCell = true, onClick = { when (item.type) { "movie" -> onOpenMovie(item.id) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 13e2f54..f916ac9 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -3,18 +3,20 @@ 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.clickable 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.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -34,16 +36,13 @@ 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 @@ -54,16 +53,18 @@ 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.components.McButton import nl.vonas.mediacluster.tv.ui.theme.McColors +import nl.vonas.mediacluster.tv.ui.theme.McDimens 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 TvRowSelected = Color(0xFF242424) private val TvRowActive = Color(0xFF1C1C1C) private val TvDivider = Color(0xFF2A2A2A) -private val TvPanelBg = Color(0xFF101010) +private val TvPanelBg = Color(0xFF121212) @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -83,15 +84,13 @@ fun TvScreen( var loading by remember { mutableStateOf(true) } var loadingChannels by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - var focusedEpg by remember { mutableStateOf(null) } - var focusedStreamId by remember { mutableStateOf(null) } + var selectedEpg by remember { mutableStateOf(null) } + var selectedStreamId by remember { mutableStateOf(null) } var epgJob by remember { mutableStateOf(null) } - var focusInList by remember { mutableStateOf(false) } val channelListState = rememberLazyListState() val categoryRowState = rememberLazyListState() val scope = rememberCoroutineScope() val categoryFocus = remember { mutableStateMapOf() } - val channelFocus = remember { mutableStateMapOf() } val epgNowTitles = remember { mutableStateMapOf() } LaunchedEffect(Unit) { @@ -113,11 +112,25 @@ fun TvScreen( } } + fun loadEpg(streamId: Int) { + selectedStreamId = streamId + onLastStreamChange(streamId) + epgJob?.cancel() + epgJob = scope.launch { + delay(120) + runCatching { + val epg = api.iptvEpg(streamId) + selectedEpg = epg + epg.now?.title?.let { epgNowTitles[streamId] = it } + } + } + } + LaunchedEffect(selectedCategoryId) { val catId = selectedCategoryId ?: return@LaunchedEffect loadingChannels = true - focusedEpg = null - focusedStreamId = null + selectedEpg = null + selectedStreamId = null epgNowTitles.clear() try { val loaded = api.iptvChannels(catId, includeEpg = true) @@ -126,10 +139,7 @@ fun TvScreen( 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) } - } + focusId?.let { loadEpg(it) } } catch (e: Exception) { error = e.message channels = emptyList() @@ -138,176 +148,126 @@ fun TvScreen( } } - 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) + delay(80) 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 } - } + loadEpg(channelTarget) } 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() - } + BackHandler { 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)) { + Box( + Modifier + .fillMaxSize() + .background(McColors.Bg) + .statusBarsPadding(), + ) { when { loading -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("TV laden…", color = McColors.TextDim, fontSize = 15.sp) + Text("TV laden…", color = McColors.TextDim, fontSize = McDimens.body) } } 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) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("TV niet beschikbaar", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.SemiBold) + Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) } } } else -> { - Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = McDimens.screenPad), + ) { + Spacer(Modifier.height(8.dp)) + Text( + "Live TV", + color = Color.White, + fontSize = McDimens.titleScreen, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(8.dp)) CategoryTabRow( categories = categories, selectedId = selectedCategoryId, listState = categoryRowState, categoryFocus = categoryFocus, - onSelect = { - onCategoryChange(it) - focusInList = true - }, - onFocused = { - focusInList = false - onContentFocused() - }, + onSelect = { onCategoryChange(it) }, + onFocused = { onContentFocused() }, ) + Spacer(Modifier.height(10.dp)) - 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) + // Gids bovenaan — leesbaar op portrait + MobileEpgCard( + epg = selectedEpg, + onPlay = { + selectedStreamId?.let { id -> + onLastStreamChange(id) + onPlayChannel(id) } - 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) - }, - ) - } - } + }, + ) + + Spacer(Modifier.height(10.dp)) + Text( + "Tik om te selecteren · nogmaals om te kijken", + color = Color(0xFF777777), + fontSize = McDimens.caption, + ) + Spacer(Modifier.height(6.dp)) + + when { + loadingChannels -> { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("Zenders laden…", color = McColors.TextDim, fontSize = McDimens.bodySmall) + } + } + channels.isEmpty() -> { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("Geen zenders", color = McColors.TextDim, fontSize = McDimens.bodySmall) + } + } + else -> { + LazyColumn( + state = channelListState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(channels, key = { it.streamId }) { ch -> + ChannelRow( + channel = ch, + nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle, + isSelected = selectedStreamId == ch.streamId, + isLastWatched = ch.streamId == lastStreamId, + onClick = { + onContentFocused() + if (selectedStreamId == ch.streamId) { + onLastStreamChange(ch.streamId) + onPlayChannel(ch.streamId) + } else { + 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), - ) } } } @@ -328,50 +288,38 @@ private fun CategoryTabRow( LazyRow( state = listState, horizontalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(vertical = 4.dp), + contentPadding = PaddingValues(vertical = 2.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 + Column( + 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, - ), + .clip(RoundedCornerShape(4.dp)) + .clickable { + onFocused() + onSelect(cat.id) + } + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - 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), - ) - } + Text( + cat.name, + color = if (active) Color.White else Color(0xFF888888), + fontSize = 13.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Box( + Modifier + .height(2.dp) + .width(if (active) 20.dp else 0.dp) + .clip(RoundedCornerShape(1.dp)) + .background(if (active) McColors.Brand else Color.Transparent), + ) } } } @@ -382,160 +330,146 @@ private fun CategoryTabRow( private fun ChannelRow( channel: IptvChannel, nowTitle: String?, - isFocused: Boolean, + isSelected: Boolean, isLastWatched: Boolean, - modifier: Modifier = Modifier, onClick: () -> Unit, - onFocused: () -> Unit, ) { val bg by animateColorAsState( when { - isFocused -> TvRowFocus + isSelected -> TvRowSelected 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() + .clip(RoundedCornerShape(6.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( + Box( Modifier - .fillMaxWidth() - .height(52.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .width(3.dp) + .height(28.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + when { + isSelected -> McColors.Brand + isLastWatched -> McColors.Brand.copy(alpha = 0.45f) + else -> Color.Transparent + }, + ), + ) + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF0A0A0A)), + contentAlignment = Alignment.Center, ) { - 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 -> + if (!channel.logoUrl.isNullOrBlank()) { + AsyncImage( + model = channel.logoUrl, + contentDescription = channel.name, + modifier = Modifier.fillMaxSize().padding(3.dp), + contentScale = ContentScale.Fit, + ) + } else { Text( - "$num", + channel.name.take(2).uppercase(), color = McColors.TextDim, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.width(36.dp), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + channel.number?.let { num -> + Text( + "$num", + color = McColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(end = 6.dp), + ) + } + Text( + channel.name, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + if (!nowTitle.isNullOrBlank()) { + Text( + nowTitle, + color = if (isSelected) Color(0xFFCCCCCC) else McColors.TextDim, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } - 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), - ) } } } +@OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ChannelDetailPanel( +private fun MobileEpgCard( epg: IptvEpg?, - modifier: Modifier = Modifier, + onPlay: () -> Unit, ) { Column( - modifier - .clip(RoundedCornerShape(6.dp)) + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .clip(RoundedCornerShape(10.dp)) .background(TvPanelBg) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.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)) + Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { + Text("Selecteer een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall) + } return } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { if (!epg.logoUrl.isNullOrBlank()) { AsyncImage( model = epg.logoUrl, contentDescription = null, modifier = Modifier - .size(48.dp) + .size(40.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, - ) - } + Text( + epg.channelName, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + McButton("▶ Kijk", primary = true, compact = true, onClick = onPlay) } Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) @@ -566,37 +500,34 @@ private fun ChannelDetailPanel( } if (epg.now == null && epg.next == null) { - Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp) + Text("Geen programma-info", color = McColors.TextDim, fontSize = McDimens.meta) } - - 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)) { + Column(verticalArrangement = Arrangement.spacedBy(2.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, + letterSpacing = 0.8.sp, ) formatEpgTime(program.start, program.end)?.let { time -> - Text(time, color = McColors.TextDim, fontSize = 12.sp) + Text(time, color = McColors.TextDim, fontSize = 11.sp) } } Text( program.title, color = if (highlight) Color.White else Color(0xFFBBBBBB), - fontSize = if (highlight) 15.sp else 14.sp, + fontSize = if (highlight) 13.sp else 12.sp, fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal, - maxLines = 3, + maxLines = 2, overflow = TextOverflow.Ellipsis, - lineHeight = 20.sp, + lineHeight = 17.sp, ) } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index 36b2566..ff6d941 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -1,7 +1,11 @@ package nl.vonas.mediacluster.tv.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -9,36 +13,23 @@ 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 +import nl.vonas.mediacluster.tv.ui.theme.McDimens @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -46,35 +37,26 @@ fun McButton( label: String, modifier: Modifier = Modifier, primary: Boolean = false, + compact: 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, - ), + val hPad = if (compact) 12.dp else 16.dp + val vPad = if (compact) 8.dp else 10.dp + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = hPad, vertical = vPad), + contentAlignment = Alignment.Center, ) { Text( text = label.ifBlank { "—" }, - modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp), - color = Color.Unspecified, - fontSize = 15.sp, + color = fg, + fontSize = if (compact) 13.sp else 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -82,39 +64,16 @@ fun McButton( } } -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) +@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class) @Composable fun PosterCard( item: MediaItem, onClick: () -> Unit, modifier: Modifier = Modifier, - width: Dp = 110.dp, - height: Dp = 165.dp, + width: Dp? = null, + height: Dp? = null, + /** Vul de grid-cel; behoud 2:3 poster-aspect. */ + fillCell: Boolean = false, onLongClick: (() -> Unit)? = null, ) { val progress = if (item.durationMs > 0) { @@ -123,55 +82,46 @@ fun PosterCard( 0f } - val useKeyHandler = onLongClick != null - Surface( - onClick = if (useKeyHandler) ({}) else onClick, - onLongClick = null, + val shelfW = if (!fillCell && width == null) McDimens.shelfPosterWidth() else null + val resolvedW = width ?: shelfW + val resolvedH = height ?: resolvedW?.let { McDimens.posterHeight(it) } + + val sizeModifier = when { + fillCell -> Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + resolvedW != null && resolvedH != null -> Modifier + .width(resolvedW) + .height(resolvedH) + else -> Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + } + + Box( modifier = modifier - .width(width) - .then( - if (useKeyHandler) { - Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!) - } else { - Modifier - }, + .then(sizeModifier) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF1A1A1A)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, ), - 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, + 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(3.dp) + .background(McColors.Brand), ) - if (progress > 0.02f) { - Box( - Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(progress) - .height(4.dp) - .background(McColors.Brand), - ) - } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt new file mode 100644 index 0000000..a5b5a89 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt @@ -0,0 +1,77 @@ +package nl.vonas.mediacluster.tv.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Phone-first maatvoering. Posterbreedtes schalen met schermbreedte: + * groter scherm / hogere dichtheid → meer posters zichtbaar. + */ +object McDimens { + val screenPad: Dp = 16.dp + val screenPadTight: Dp = 12.dp + val shelfGap: Dp = 8.dp + val gridGap: Dp = 8.dp + + val titleHero: TextUnit = 26.sp + val titleScreen: TextUnit = 20.sp + val titleShelf: TextUnit = 15.sp + val body: TextUnit = 14.sp + val bodySmall: TextUnit = 13.sp + val meta: TextUnit = 12.sp + val caption: TextUnit = 11.sp + + val detailPosterW: Dp = 110.dp + val detailPosterH: Dp = 165.dp + val episodeStillW: Dp = 112.dp + val episodeStillH: Dp = 63.dp + + @Composable + fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp + + @Composable + fun screenHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp + + /** ~3.2–3.5 posters + peek in een shelf-rij. */ + @Composable + fun shelfPosterWidth(): Dp { + val w = screenWidth() + return remember(w) { + ((w - screenPad * 2) / 3.4f).coerceIn(78.dp, 118.dp) + } + } + + fun posterHeight(width: Dp): Dp = width * 1.5f + + /** + * Adaptive grid-cel: mikt op ~4 kolommen op typische phones, + * 5+ op grotere / tablet-achtige breedtes. + */ + @Composable + fun gridMinCell(): Dp { + val w = screenWidth() + return remember(w) { + val targetCols = when { + w.value >= 720f -> 6f + w.value >= 600f -> 5f + w.value >= 400f -> 4f + else -> 3.5f + } + ((w - screenPad * 2 - gridGap * (targetCols - 1)) / targetCols) + .coerceIn(72.dp, 130.dp) + } + } + + @Composable + fun billboardHeight(): Dp { + val h = screenHeight() + return remember(h) { + (h * 0.36f).coerceIn(200.dp, 280.dp) + } + } +} diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 2f3b9e2..6eeade0 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -412,33 +412,93 @@ async function getMovieCatalog( }); if (catalogId === "recent-movies" && !search) { - movies = await prisma.movie.findMany({ - where: { mediaFiles: { some: { available: true } } }, - orderBy: { createdAt: "desc" }, - skip, - take: CATALOG_PAGE, - include: { mediaFiles: { where: { available: true } } }, - }); + const idRows = await prisma.$queryRaw>` + SELECT mf.movie_id + FROM media_files mf + WHERE mf.available = true AND mf.movie_id IS NOT NULL + GROUP BY mf.movie_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.movie_id); + movies = ids.length + ? await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { mediaFiles: { where: { available: true } } }, + }).then((rows) => { + const byId = new Map(rows.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is NonNullable => !!m); + }) + : []; } else if (catalogId === "4k-movies") { - movies = await prisma.movie.findMany({ - where: { - ...(search ? { title: { contains: search, mode: "insensitive" } } : {}), - mediaFiles: { - some: { - available: true, - OR: [ - { resolution: { contains: "2160" } }, - { resolution: { contains: "4K", mode: "insensitive" } }, - { resolution: { contains: "UHD", mode: "insensitive" } }, - ], + if (!search) { + const idRows = await prisma.$queryRaw>` + SELECT mf.movie_id + FROM media_files mf + WHERE mf.available = true + AND mf.movie_id IS NOT NULL + AND ( + mf.resolution ILIKE '%2160%' + OR mf.resolution ILIKE '%4K%' + OR mf.resolution ILIKE '%UHD%' + ) + GROUP BY mf.movie_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.movie_id); + movies = ids.length + ? await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { mediaFiles: { where: { available: true } } }, + }).then((rows) => { + const byId = new Map(rows.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is NonNullable => !!m); + }) + : []; + } else { + movies = await prisma.movie.findMany({ + where: { + title: { contains: search, mode: "insensitive" }, + mediaFiles: { + some: { + available: true, + OR: [ + { resolution: { contains: "2160" } }, + { resolution: { contains: "4K", mode: "insensitive" } }, + { resolution: { contains: "UHD", mode: "insensitive" } }, + ], + }, }, }, - }, - orderBy: [{ title: "asc" }, { year: "asc" }], - skip, - take: CATALOG_PAGE, - include: { mediaFiles: { where: { available: true } } }, - }); + orderBy: [{ title: "asc" }, { year: "asc" }], + skip, + take: CATALOG_PAGE, + include: { mediaFiles: { where: { available: true } } }, + }); + } + } else if (catalogId === "movies" && !search) { + const idRows = await prisma.$queryRaw>` + SELECT mf.movie_id + FROM media_files mf + WHERE mf.available = true AND mf.movie_id IS NOT NULL + GROUP BY mf.movie_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.movie_id); + movies = ids.length + ? await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { mediaFiles: { where: { available: true } } }, + }).then((rows) => { + const byId = new Map(rows.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is NonNullable => !!m); + }) + : []; } return movies @@ -489,12 +549,41 @@ async function getSeriesCatalog( }); if (catalogId === "recent-series" && !search) { - series = await prisma.series.findMany({ - where: availableFilter, - orderBy: { createdAt: "desc" }, - skip, - take: CATALOG_PAGE, - }); + const idRows = await prisma.$queryRaw>` + SELECT e.series_id + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.available = true AND mf.episode_id IS NOT NULL + GROUP BY e.series_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.series_id); + series = ids.length + ? await prisma.series.findMany({ where: { id: { in: ids } } }).then((rows) => { + const byId = new Map(rows.map((s) => [s.id, s])); + return ids.map((id) => byId.get(id)).filter((s): s is NonNullable => !!s); + }) + : []; + } else if (catalogId === "series" && !search) { + const idRows = await prisma.$queryRaw>` + SELECT e.series_id + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.available = true AND mf.episode_id IS NOT NULL + GROUP BY e.series_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.series_id); + series = ids.length + ? await prisma.series.findMany({ where: { id: { in: ids } } }).then((rows) => { + const byId = new Map(rows.map((s) => [s.id, s])); + return ids.map((id) => byId.get(id)).filter((s): s is NonNullable => !!s); + }) + : []; } return series.map((s) => ({ diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 0c95099..00e167d 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -1,4 +1,5 @@ import { randomBytes } from "crypto"; +import { Prisma } from "@prisma/client"; import { prisma } from "../database/client"; import { generateSecureToken, @@ -421,22 +422,23 @@ export class ViewerService { /** Nieuwste titels (op mediabestand-datum) voor de home billboard. */ async spotlight(take: number) { - const [movieFiles, episodeFiles] = await Promise.all([ - prisma.mediaFile.findMany({ - where: { available: true, movieId: { not: null } }, - orderBy: { createdAt: "desc" }, - take: take * 3, - include: { movie: true }, - }), - prisma.mediaFile.findMany({ - where: { available: true, episodeId: { not: null } }, - orderBy: { createdAt: "desc" }, - take: take * 4, - include: { - episode: { include: { series: true } }, - }, - }), - ]); + const movieIds = await prisma.$queryRaw>` + SELECT mf.movie_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at + FROM media_files mf + WHERE mf.available = true AND mf.movie_id IS NOT NULL + GROUP BY mf.movie_id + ORDER BY added_at DESC + LIMIT ${take * 2} + `; + const seriesIds = await prisma.$queryRaw>` + SELECT e.series_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.available = true AND mf.episode_id IS NOT NULL + GROUP BY e.series_id + ORDER BY added_at DESC + LIMIT ${take * 2} + `; type Spot = { type: "movie" | "series"; @@ -451,41 +453,57 @@ export class ViewerService { }; const spots: Spot[] = []; - const seenMovies = new Set(); - for (const f of movieFiles) { - const m = f.movie; - if (!m || !f.movieId || seenMovies.has(f.movieId)) continue; - seenMovies.add(f.movieId); - spots.push({ - type: "movie", - id: m.id, - title: m.title, - year: m.year, - posterUrl: m.posterUrl, - backdropUrl: m.backdropUrl, - mediaFileId: f.id, - addedAt: f.createdAt.getTime(), - badge: "Nieuw", + if (movieIds.length) { + const movies = await prisma.movie.findMany({ + where: { id: { in: movieIds.map((r) => r.movie_id) } }, + include: { + mediaFiles: { + where: { available: true }, + orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], + take: 1, + }, + }, }); + const byId = new Map(movies.map((m) => [m.id, m])); + const added = new Map(movieIds.map((r) => [r.movie_id, r.added_at.getTime()])); + for (const row of movieIds) { + const m = byId.get(row.movie_id); + if (!m) continue; + spots.push({ + type: "movie", + id: m.id, + title: m.title, + year: m.year, + posterUrl: m.posterUrl, + backdropUrl: m.backdropUrl, + mediaFileId: m.mediaFiles[0]?.id ?? null, + addedAt: added.get(m.id) ?? 0, + badge: "Nieuw", + }); + } } - const seenSeries = new Set(); - for (const f of episodeFiles) { - const s = f.episode?.series; - const seriesId = f.episode?.seriesId; - if (!s || !seriesId || seenSeries.has(seriesId)) continue; - seenSeries.add(seriesId); - spots.push({ - type: "series", - id: s.id, - title: s.title, - year: s.year, - posterUrl: s.posterUrl, - backdropUrl: s.backdropUrl, - mediaFileId: null, - addedAt: f.createdAt.getTime(), - badge: "Nieuwe aflevering", + if (seriesIds.length) { + const series = await prisma.series.findMany({ + where: { id: { in: seriesIds.map((r) => r.series_id) } }, }); + const byId = new Map(series.map((s) => [s.id, s])); + const added = new Map(seriesIds.map((r) => [r.series_id, r.added_at.getTime()])); + for (const row of seriesIds) { + const s = byId.get(row.series_id); + if (!s) continue; + spots.push({ + type: "series", + id: s.id, + title: s.title, + year: s.year, + posterUrl: s.posterUrl, + backdropUrl: s.backdropUrl, + mediaFileId: null, + addedAt: added.get(s.id) ?? 0, + badge: "Nieuwe aflevering", + }); + } } return spots @@ -494,49 +512,103 @@ export class ViewerService { .map(({ addedAt: _a, ...rest }) => rest); } - async recentMovies(take: number) { - const files = await prisma.mediaFile.findMany({ - where: { available: true, movieId: { not: null } }, - orderBy: { createdAt: "desc" }, - take: Math.max(take * 3, take), - include: { movie: true }, + async recentMovies(take: number, skip = 0) { + const page = await this.moviesByRecency(skip, take); + return page.items; + } + + async recentSeries(take: number, skip = 0) { + const page = await this.seriesByRecency(skip, take); + return page.items; + } + + /** Films gesorteerd op echte mediadatum (bestand-mtime of eerste indexering). */ + private async moviesByRecency( + skip: number, + take: number, + extraWhereSql: Prisma.Sql = Prisma.empty + ) { + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count + FROM media_files mf + WHERE mf.available = true + AND mf.movie_id IS NOT NULL + ${extraWhereSql} + `; + const total = Number(countRow?.count ?? 0); + const idRows = await prisma.$queryRaw>` + SELECT mf.movie_id + FROM media_files mf + WHERE mf.available = true + AND mf.movie_id IS NOT NULL + ${extraWhereSql} + GROUP BY mf.movie_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${take} + `; + const ids = idRows.map((r) => r.movie_id); + if (!ids.length) return { total, items: [] }; + + const movies = await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { + mediaFiles: { + where: { available: true }, + orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], + take: 1, + }, + }, }); - const seen = new Set(); - const out: Array<{ - type: "movie"; + return { total, items: this.mapMovies(ids, movies) }; + } + + private mapMovies( + ids: string[], + movies: Array<{ id: string; title: string; year: number | null; posterUrl: string | null; backdropUrl: string | null; - mediaFileId: string | null; - }> = []; - for (const f of files) { - if (!f.movieId || !f.movie || seen.has(f.movieId)) continue; - seen.add(f.movieId); - out.push({ - type: "movie", - id: f.movie.id, - title: f.movie.title, - year: f.movie.year, - posterUrl: f.movie.posterUrl, - backdropUrl: f.movie.backdropUrl, - mediaFileId: f.id, - }); - if (out.length >= take) break; - } - return out; + mediaFiles: Array<{ id: string }>; + }> + ) { + const byId = new Map(movies.map((m) => [m.id, m])); + return ids + .map((id) => byId.get(id)) + .filter((m): m is NonNullable => !!m) + .map((m) => ({ + type: "movie" as const, + id: m.id, + title: m.title, + year: m.year, + posterUrl: m.posterUrl, + backdropUrl: m.backdropUrl, + mediaFileId: m.mediaFiles[0]?.id ?? null, + })); } - async recentSeries(take: number) { - const files = await prisma.mediaFile.findMany({ - where: { available: true, episodeId: { not: null } }, - orderBy: { createdAt: "desc" }, - take: Math.max(take * 5, take), - include: { episode: { include: { series: true } } }, - }); - const seen = new Set(); - const out: Array<{ + private async seriesByRecency(skip: number, take: number) { + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT e.series_id)::bigint AS count + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.available = true AND mf.episode_id IS NOT NULL + `; + const total = Number(countRow?.count ?? 0); + const idRows = await prisma.$queryRaw>` + SELECT e.series_id + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.available = true AND mf.episode_id IS NOT NULL + GROUP BY e.series_id + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + OFFSET ${skip} + LIMIT ${take} + `; + const ids = idRows.map((r) => r.series_id); + if (!ids.length) return { total, items: [] as Array<{ type: "series"; id: string; title: string; @@ -544,66 +616,38 @@ export class ViewerService { posterUrl: string | null; backdropUrl: string | null; mediaFileId: string | null; - }> = []; - for (const f of files) { - const series = f.episode?.series; - const seriesId = f.episode?.seriesId; - if (!series || !seriesId || seen.has(seriesId)) continue; - seen.add(seriesId); - out.push({ - type: "series", - id: series.id, - title: series.title, - year: series.year, - posterUrl: series.posterUrl, - backdropUrl: series.backdropUrl, - mediaFileId: null, - }); - if (out.length >= take) break; - } - return out; + }> }; + + const series = await prisma.series.findMany({ where: { id: { in: ids } } }); + const byId = new Map(series.map((s) => [s.id, s])); + return { + total, + items: ids + .map((id) => byId.get(id)) + .filter((s): s is NonNullable => !!s) + .map((s) => ({ + type: "series" as const, + id: s.id, + title: s.title, + year: s.year, + posterUrl: s.posterUrl, + backdropUrl: s.backdropUrl, + mediaFileId: null as string | null, + })), + }; } - async fourKMovies(take: number) { - const files = await prisma.mediaFile.findMany({ - where: { - available: true, - movieId: { not: null }, - OR: [ - { resolution: { contains: "2160" } }, - { resolution: { contains: "4K", mode: "insensitive" } }, - { resolution: { contains: "UHD", mode: "insensitive" } }, - ], - }, - orderBy: { createdAt: "desc" }, - take: Math.max(take * 3, take), - include: { movie: true }, - }); - const seen = new Set(); - const out: Array<{ - type: "movie"; - id: string; - title: string; - year: number | null; - posterUrl: string | null; - backdropUrl: string | null; - mediaFileId: string | null; - }> = []; - for (const f of files) { - if (!f.movieId || !f.movie || seen.has(f.movieId)) continue; - seen.add(f.movieId); - out.push({ - type: "movie", - id: f.movie.id, - title: f.movie.title, - year: f.movie.year, - posterUrl: f.movie.posterUrl, - backdropUrl: f.movie.backdropUrl, - mediaFileId: f.id, - }); - if (out.length >= take) break; - } - return out; + async fourKMovies(take: number, skip = 0) { + const page = await this.moviesByRecency( + skip, + take, + Prisma.sql`AND ( + mf.resolution ILIKE '%2160%' + OR mf.resolution ILIKE '%4K%' + OR mf.resolution ILIKE '%UHD%' + )` + ); + return page.items; } async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { @@ -634,7 +678,7 @@ export class ViewerService { AND mf.available = true AND mf.movie_id IS NOT NULL GROUP BY mf.movie_id - ORDER BY MAX(mf.created_at) DESC + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC OFFSET ${skip} LIMIT ${take} `; @@ -646,7 +690,7 @@ export class ViewerService { include: { mediaFiles: { where: { shelfId, available: true }, - orderBy: { createdAt: "desc" }, + orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], take: 1, }, }, @@ -686,7 +730,7 @@ export class ViewerService { AND mf.available = true AND mf.episode_id IS NOT NULL GROUP BY e.series_id - ORDER BY MAX(mf.created_at) DESC + ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC OFFSET ${skip} LIMIT ${take} `; @@ -964,92 +1008,65 @@ export class ViewerService { } if (id === "movies" || id === "films") { - const total = await prisma.movie.count({ - where: { mediaFiles: { some: { available: true } } }, - }); - const movies = await prisma.movie.findMany({ - where: { mediaFiles: { some: { available: true } } }, - orderBy: [{ title: "asc" }, { year: "asc" }], - skip, - take, - include: { - mediaFiles: { where: { available: true }, orderBy: { createdAt: "desc" }, take: 1 }, - }, - }); + const page = await this.moviesByRecency(skip, take); return { id, title: "Films", kind: "MOVIE" as const, - total, - items: movies.map((m) => ({ - type: "movie" as const, - id: m.id, - title: m.title, - year: m.year, - posterUrl: m.posterUrl, - backdropUrl: m.backdropUrl, - mediaFileId: m.mediaFiles[0]?.id ?? null, - })), + total: page.total, + items: page.items, }; } if (id === "series" || id === "shows") { - const total = await prisma.series.count({ - where: { episodes: { some: { mediaFiles: { some: { available: true } } } } }, - }); - const series = await prisma.series.findMany({ - where: { episodes: { some: { mediaFiles: { some: { available: true } } } } }, - orderBy: [{ title: "asc" }, { year: "asc" }], - skip, - take, - }); + const page = await this.seriesByRecency(skip, take); return { id, title: "Series", kind: "SERIES" as const, - total, - items: series.map((s) => ({ - type: "series" as const, - id: s.id, - title: s.title, - year: s.year, - posterUrl: s.posterUrl, - backdropUrl: s.backdropUrl, - mediaFileId: null as string | null, - })), + total: page.total, + items: page.items, }; } if (id === "recent-movies") { - const items = await this.recentMovies(skip + take); + const page = await this.moviesByRecency(skip, take); return { id, title: "Recent toegevoegd", kind: "MOVIE" as const, - total: items.length + skip, - items: items.slice(skip, skip + take), + total: page.total, + items: page.items, }; } if (id === "recent-series") { - const items = await this.recentSeries(skip + take); + const page = await this.seriesByRecency(skip, take); return { id, title: "Recent toegevoegde series", kind: "SERIES" as const, - total: items.length + skip, - items: items.slice(skip, skip + take), + total: page.total, + items: page.items, }; } if (id === "4k-movies" || id === "4k") { - const items = await this.fourKMovies(skip + take); + const page = await this.moviesByRecency( + skip, + take, + Prisma.sql`AND ( + mf.resolution ILIKE '%2160%' + OR mf.resolution ILIKE '%4K%' + OR mf.resolution ILIKE '%UHD%' + )` + ); return { id, title: "4K films", kind: "MOVIE" as const, - total: items.length + skip, - items: items.slice(skip, skip + take), + total: page.total, + items: page.items, }; }