Fix recent catalog sorting by file mtime and polish mobile UI.
Use GREATEST(last_seen, created_at) so Recent/Films/shelves show truly newest media after bulk imports, and ship phone-first layout with touch-friendly players. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a5e8e18b75
commit
421baae3d7
12 changed files with 1082 additions and 1045 deletions
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster"
|
applicationId = "nl.vonas.mediacluster"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 4
|
||||||
versionName = "1.0.0"
|
versionName = "1.0.3"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
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.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -66,6 +67,7 @@ fun BrowseScreen(
|
||||||
var loadingMore by remember { mutableStateOf(false) }
|
var loadingMore by remember { mutableStateOf(false) }
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val minCell = McDimens.gridMinCell()
|
||||||
|
|
||||||
suspend fun load(reset: Boolean) {
|
suspend fun load(reset: Boolean) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
|
@ -128,43 +130,48 @@ fun BrowseScreen(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(McColors.Bg)
|
.background(McColors.Bg)
|
||||||
.padding(horizontal = 36.dp, vertical = 20.dp),
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = McDimens.screenPad),
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = McDimens.titleScreen,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
|
if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 14.sp,
|
fontSize = McDimens.meta,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onBack != null) {
|
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) {
|
if (error != null) {
|
||||||
Text(error!!, color = McColors.Danger)
|
Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
||||||
}
|
}
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
columns = GridCells.Adaptive(minSize = minCell),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(McDimens.gridGap),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 140.dp,
|
fillCell = true,
|
||||||
height = 210.dp,
|
|
||||||
modifier = Modifier.padding(6.dp),
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val canResume = onPlayContinue != null &&
|
val canResume = onPlayContinue != null &&
|
||||||
!item.mediaFileId.isNullOrBlank() &&
|
!item.mediaFileId.isNullOrBlank() &&
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
package nl.vonas.mediacluster.tv.ui
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import androidx.compose.foundation.layout.padding
|
||||||
import android.content.Intent
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import android.net.Uri
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.activity.compose.BackHandler
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.background
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.padding
|
import android.content.ActivityNotFoundException
|
||||||
import androidx.compose.foundation.layout.width
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Glow
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
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.VersionQualityMark
|
||||||
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
|
import nl.vonas.mediacluster.tv.ui.components.parseMediaVersion
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
|
@ -164,8 +164,8 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
||||||
model = logoUrl,
|
model = logoUrl,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 280.dp)
|
||||||
.heightIn(max = 120.dp)
|
.heightIn(max = 72.dp)
|
||||||
.fillMaxWidth(0.85f),
|
.fillMaxWidth(0.85f),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
alignment = Alignment.CenterStart,
|
alignment = Alignment.CenterStart,
|
||||||
|
|
@ -174,11 +174,11 @@ private fun DetailLogoOrTitle(logoUrl: String?, title: String) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 40.sp,
|
fontSize = McDimens.titleHero,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 46.sp,
|
lineHeight = 30.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +198,7 @@ private fun MetaChips(parts: List<String>) {
|
||||||
Text(
|
Text(
|
||||||
part,
|
part,
|
||||||
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
|
color = if (part.startsWith("★")) Color(0xFFFFD54F) else McColors.TextDim,
|
||||||
fontSize = 14.sp,
|
fontSize = McDimens.meta,
|
||||||
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
|
fontWeight = if (part.startsWith("★")) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -208,28 +208,15 @@ private fun MetaChips(parts: List<String>) {
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailBackButton(onClick: () -> Unit) {
|
private fun DetailBackButton(onClick: () -> Unit) {
|
||||||
Surface(
|
Box(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
.clickable(onClick = onClick)
|
||||||
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
focusedContainerColor = Color(0x33FFFFFF),
|
|
||||||
contentColor = Color(0xFFCFCFCF),
|
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
border = ClickableSurfaceDefaults.border(
|
|
||||||
focusedBorder = Border(
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Terug",
|
"Terug",
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
color = Color(0xFFCFCFCF),
|
||||||
color = Color.Unspecified,
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
|
@ -245,39 +232,18 @@ private fun DetailActionButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bg = if (primary) Color.White else Color(0xE6181818)
|
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
|
val fg = if (primary) Color.Black else Color.White
|
||||||
Surface(
|
Box(
|
||||||
onClick = onClick,
|
modifier = Modifier
|
||||||
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
.background(bg)
|
||||||
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
.clickable(onClick = onClick)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
containerColor = bg,
|
|
||||||
focusedContainerColor = bgFocus,
|
|
||||||
contentColor = fg,
|
|
||||||
focusedContentColor = Color.Black,
|
|
||||||
),
|
|
||||||
border = if (primary) {
|
|
||||||
ClickableSurfaceDefaults.border()
|
|
||||||
} else {
|
|
||||||
ClickableSurfaceDefaults.border(
|
|
||||||
border = Border(
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
),
|
|
||||||
focusedBorder = Border(
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
color = fg,
|
||||||
color = Color.Unspecified,
|
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -368,26 +334,49 @@ fun MovieDetailScreen(
|
||||||
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
|
val playLabel = selected?.let { "Afspelen · ${it.shortLabel}" } ?: "Afspelen"
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
DetailBackdrop(backdrop)
|
DetailBackdrop(backdrop)
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxSize()
|
||||||
.fillMaxWidth(0.58f)
|
.statusBarsPadding()
|
||||||
.padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
|
.verticalScroll(scroll)
|
||||||
.verticalScroll(scroll),
|
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
DetailBackButton(onClick = onBack)
|
DetailBackButton(onClick = onBack)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
Row(
|
||||||
MetaChips(metaParts)
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
SelectedQualityLine(selected)
|
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()) {
|
if (director.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Regie: ${director.take(2).joinToString(", ")}",
|
"Regie: ${director.take(2).joinToString(", ")}",
|
||||||
color = Color(0xFF9A9A9A),
|
color = Color(0xFF9A9A9A),
|
||||||
fontSize = 13.sp,
|
fontSize = McDimens.meta,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
@ -396,24 +385,27 @@ fun MovieDetailScreen(
|
||||||
Text(
|
Text(
|
||||||
overview,
|
overview,
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 15.sp,
|
fontSize = McDimens.bodySmall,
|
||||||
maxLines = 4,
|
maxLines = 6,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 21.sp,
|
lineHeight = 18.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (cast.isNotEmpty()) {
|
if (cast.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Met: ${cast.take(4).joinToString(", ")}",
|
"Met: ${cast.take(4).joinToString(", ")}",
|
||||||
color = Color(0xFF8A8A8A),
|
color = Color(0xFF8A8A8A),
|
||||||
fontSize = 13.sp,
|
fontSize = McDimens.meta,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
selected?.let { ver ->
|
selected?.let { ver ->
|
||||||
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
|
DetailActionButton(playLabel, primary = true, focusRequester = playFocus) {
|
||||||
onPlay(ver.id, title, 0)
|
onPlay(ver.id, title, 0)
|
||||||
|
|
@ -432,17 +424,16 @@ fun MovieDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (versions.size > 1) {
|
if (versions.size > 1) {
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
Text(
|
||||||
"Kwaliteit",
|
"Kwaliteit",
|
||||||
color = Color(0xFF8F8F8F),
|
color = Color(0xFF8F8F8F),
|
||||||
fontSize = 11.sp,
|
fontSize = McDimens.caption,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
letterSpacing = 0.6.sp,
|
letterSpacing = 0.6.sp,
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(end = 8.dp, bottom = 10.dp),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
) {
|
) {
|
||||||
items(versions, key = { it.id }) { ver ->
|
items(versions, key = { it.id }) { ver ->
|
||||||
VersionPickChip(
|
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,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(5.dp)
|
val shape = RoundedCornerShape(5.dp)
|
||||||
Surface(
|
Box(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = shape),
|
.clip(shape)
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
.background(if (selected) Color(0xEE222222) else Color(0x99101010))
|
||||||
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
.clickable(onClick = onClick)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.padding(1.dp),
|
||||||
containerColor = if (selected) Color(0xEE222222) else Color(0x99101010),
|
|
||||||
focusedContainerColor = Color(0xFF2C2C2C),
|
|
||||||
contentColor = Color.White,
|
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
border = ClickableSurfaceDefaults.border(
|
|
||||||
border = Border(
|
|
||||||
border = androidx.compose.foundation.BorderStroke(
|
|
||||||
if (selected) 1.5.dp else 1.dp,
|
|
||||||
if (selected) Color.White else Color(0x33FFFFFF),
|
|
||||||
),
|
|
||||||
shape = shape,
|
|
||||||
),
|
|
||||||
focusedBorder = Border(
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.5.dp, Color.White),
|
|
||||||
shape = shape,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
VersionQualityMark(version = version, selected = selected)
|
VersionQualityMark(version = version, selected = selected)
|
||||||
}
|
}
|
||||||
|
|
@ -592,30 +552,36 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
DetailBackdrop(backdrop)
|
DetailBackdrop(backdrop)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp),
|
contentPadding = PaddingValues(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
start = McDimens.screenPad,
|
||||||
|
end = McDimens.screenPad,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 28.dp,
|
||||||
|
),
|
||||||
|
modifier = Modifier.statusBarsPadding(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
DetailBackButton(onClick = onBack)
|
DetailBackButton(onClick = onBack)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
DetailLogoOrTitle(logoUrl, title)
|
||||||
MetaChips(metaParts)
|
MetaChips(metaParts)
|
||||||
if (overview.isNotBlank()) {
|
if (overview.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
overview,
|
overview,
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 15.sp,
|
fontSize = McDimens.bodySmall,
|
||||||
maxLines = 4,
|
maxLines = 5,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 22.sp,
|
lineHeight = 18.sp,
|
||||||
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
episodes.firstOrNull()?.let { ep ->
|
episodes.firstOrNull()?.let { ep ->
|
||||||
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) {
|
DetailActionButton("Afspelen", primary = true, focusRequester = firstEpFocus) {
|
||||||
|
|
@ -627,8 +593,8 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seasons.size > 1) {
|
if (seasons.size > 1) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("Seizoenen", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(seasons) { s ->
|
items(seasons) { s ->
|
||||||
|
|
@ -641,9 +607,9 @@ fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
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(
|
EpisodeCard(
|
||||||
episode = ep,
|
episode = ep,
|
||||||
focusRequester = null,
|
focusRequester = null,
|
||||||
|
|
@ -674,59 +640,48 @@ private fun EpisodeCard(
|
||||||
focusRequester: FocusRequester?,
|
focusRequester: FocusRequester?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Row(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp))
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
.background(Color(0xCC141414))
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.clickable(onClick = onClick)
|
||||||
containerColor = Color(0xCC141414),
|
.padding(8.dp),
|
||||||
focusedContainerColor = Color(0xFF222222),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
contentColor = Color.White,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
AsyncImage(
|
||||||
Modifier
|
model = episode.stillUrl,
|
||||||
.fillMaxWidth()
|
contentDescription = null,
|
||||||
.padding(10.dp),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
.width(McDimens.episodeStillW)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.height(McDimens.episodeStillH)
|
||||||
) {
|
.clip(RoundedCornerShape(5.dp))
|
||||||
AsyncImage(
|
.background(Color(0xFF1A1A1A)),
|
||||||
model = episode.stillUrl,
|
contentScale = ContentScale.Crop,
|
||||||
contentDescription = null,
|
)
|
||||||
modifier = Modifier
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
.width(160.dp)
|
Text(
|
||||||
.height(90.dp)
|
"S${episode.season}E${episode.number} · ${episode.title}",
|
||||||
.clip(RoundedCornerShape(6.dp))
|
color = Color.White,
|
||||||
.background(Color(0xFF1A1A1A)),
|
fontSize = McDimens.bodySmall,
|
||||||
contentScale = ContentScale.Crop,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
if (episode.overview.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
"S${episode.season}E${episode.number} · ${episode.title}",
|
episode.overview,
|
||||||
color = Color.White,
|
color = McColors.TextDim,
|
||||||
fontSize = 16.sp,
|
fontSize = McDimens.meta,
|
||||||
fontWeight = FontWeight.SemiBold,
|
maxLines = 2,
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
lineHeight = 15.sp,
|
||||||
)
|
)
|
||||||
if (episode.overview.isNotBlank()) {
|
}
|
||||||
Text(
|
formatRuntime(episode.runtime)?.let {
|
||||||
episode.overview,
|
Text(it, color = Color(0xFF888888), fontSize = McDimens.caption)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -62,9 +64,7 @@ import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -397,6 +397,18 @@ fun LiveTvPlayerScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
delay(200)
|
delay(200)
|
||||||
runCatching { playerFocus.requestFocus() }
|
runCatching { playerFocus.requestFocus() }
|
||||||
|
|
@ -426,7 +438,37 @@ fun LiveTvPlayerScreen(
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.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),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
@ -493,7 +535,11 @@ fun LiveTvPlayerScreen(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = 0.4f)),
|
.background(Color.Black.copy(alpha = 0.4f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { channelMenuOpen = false },
|
||||||
)
|
)
|
||||||
LiveChannelSwitcher(
|
LiveChannelSwitcher(
|
||||||
categoryName = menuCategoryName,
|
categoryName = menuCategoryName,
|
||||||
|
|
@ -660,28 +706,18 @@ private fun LiveChannelSwitcher(
|
||||||
val ch = channels[index]
|
val ch = channels[index]
|
||||||
val isCurrent = ch.streamId == currentStreamId
|
val isCurrent = ch.streamId == currentStreamId
|
||||||
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
||||||
Surface(
|
Row(
|
||||||
onClick = { onSelect(ch.streamId) },
|
Modifier
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 10.dp)
|
.padding(horizontal = 10.dp)
|
||||||
.focusRequester(fr),
|
.focusRequester(fr)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp))
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
.background(if (isCurrent) Color(0x33E50914) else Color.Transparent)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.clickable { onSelect(ch.streamId) }
|
||||||
containerColor = if (isCurrent) Color(0x33E50914) else Color.Transparent,
|
.height(54.dp)
|
||||||
focusedContainerColor = Color(0x28FFFFFF),
|
.padding(horizontal = 10.dp),
|
||||||
contentColor = Color.White,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(54.dp)
|
|
||||||
.padding(horizontal = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(3.dp)
|
.width(3.dp)
|
||||||
|
|
@ -737,7 +773,6 @@ private fun LiveChannelSwitcher(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.SubNavItem
|
||||||
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
|
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun McNav() {
|
fun McNav() {
|
||||||
|
|
@ -646,8 +647,8 @@ private fun HomeScreen(
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 56.dp),
|
contentPadding = PaddingValues(bottom = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SpotlightBillboard(
|
SpotlightBillboard(
|
||||||
|
|
@ -674,7 +675,14 @@ private fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (error != null) {
|
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()
|
val continueItems = home?.continueWatching.orEmpty()
|
||||||
if (continueItems.isNotEmpty()) {
|
if (continueItems.isNotEmpty()) {
|
||||||
|
|
@ -736,15 +744,15 @@ private fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.width(440.dp)
|
.fillMaxWidth(0.9f)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(Color(0xFF1A1A1A))
|
.background(Color(0xFF1A1A1A))
|
||||||
.padding(28.dp),
|
.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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()) {
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
McButton("Markeren als gezien") {
|
McButton("Markeren als gezien") {
|
||||||
|
|
@ -774,7 +782,7 @@ private fun HomeScreen(
|
||||||
continueMenu = null
|
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) }
|
var index by remember { mutableStateOf(0) }
|
||||||
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
|
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
|
||||||
val current = safeItems[index.coerceIn(0, safeItems.lastIndex)]
|
val current = safeItems[index.coerceIn(0, safeItems.lastIndex)]
|
||||||
|
val billboardH = McDimens.billboardHeight()
|
||||||
|
|
||||||
LaunchedEffect(safeItems.size) {
|
LaunchedEffect(safeItems.size) {
|
||||||
if (safeItems.size <= 1) return@LaunchedEffect
|
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(
|
AsyncImage(
|
||||||
model = current.backdropUrl ?: current.posterUrl,
|
model = current.backdropUrl ?: current.posterUrl,
|
||||||
contentDescription = current.title,
|
contentDescription = current.title,
|
||||||
|
|
@ -810,81 +819,47 @@ private fun SpotlightBillboard(
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth(0.55f)
|
.fillMaxWidth()
|
||||||
.padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
|
.padding(horizontal = McDimens.screenPad, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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(
|
Text(
|
||||||
"VMC",
|
"VMC",
|
||||||
color = McColors.Brand,
|
color = McColors.Brand,
|
||||||
fontSize = 13.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
letterSpacing = 2.sp,
|
letterSpacing = 1.5.sp,
|
||||||
)
|
)
|
||||||
if (!current.badge.isNullOrBlank()) {
|
if (!current.badge.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
current.badge!!.uppercase(),
|
current.badge!!.uppercase(),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 12.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(McColors.Brand)
|
.background(McColors.Brand)
|
||||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
current.title,
|
current.title,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 46.sp,
|
fontSize = McDimens.titleHero,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 50.sp,
|
lineHeight = 30.sp,
|
||||||
)
|
)
|
||||||
val meta = listOfNotNull(
|
val meta = listOfNotNull(
|
||||||
current.year?.toString(),
|
current.year?.toString(),
|
||||||
|
|
@ -892,21 +867,21 @@ private fun SpotlightBillboard(
|
||||||
current.subtitle?.takeIf { it.isNotBlank() },
|
current.subtitle?.takeIf { it.isNotBlank() },
|
||||||
).joinToString(" · ")
|
).joinToString(" · ")
|
||||||
if (meta.isNotBlank()) {
|
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()) {
|
if (current.id.isNotBlank()) {
|
||||||
McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
|
McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) })
|
||||||
McButton("Meer info", onClick = { onOpen(current) })
|
McButton("Meer info", compact = true, onClick = { onOpen(current) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (safeItems.size > 1) {
|
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 ->
|
safeItems.indices.forEach { i ->
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.height(4.dp)
|
.height(3.dp)
|
||||||
.width(if (i == index) 22.dp else 8.dp)
|
.width(if (i == index) 16.dp else 6.dp)
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
.background(if (i == index) Color.White else Color(0x55FFFFFF)),
|
.background(if (i == index) Color.White else Color(0x55FFFFFF)),
|
||||||
)
|
)
|
||||||
|
|
@ -926,30 +901,36 @@ private fun ShelfBlock(
|
||||||
onClick: (MediaItem) -> Unit,
|
onClick: (MediaItem) -> Unit,
|
||||||
onLongClick: ((MediaItem) -> Unit)? = null,
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 48.dp),
|
Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
color = Color(0xFFE5E5E5),
|
color = Color(0xFFE5E5E5),
|
||||||
fontSize = 20.sp,
|
fontSize = McDimens.titleShelf,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (onSeeAll != null) {
|
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(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(McDimens.shelfGap),
|
||||||
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp),
|
contentPadding = PaddingValues(horizontal = McDimens.screenPad, vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
|
width = posterW,
|
||||||
|
height = posterH,
|
||||||
onClick = { onClick(item) },
|
onClick = { onClick(item) },
|
||||||
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
||||||
)
|
)
|
||||||
|
|
@ -989,30 +970,34 @@ private fun SettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.fillMaxSize()
|
||||||
|
.background(McColors.Bg)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold)
|
||||||
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
|
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
McButton("Uitloggen", onClick = onLogout)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
McButton("Uitloggen", compact = true, onClick = onLogout)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
itemsIndexed(prefs) { index, p ->
|
itemsIndexed(prefs) { index, p ->
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(McColors.BgElevated)
|
.background(McColors.BgElevated)
|
||||||
.padding(16.dp),
|
.padding(12.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
McButton("↑") {
|
McButton("↑", compact = true) {
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index - 1]
|
val tmp = next[index - 1]
|
||||||
|
|
@ -1021,7 +1006,7 @@ private fun SettingsScreen(
|
||||||
persist(next)
|
persist(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
McButton("↓") {
|
McButton("↓", compact = true) {
|
||||||
if (index < prefs.lastIndex) {
|
if (index < prefs.lastIndex) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index + 1]
|
val tmp = next[index + 1]
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,21 @@ import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import android.util.Base64
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
@ -31,6 +34,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -56,10 +60,9 @@ import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
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.playback.selectTrack
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import android.util.Base64
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -372,13 +372,17 @@ fun PlayerScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
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) {
|
if (!controls && menu == null && error == null) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.focusRequester(catcherFocus)
|
.focusRequester(catcherFocus)
|
||||||
.focusable(),
|
.focusable()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { showControls() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,7 +405,15 @@ fun PlayerScreen(
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
enabled = menu == null,
|
||||||
|
) { hideControls() },
|
||||||
|
) {
|
||||||
// Top / bottom scrim zoals Netflix
|
// Top / bottom scrim zoals Netflix
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -430,21 +442,16 @@ fun PlayerScreen(
|
||||||
|
|
||||||
// Grote play/pause in het midden als gepauzeerd
|
// Grote play/pause in het midden als gepauzeerd
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
Surface(
|
Box(
|
||||||
onClick = { togglePlay() },
|
Modifier
|
||||||
modifier = Modifier.align(Alignment.Center),
|
.align(Alignment.Center)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
.size(88.dp)
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
.clip(CircleShape)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.background(Color(0x55FFFFFF))
|
||||||
containerColor = Color(0x55FFFFFF),
|
.clickable { togglePlay() },
|
||||||
focusedContainerColor = Color.White,
|
contentAlignment = Alignment.Center,
|
||||||
contentColor = Color.White,
|
|
||||||
focusedContentColor = Color.Black,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) {
|
Text("▶", color = Color.White, fontSize = 36.sp)
|
||||||
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,44 +628,35 @@ private fun NetflixIconButton(
|
||||||
primary: Boolean = false,
|
primary: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
.size(52.dp)
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.15f),
|
.clip(CircleShape)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.background(if (primary) Color.White else Color(0x22FFFFFF))
|
||||||
containerColor = if (primary) Color.White else Color(0x22FFFFFF),
|
.clickable(onClick = onClick),
|
||||||
focusedContainerColor = Color.White,
|
contentAlignment = Alignment.Center,
|
||||||
contentColor = if (primary) Color.Black else Color.White,
|
|
||||||
focusedContentColor = Color.Black,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Text(
|
||||||
Modifier.size(52.dp),
|
label,
|
||||||
contentAlignment = Alignment.Center,
|
color = if (primary) Color.Black else Color.White,
|
||||||
) {
|
fontSize = 14.sp,
|
||||||
Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
fontWeight = FontWeight.Bold,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NetflixTextButton(label: String, onClick: () -> Unit) {
|
private fun NetflixTextButton(label: String, onClick: () -> Unit) {
|
||||||
Surface(
|
Box(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
.clickable(onClick = onClick)
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
containerColor = Color.Transparent,
|
|
||||||
focusedContainerColor = Color(0x33FFFFFF),
|
|
||||||
contentColor = Color(0xFFE5E5E5),
|
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
color = Color(0xFFE5E5E5),
|
||||||
color = Color.Unspecified,
|
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
@ -668,31 +666,21 @@ private fun NetflixTextButton(label: String, onClick: () -> Unit) {
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Surface(
|
Row(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.fillMaxWidth()
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
.clip(RoundedCornerShape(4.dp))
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
.background(if (selected) Color(0x22FFFFFF) else Color.Transparent)
|
||||||
containerColor = if (selected) Color(0x22FFFFFF) else Color.Transparent,
|
.clickable(onClick = onClick)
|
||||||
focusedContainerColor = Color(0x33FFFFFF),
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
contentColor = Color.White,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
Modifier
|
if (selected) "✓ " else " ",
|
||||||
.fillMaxWidth()
|
color = McColors.Brand,
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
fontSize = 15.sp,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
)
|
||||||
) {
|
Text(label, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||||
Text(
|
|
||||||
if (selected) "✓ " else " ",
|
|
||||||
color = McColors.Brand,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
)
|
|
||||||
Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.Job
|
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.data.MediaItem
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -60,6 +59,7 @@ fun SearchScreen(
|
||||||
var searchJob by remember { mutableStateOf<Job?>(null) }
|
var searchJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val minCell = McDimens.gridMinCell()
|
||||||
|
|
||||||
if (onAtTopBack != null) {
|
if (onAtTopBack != null) {
|
||||||
BackHandler {
|
BackHandler {
|
||||||
|
|
@ -98,12 +98,14 @@ fun SearchScreen(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(McColors.Bg)
|
.background(McColors.Bg)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = McDimens.screenPad),
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Zoeken",
|
"Zoeken",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 22.sp,
|
fontSize = McDimens.titleScreen,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
@ -112,8 +114,10 @@ fun SearchScreen(
|
||||||
onValueChange = { scheduleSearch(it) },
|
onValueChange = { scheduleSearch(it) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("Typ een titel…", color = McColors.TextDim) },
|
placeholder = {
|
||||||
shape = RoundedCornerShape(12.dp),
|
androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
|
@ -128,24 +132,23 @@ fun SearchScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
when {
|
when {
|
||||||
error != null -> Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
||||||
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = 14.sp)
|
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
query.isNotBlank() && results.isEmpty() ->
|
query.isNotBlank() && results.isEmpty() ->
|
||||||
Text("Geen resultaten", color = McColors.TextDim, fontSize = 14.sp)
|
Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
}
|
}
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 110.dp),
|
columns = GridCells.Adaptive(minSize = minCell),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(McDimens.gridGap),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(results, key = { "${it.type}-${it.id}" }) { item ->
|
items(results, key = { "${it.type}-${it.id}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 110.dp,
|
fillCell = true,
|
||||||
height = 165.dp,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
"movie" -> onOpenMovie(item.id)
|
"movie" -> onOpenMovie(item.id)
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.Job
|
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.IptvCategory
|
||||||
import nl.vonas.mediacluster.tv.data.IptvChannel
|
import nl.vonas.mediacluster.tv.data.IptvChannel
|
||||||
import nl.vonas.mediacluster.tv.data.IptvEpg
|
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.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
private val TvRowBg = Color(0xFF141414)
|
private val TvRowBg = Color(0xFF141414)
|
||||||
private val TvRowFocus = Color(0xFF242424)
|
private val TvRowSelected = Color(0xFF242424)
|
||||||
private val TvRowActive = Color(0xFF1C1C1C)
|
private val TvRowActive = Color(0xFF1C1C1C)
|
||||||
private val TvDivider = Color(0xFF2A2A2A)
|
private val TvDivider = Color(0xFF2A2A2A)
|
||||||
private val TvPanelBg = Color(0xFF101010)
|
private val TvPanelBg = Color(0xFF121212)
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -83,15 +84,13 @@ fun TvScreen(
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var loadingChannels by remember { mutableStateOf(false) }
|
var loadingChannels by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
var selectedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||||
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
|
var selectedStreamId by remember { mutableStateOf<Int?>(null) }
|
||||||
var epgJob by remember { mutableStateOf<Job?>(null) }
|
var epgJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var focusInList by remember { mutableStateOf(false) }
|
|
||||||
val channelListState = rememberLazyListState()
|
val channelListState = rememberLazyListState()
|
||||||
val categoryRowState = rememberLazyListState()
|
val categoryRowState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
|
||||||
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
|
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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) {
|
LaunchedEffect(selectedCategoryId) {
|
||||||
val catId = selectedCategoryId ?: return@LaunchedEffect
|
val catId = selectedCategoryId ?: return@LaunchedEffect
|
||||||
loadingChannels = true
|
loadingChannels = true
|
||||||
focusedEpg = null
|
selectedEpg = null
|
||||||
focusedStreamId = null
|
selectedStreamId = null
|
||||||
epgNowTitles.clear()
|
epgNowTitles.clear()
|
||||||
try {
|
try {
|
||||||
val loaded = api.iptvChannels(catId, includeEpg = true)
|
val loaded = api.iptvChannels(catId, includeEpg = true)
|
||||||
|
|
@ -126,10 +139,7 @@ fun TvScreen(
|
||||||
titles.forEach { (id, title) -> epgNowTitles[id] = title }
|
titles.forEach { (id, title) -> epgNowTitles[id] = title }
|
||||||
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
|
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
|
||||||
?: loaded.firstOrNull()?.streamId
|
?: loaded.firstOrNull()?.streamId
|
||||||
focusId?.let { id ->
|
focusId?.let { loadEpg(it) }
|
||||||
focusedStreamId = id
|
|
||||||
runCatching { focusedEpg = api.iptvEpg(id) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
channels = emptyList()
|
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) {
|
LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
|
||||||
if (loading || categories.isEmpty()) return@LaunchedEffect
|
if (loading || categories.isEmpty()) return@LaunchedEffect
|
||||||
delay(120)
|
delay(80)
|
||||||
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
|
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
|
||||||
if (channelTarget != null) {
|
if (channelTarget != null) {
|
||||||
val idx = channels.indexOfFirst { it.streamId == channelTarget }
|
val idx = channels.indexOfFirst { it.streamId == channelTarget }
|
||||||
if (idx >= 0) channelListState.scrollToItem(idx)
|
if (idx >= 0) channelListState.scrollToItem(idx)
|
||||||
channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } }
|
loadEpg(channelTarget)
|
||||||
focusInList = true
|
|
||||||
focusedStreamId = channelTarget
|
|
||||||
runCatching {
|
|
||||||
val epg = api.iptvEpg(channelTarget)
|
|
||||||
focusedEpg = epg
|
|
||||||
epg.now?.title?.let { epgNowTitles[channelTarget] = it }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
|
val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
|
||||||
val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
|
val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
|
||||||
categoryRowState.scrollToItem(catIdx)
|
categoryRowState.scrollToItem(catIdx)
|
||||||
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
|
||||||
focusInList = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = focusInList) {
|
BackHandler { onAtTopBack() }
|
||||||
val catId = selectedCategoryId ?: categories.firstOrNull()?.id
|
|
||||||
if (catId != null) {
|
|
||||||
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
|
||||||
focusInList = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackHandler(enabled = !focusInList) {
|
|
||||||
onAtTopBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadEpg(streamId: Int) {
|
Box(
|
||||||
focusedStreamId = streamId
|
Modifier
|
||||||
epgJob?.cancel()
|
.fillMaxSize()
|
||||||
epgJob = scope.launch {
|
.background(McColors.Bg)
|
||||||
delay(200)
|
.statusBarsPadding(),
|
||||||
runCatching {
|
) {
|
||||||
val epg = api.iptvEpg(streamId)
|
|
||||||
focusedEpg = epg
|
|
||||||
epg.now?.title?.let { epgNowTitles[streamId] = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
|
||||||
when {
|
when {
|
||||||
loading -> {
|
loading -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
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() -> {
|
error != null && categories.isEmpty() -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(
|
||||||
Text("TV niet beschikbaar", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
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 -> {
|
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(
|
CategoryTabRow(
|
||||||
categories = categories,
|
categories = categories,
|
||||||
selectedId = selectedCategoryId,
|
selectedId = selectedCategoryId,
|
||||||
listState = categoryRowState,
|
listState = categoryRowState,
|
||||||
categoryFocus = categoryFocus,
|
categoryFocus = categoryFocus,
|
||||||
onSelect = {
|
onSelect = { onCategoryChange(it) },
|
||||||
onCategoryChange(it)
|
onFocused = { onContentFocused() },
|
||||||
focusInList = true
|
|
||||||
},
|
|
||||||
onFocused = {
|
|
||||||
focusInList = false
|
|
||||||
onContentFocused()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
// Gids bovenaan — leesbaar op portrait
|
||||||
Box(
|
MobileEpgCard(
|
||||||
Modifier
|
epg = selectedEpg,
|
||||||
.fillMaxWidth()
|
onPlay = {
|
||||||
.height(1.dp)
|
selectedStreamId?.let { id ->
|
||||||
.background(TvDivider),
|
onLastStreamChange(id)
|
||||||
)
|
onPlayChannel(id)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
Column(Modifier.weight(0.58f).fillMaxHeight()) {
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
|
||||||
Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
}
|
||||||
when {
|
},
|
||||||
loadingChannels -> {
|
)
|
||||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
|
||||||
Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp)
|
Spacer(Modifier.height(10.dp))
|
||||||
}
|
Text(
|
||||||
}
|
"Tik om te selecteren · nogmaals om te kijken",
|
||||||
channels.isEmpty() -> {
|
color = Color(0xFF777777),
|
||||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
fontSize = McDimens.caption,
|
||||||
Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp)
|
)
|
||||||
}
|
Spacer(Modifier.height(6.dp))
|
||||||
}
|
|
||||||
else -> {
|
when {
|
||||||
LazyColumn(
|
loadingChannels -> {
|
||||||
state = channelListState,
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.weight(1f),
|
Text("Zenders laden…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
contentPadding = PaddingValues(bottom = 12.dp),
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
}
|
||||||
) {
|
channels.isEmpty() -> {
|
||||||
items(channels, key = { it.streamId }) { ch ->
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
Text("Geen zenders", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
ChannelRow(
|
}
|
||||||
channel = ch,
|
}
|
||||||
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
|
else -> {
|
||||||
isFocused = focusedStreamId == ch.streamId,
|
LazyColumn(
|
||||||
isLastWatched = ch.streamId == lastStreamId,
|
state = channelListState,
|
||||||
modifier = Modifier.focusRequester(fr),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
contentPadding = PaddingValues(bottom = 12.dp),
|
||||||
onLastStreamChange(ch.streamId)
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
onPlayChannel(ch.streamId)
|
) {
|
||||||
},
|
items(channels, key = { it.streamId }) { ch ->
|
||||||
onFocused = {
|
ChannelRow(
|
||||||
focusInList = true
|
channel = ch,
|
||||||
onContentFocused()
|
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
|
||||||
onLastStreamChange(ch.streamId)
|
isSelected = selectedStreamId == ch.streamId,
|
||||||
loadEpg(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(
|
LazyRow(
|
||||||
state = listState,
|
state = listState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
contentPadding = PaddingValues(vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
items(categories, key = { it.id }) { cat ->
|
items(categories, key = { it.id }) { cat ->
|
||||||
val active = cat.id == selectedId
|
val active = cat.id == selectedId
|
||||||
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
|
val fr = categoryFocus.getOrPut(cat.id) { FocusRequester() }
|
||||||
val textColor by animateColorAsState(
|
Column(
|
||||||
if (active) Color.White else Color(0xFF888888),
|
Modifier
|
||||||
label = "tabText",
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
onClick = { onSelect(cat.id) },
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(fr)
|
.focusRequester(fr)
|
||||||
.onFocusChanged { if (it.isFocused) onFocused() },
|
.clip(RoundedCornerShape(4.dp))
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clickable {
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
onFocused()
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
onSelect(cat.id)
|
||||||
containerColor = Color.Transparent,
|
}
|
||||||
focusedContainerColor = Color(0x18FFFFFF),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
contentColor = textColor,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Text(
|
||||||
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
cat.name,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
color = if (active) Color.White else Color(0xFF888888),
|
||||||
) {
|
fontSize = 13.sp,
|
||||||
Text(
|
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
cat.name,
|
maxLines = 1,
|
||||||
color = Color.Unspecified,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontSize = 14.sp,
|
)
|
||||||
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
|
Spacer(Modifier.height(4.dp))
|
||||||
maxLines = 1,
|
Box(
|
||||||
overflow = TextOverflow.Ellipsis,
|
Modifier
|
||||||
)
|
.height(2.dp)
|
||||||
Spacer(Modifier.height(5.dp))
|
.width(if (active) 20.dp else 0.dp)
|
||||||
Box(
|
.clip(RoundedCornerShape(1.dp))
|
||||||
Modifier
|
.background(if (active) McColors.Brand else Color.Transparent),
|
||||||
.height(2.dp)
|
)
|
||||||
.width(if (active) 24.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(
|
private fun ChannelRow(
|
||||||
channel: IptvChannel,
|
channel: IptvChannel,
|
||||||
nowTitle: String?,
|
nowTitle: String?,
|
||||||
isFocused: Boolean,
|
isSelected: Boolean,
|
||||||
isLastWatched: Boolean,
|
isLastWatched: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onFocused: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val bg by animateColorAsState(
|
val bg by animateColorAsState(
|
||||||
when {
|
when {
|
||||||
isFocused -> TvRowFocus
|
isSelected -> TvRowSelected
|
||||||
isLastWatched -> TvRowActive
|
isLastWatched -> TvRowActive
|
||||||
else -> TvRowBg
|
else -> TvRowBg
|
||||||
},
|
},
|
||||||
label = "rowBg",
|
label = "rowBg",
|
||||||
)
|
)
|
||||||
Surface(
|
Row(
|
||||||
onClick = onClick,
|
Modifier
|
||||||
modifier = modifier.onFocusChanged { state ->
|
.fillMaxWidth()
|
||||||
if (state.isFocused) onFocused()
|
.clip(RoundedCornerShape(6.dp))
|
||||||
},
|
.background(bg)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clickable(onClick = onClick)
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
containerColor = bg,
|
|
||||||
focusedContainerColor = TvRowFocus,
|
|
||||||
contentColor = Color.White,
|
|
||||||
focusedContentColor = Color.White,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.width(3.dp)
|
||||||
.height(52.dp)
|
.height(28.dp)
|
||||||
.padding(horizontal = 8.dp),
|
.clip(RoundedCornerShape(2.dp))
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.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(
|
if (!channel.logoUrl.isNullOrBlank()) {
|
||||||
Modifier
|
AsyncImage(
|
||||||
.width(3.dp)
|
model = channel.logoUrl,
|
||||||
.height(28.dp)
|
contentDescription = channel.name,
|
||||||
.clip(RoundedCornerShape(2.dp))
|
modifier = Modifier.fillMaxSize().padding(3.dp),
|
||||||
.background(
|
contentScale = ContentScale.Fit,
|
||||||
when {
|
)
|
||||||
isFocused -> McColors.Brand
|
} else {
|
||||||
isLastWatched -> McColors.Brand.copy(alpha = 0.45f)
|
|
||||||
else -> Color.Transparent
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(Color(0xFF0A0A0A)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (!channel.logoUrl.isNullOrBlank()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = channel.logoUrl,
|
|
||||||
contentDescription = channel.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(4.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
channel.name.take(2).uppercase(),
|
|
||||||
color = McColors.TextDim,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
channel.number?.let { num ->
|
|
||||||
Text(
|
Text(
|
||||||
"$num",
|
channel.name.take(2).uppercase(),
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 13.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.width(36.dp),
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun ChannelDetailPanel(
|
private fun MobileEpgCard(
|
||||||
epg: IptvEpg?,
|
epg: IptvEpg?,
|
||||||
modifier: Modifier = Modifier,
|
onPlay: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 120.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(TvPanelBg)
|
.background(TvPanelBg)
|
||||||
.padding(16.dp),
|
.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (epg == null) {
|
if (epg == null) {
|
||||||
Spacer(Modifier.weight(1f))
|
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text("Selecteer een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
"Selecteer een zender",
|
}
|
||||||
color = McColors.TextDim,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
if (!epg.logoUrl.isNullOrBlank()) {
|
if (!epg.logoUrl.isNullOrBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = epg.logoUrl,
|
model = epg.logoUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(40.dp)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(Color(0xFF0A0A0A)),
|
.background(Color(0xFF0A0A0A)),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column {
|
Text(
|
||||||
Text(
|
epg.channelName,
|
||||||
epg.channelName,
|
color = Color.White,
|
||||||
color = Color.White,
|
fontSize = 15.sp,
|
||||||
fontSize = 16.sp,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.SemiBold,
|
maxLines = 1,
|
||||||
maxLines = 2,
|
overflow = TextOverflow.Ellipsis,
|
||||||
overflow = TextOverflow.Ellipsis,
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
McButton("▶ Kijk", primary = true, compact = true, onClick = onPlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
|
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
|
||||||
|
|
@ -566,37 +500,34 @@ private fun ChannelDetailPanel(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (epg.now == null && epg.next == null) {
|
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
|
@Composable
|
||||||
private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) {
|
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) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
label.uppercase(),
|
label.uppercase(),
|
||||||
color = if (highlight) McColors.Brand else McColors.TextDim,
|
color = if (highlight) McColors.Brand else McColors.TextDim,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.sp,
|
letterSpacing = 0.8.sp,
|
||||||
)
|
)
|
||||||
formatEpgTime(program.start, program.end)?.let { time ->
|
formatEpgTime(program.start, program.end)?.let { time ->
|
||||||
Text(time, color = McColors.TextDim, fontSize = 12.sp)
|
Text(time, color = McColors.TextDim, fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
program.title,
|
program.title,
|
||||||
color = if (highlight) Color.White else Color(0xFFBBBBBB),
|
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,
|
fontWeight = if (highlight) FontWeight.Medium else FontWeight.Normal,
|
||||||
maxLines = 3,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 20.sp,
|
lineHeight = 17.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package nl.vonas.mediacluster.tv.ui.components
|
package nl.vonas.mediacluster.tv.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Glow
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -46,35 +37,26 @@ fun McButton(
|
||||||
label: String,
|
label: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
primary: Boolean = false,
|
primary: Boolean = false,
|
||||||
|
compact: Boolean = false,
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bg = if (primary) Color.White else Color(0x33FFFFFF)
|
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 fg = if (primary) Color.Black else Color.White
|
||||||
val fgFocus = Color.Black
|
val hPad = if (compact) 12.dp else 16.dp
|
||||||
Surface(
|
val vPad = if (compact) 8.dp else 10.dp
|
||||||
onClick = onClick,
|
Box(
|
||||||
modifier = modifier.then(
|
modifier = modifier
|
||||||
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
.clip(RoundedCornerShape(4.dp))
|
||||||
),
|
.background(bg)
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
.clickable(onClick = onClick)
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
.padding(horizontal = hPad, vertical = vPad),
|
||||||
glow = ClickableSurfaceDefaults.glow(
|
contentAlignment = Alignment.Center,
|
||||||
focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
|
|
||||||
),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = bg,
|
|
||||||
focusedContainerColor = bgFocus,
|
|
||||||
contentColor = fg,
|
|
||||||
focusedContentColor = fgFocus,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label.ifBlank { "—" },
|
text = label.ifBlank { "—" },
|
||||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
|
color = fg,
|
||||||
color = Color.Unspecified,
|
fontSize = if (compact) 13.sp else 14.sp,
|
||||||
fontSize = 15.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
@ -82,39 +64,16 @@ fun McButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Key.isTvSelectKey(): Boolean =
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class)
|
||||||
this == Key.DirectionCenter || this == Key.Enter || this == Key.NumPadEnter
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Modifier.tvShortOrLongPress(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongPress: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
var downAt by remember { mutableLongStateOf(0L) }
|
|
||||||
return this.onPreviewKeyEvent { ev ->
|
|
||||||
when {
|
|
||||||
ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
|
|
||||||
downAt = System.currentTimeMillis()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
|
|
||||||
val held = System.currentTimeMillis() - downAt
|
|
||||||
if (held >= 450) onLongPress() else onClick()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PosterCard(
|
fun PosterCard(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
width: Dp = 110.dp,
|
width: Dp? = null,
|
||||||
height: Dp = 165.dp,
|
height: Dp? = null,
|
||||||
|
/** Vul de grid-cel; behoud 2:3 poster-aspect. */
|
||||||
|
fillCell: Boolean = false,
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val progress = if (item.durationMs > 0) {
|
val progress = if (item.durationMs > 0) {
|
||||||
|
|
@ -123,55 +82,46 @@ fun PosterCard(
|
||||||
0f
|
0f
|
||||||
}
|
}
|
||||||
|
|
||||||
val useKeyHandler = onLongClick != null
|
val shelfW = if (!fillCell && width == null) McDimens.shelfPosterWidth() else null
|
||||||
Surface(
|
val resolvedW = width ?: shelfW
|
||||||
onClick = if (useKeyHandler) ({}) else onClick,
|
val resolvedH = height ?: resolvedW?.let { McDimens.posterHeight(it) }
|
||||||
onLongClick = null,
|
|
||||||
|
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
|
modifier = modifier
|
||||||
.width(width)
|
.then(sizeModifier)
|
||||||
.then(
|
.clip(RoundedCornerShape(4.dp))
|
||||||
if (useKeyHandler) {
|
.background(Color(0xFF1A1A1A))
|
||||||
Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!)
|
.combinedClickable(
|
||||||
} else {
|
onClick = onClick,
|
||||||
Modifier
|
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(
|
AsyncImage(
|
||||||
Modifier
|
model = item.posterUrl ?: item.backdropUrl,
|
||||||
.fillMaxWidth()
|
contentDescription = item.title,
|
||||||
.height(height)
|
modifier = Modifier.fillMaxSize(),
|
||||||
.clip(RoundedCornerShape(4.dp))
|
contentScale = ContentScale.Crop,
|
||||||
.background(Color(0xFF1A1A1A)),
|
)
|
||||||
) {
|
if (progress > 0.02f) {
|
||||||
AsyncImage(
|
Box(
|
||||||
model = item.posterUrl ?: item.backdropUrl,
|
Modifier
|
||||||
contentDescription = item.title,
|
.align(Alignment.BottomStart)
|
||||||
modifier = Modifier.fillMaxSize(),
|
.fillMaxWidth(progress)
|
||||||
contentScale = ContentScale.Crop,
|
.height(3.dp)
|
||||||
|
.background(McColors.Brand),
|
||||||
)
|
)
|
||||||
if (progress > 0.02f) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.fillMaxWidth(progress)
|
|
||||||
.height(4.dp)
|
|
||||||
.background(McColors.Brand),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -412,33 +412,93 @@ async function getMovieCatalog(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (catalogId === "recent-movies" && !search) {
|
if (catalogId === "recent-movies" && !search) {
|
||||||
movies = await prisma.movie.findMany({
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
where: { mediaFiles: { some: { available: true } } },
|
SELECT mf.movie_id
|
||||||
orderBy: { createdAt: "desc" },
|
FROM media_files mf
|
||||||
skip,
|
WHERE mf.available = true AND mf.movie_id IS NOT NULL
|
||||||
take: CATALOG_PAGE,
|
GROUP BY mf.movie_id
|
||||||
include: { mediaFiles: { where: { available: true } } },
|
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<typeof m> => !!m);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
} else if (catalogId === "4k-movies") {
|
} else if (catalogId === "4k-movies") {
|
||||||
movies = await prisma.movie.findMany({
|
if (!search) {
|
||||||
where: {
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
|
SELECT mf.movie_id
|
||||||
mediaFiles: {
|
FROM media_files mf
|
||||||
some: {
|
WHERE mf.available = true
|
||||||
available: true,
|
AND mf.movie_id IS NOT NULL
|
||||||
OR: [
|
AND (
|
||||||
{ resolution: { contains: "2160" } },
|
mf.resolution ILIKE '%2160%'
|
||||||
{ resolution: { contains: "4K", mode: "insensitive" } },
|
OR mf.resolution ILIKE '%4K%'
|
||||||
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
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<typeof m> => !!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" }],
|
||||||
orderBy: [{ title: "asc" }, { year: "asc" }],
|
skip,
|
||||||
skip,
|
take: CATALOG_PAGE,
|
||||||
take: CATALOG_PAGE,
|
include: { mediaFiles: { where: { available: true } } },
|
||||||
include: { mediaFiles: { where: { available: true } } },
|
});
|
||||||
});
|
}
|
||||||
|
} else if (catalogId === "movies" && !search) {
|
||||||
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
|
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<typeof m> => !!m);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return movies
|
return movies
|
||||||
|
|
@ -489,12 +549,41 @@ async function getSeriesCatalog(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (catalogId === "recent-series" && !search) {
|
if (catalogId === "recent-series" && !search) {
|
||||||
series = await prisma.series.findMany({
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
where: availableFilter,
|
SELECT e.series_id
|
||||||
orderBy: { createdAt: "desc" },
|
FROM media_files mf
|
||||||
skip,
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
take: CATALOG_PAGE,
|
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<typeof s> => !!s);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
} else if (catalogId === "series" && !search) {
|
||||||
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
|
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<typeof s> => !!s);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return series.map((s) => ({
|
return series.map((s) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
import {
|
import {
|
||||||
generateSecureToken,
|
generateSecureToken,
|
||||||
|
|
@ -421,22 +422,23 @@ export class ViewerService {
|
||||||
|
|
||||||
/** Nieuwste titels (op mediabestand-datum) voor de home billboard. */
|
/** Nieuwste titels (op mediabestand-datum) voor de home billboard. */
|
||||||
async spotlight(take: number) {
|
async spotlight(take: number) {
|
||||||
const [movieFiles, episodeFiles] = await Promise.all([
|
const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
|
||||||
prisma.mediaFile.findMany({
|
SELECT mf.movie_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at
|
||||||
where: { available: true, movieId: { not: null } },
|
FROM media_files mf
|
||||||
orderBy: { createdAt: "desc" },
|
WHERE mf.available = true AND mf.movie_id IS NOT NULL
|
||||||
take: take * 3,
|
GROUP BY mf.movie_id
|
||||||
include: { movie: true },
|
ORDER BY added_at DESC
|
||||||
}),
|
LIMIT ${take * 2}
|
||||||
prisma.mediaFile.findMany({
|
`;
|
||||||
where: { available: true, episodeId: { not: null } },
|
const seriesIds = await prisma.$queryRaw<Array<{ series_id: string; added_at: Date }>>`
|
||||||
orderBy: { createdAt: "desc" },
|
SELECT e.series_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at
|
||||||
take: take * 4,
|
FROM media_files mf
|
||||||
include: {
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
episode: { include: { series: true } },
|
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 Spot = {
|
||||||
type: "movie" | "series";
|
type: "movie" | "series";
|
||||||
|
|
@ -451,41 +453,57 @@ export class ViewerService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const spots: Spot[] = [];
|
const spots: Spot[] = [];
|
||||||
const seenMovies = new Set<string>();
|
if (movieIds.length) {
|
||||||
for (const f of movieFiles) {
|
const movies = await prisma.movie.findMany({
|
||||||
const m = f.movie;
|
where: { id: { in: movieIds.map((r) => r.movie_id) } },
|
||||||
if (!m || !f.movieId || seenMovies.has(f.movieId)) continue;
|
include: {
|
||||||
seenMovies.add(f.movieId);
|
mediaFiles: {
|
||||||
spots.push({
|
where: { available: true },
|
||||||
type: "movie",
|
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||||||
id: m.id,
|
take: 1,
|
||||||
title: m.title,
|
},
|
||||||
year: m.year,
|
},
|
||||||
posterUrl: m.posterUrl,
|
|
||||||
backdropUrl: m.backdropUrl,
|
|
||||||
mediaFileId: f.id,
|
|
||||||
addedAt: f.createdAt.getTime(),
|
|
||||||
badge: "Nieuw",
|
|
||||||
});
|
});
|
||||||
|
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<string>();
|
if (seriesIds.length) {
|
||||||
for (const f of episodeFiles) {
|
const series = await prisma.series.findMany({
|
||||||
const s = f.episode?.series;
|
where: { id: { in: seriesIds.map((r) => r.series_id) } },
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
|
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
|
return spots
|
||||||
|
|
@ -494,49 +512,103 @@ export class ViewerService {
|
||||||
.map(({ addedAt: _a, ...rest }) => rest);
|
.map(({ addedAt: _a, ...rest }) => rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentMovies(take: number) {
|
async recentMovies(take: number, skip = 0) {
|
||||||
const files = await prisma.mediaFile.findMany({
|
const page = await this.moviesByRecency(skip, take);
|
||||||
where: { available: true, movieId: { not: null } },
|
return page.items;
|
||||||
orderBy: { createdAt: "desc" },
|
}
|
||||||
take: Math.max(take * 3, take),
|
|
||||||
include: { movie: true },
|
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<Array<{ count: bigint }>>`
|
||||||
|
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<Array<{ movie_id: string }>>`
|
||||||
|
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<string>();
|
return { total, items: this.mapMovies(ids, movies) };
|
||||||
const out: Array<{
|
}
|
||||||
type: "movie";
|
|
||||||
|
private mapMovies(
|
||||||
|
ids: string[],
|
||||||
|
movies: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
backdropUrl: string | null;
|
backdropUrl: string | null;
|
||||||
mediaFileId: string | null;
|
mediaFiles: Array<{ id: string }>;
|
||||||
}> = [];
|
}>
|
||||||
for (const f of files) {
|
) {
|
||||||
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
|
const byId = new Map(movies.map((m) => [m.id, m]));
|
||||||
seen.add(f.movieId);
|
return ids
|
||||||
out.push({
|
.map((id) => byId.get(id))
|
||||||
type: "movie",
|
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||||
id: f.movie.id,
|
.map((m) => ({
|
||||||
title: f.movie.title,
|
type: "movie" as const,
|
||||||
year: f.movie.year,
|
id: m.id,
|
||||||
posterUrl: f.movie.posterUrl,
|
title: m.title,
|
||||||
backdropUrl: f.movie.backdropUrl,
|
year: m.year,
|
||||||
mediaFileId: f.id,
|
posterUrl: m.posterUrl,
|
||||||
});
|
backdropUrl: m.backdropUrl,
|
||||||
if (out.length >= take) break;
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
||||||
}
|
}));
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentSeries(take: number) {
|
private async seriesByRecency(skip: number, take: number) {
|
||||||
const files = await prisma.mediaFile.findMany({
|
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||||
where: { available: true, episodeId: { not: null } },
|
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
|
||||||
orderBy: { createdAt: "desc" },
|
FROM media_files mf
|
||||||
take: Math.max(take * 5, take),
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
include: { episode: { include: { series: true } } },
|
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||||
});
|
`;
|
||||||
const seen = new Set<string>();
|
const total = Number(countRow?.count ?? 0);
|
||||||
const out: Array<{
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
|
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";
|
type: "series";
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -544,66 +616,38 @@ export class ViewerService {
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
backdropUrl: string | null;
|
backdropUrl: string | null;
|
||||||
mediaFileId: string | null;
|
mediaFileId: string | null;
|
||||||
}> = [];
|
}> };
|
||||||
for (const f of files) {
|
|
||||||
const series = f.episode?.series;
|
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||||||
const seriesId = f.episode?.seriesId;
|
const byId = new Map(series.map((s) => [s.id, s]));
|
||||||
if (!series || !seriesId || seen.has(seriesId)) continue;
|
return {
|
||||||
seen.add(seriesId);
|
total,
|
||||||
out.push({
|
items: ids
|
||||||
type: "series",
|
.map((id) => byId.get(id))
|
||||||
id: series.id,
|
.filter((s): s is NonNullable<typeof s> => !!s)
|
||||||
title: series.title,
|
.map((s) => ({
|
||||||
year: series.year,
|
type: "series" as const,
|
||||||
posterUrl: series.posterUrl,
|
id: s.id,
|
||||||
backdropUrl: series.backdropUrl,
|
title: s.title,
|
||||||
mediaFileId: null,
|
year: s.year,
|
||||||
});
|
posterUrl: s.posterUrl,
|
||||||
if (out.length >= take) break;
|
backdropUrl: s.backdropUrl,
|
||||||
}
|
mediaFileId: null as string | null,
|
||||||
return out;
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fourKMovies(take: number) {
|
async fourKMovies(take: number, skip = 0) {
|
||||||
const files = await prisma.mediaFile.findMany({
|
const page = await this.moviesByRecency(
|
||||||
where: {
|
skip,
|
||||||
available: true,
|
take,
|
||||||
movieId: { not: null },
|
Prisma.sql`AND (
|
||||||
OR: [
|
mf.resolution ILIKE '%2160%'
|
||||||
{ resolution: { contains: "2160" } },
|
OR mf.resolution ILIKE '%4K%'
|
||||||
{ resolution: { contains: "4K", mode: "insensitive" } },
|
OR mf.resolution ILIKE '%UHD%'
|
||||||
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
)`
|
||||||
],
|
);
|
||||||
},
|
return page.items;
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: Math.max(take * 3, take),
|
|
||||||
include: { movie: true },
|
|
||||||
});
|
|
||||||
const seen = new Set<string>();
|
|
||||||
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 shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
||||||
|
|
@ -634,7 +678,7 @@ export class ViewerService {
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.movie_id IS NOT NULL
|
AND mf.movie_id IS NOT NULL
|
||||||
GROUP BY mf.movie_id
|
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}
|
OFFSET ${skip}
|
||||||
LIMIT ${take}
|
LIMIT ${take}
|
||||||
`;
|
`;
|
||||||
|
|
@ -646,7 +690,7 @@ export class ViewerService {
|
||||||
include: {
|
include: {
|
||||||
mediaFiles: {
|
mediaFiles: {
|
||||||
where: { shelfId, available: true },
|
where: { shelfId, available: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -686,7 +730,7 @@ export class ViewerService {
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.episode_id IS NOT NULL
|
AND mf.episode_id IS NOT NULL
|
||||||
GROUP BY e.series_id
|
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}
|
OFFSET ${skip}
|
||||||
LIMIT ${take}
|
LIMIT ${take}
|
||||||
`;
|
`;
|
||||||
|
|
@ -964,92 +1008,65 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "movies" || id === "films") {
|
if (id === "movies" || id === "films") {
|
||||||
const total = await prisma.movie.count({
|
const page = await this.moviesByRecency(skip, take);
|
||||||
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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Films",
|
title: "Films",
|
||||||
kind: "MOVIE" as const,
|
kind: "MOVIE" as const,
|
||||||
total,
|
total: page.total,
|
||||||
items: movies.map((m) => ({
|
items: page.items,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "series" || id === "shows") {
|
if (id === "series" || id === "shows") {
|
||||||
const total = await prisma.series.count({
|
const page = await this.seriesByRecency(skip, take);
|
||||||
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,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Series",
|
title: "Series",
|
||||||
kind: "SERIES" as const,
|
kind: "SERIES" as const,
|
||||||
total,
|
total: page.total,
|
||||||
items: series.map((s) => ({
|
items: page.items,
|
||||||
type: "series" as const,
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
year: s.year,
|
|
||||||
posterUrl: s.posterUrl,
|
|
||||||
backdropUrl: s.backdropUrl,
|
|
||||||
mediaFileId: null as string | null,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "recent-movies") {
|
if (id === "recent-movies") {
|
||||||
const items = await this.recentMovies(skip + take);
|
const page = await this.moviesByRecency(skip, take);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Recent toegevoegd",
|
title: "Recent toegevoegd",
|
||||||
kind: "MOVIE" as const,
|
kind: "MOVIE" as const,
|
||||||
total: items.length + skip,
|
total: page.total,
|
||||||
items: items.slice(skip, skip + take),
|
items: page.items,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "recent-series") {
|
if (id === "recent-series") {
|
||||||
const items = await this.recentSeries(skip + take);
|
const page = await this.seriesByRecency(skip, take);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Recent toegevoegde series",
|
title: "Recent toegevoegde series",
|
||||||
kind: "SERIES" as const,
|
kind: "SERIES" as const,
|
||||||
total: items.length + skip,
|
total: page.total,
|
||||||
items: items.slice(skip, skip + take),
|
items: page.items,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "4k-movies" || id === "4k") {
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "4K films",
|
title: "4K films",
|
||||||
kind: "MOVIE" as const,
|
kind: "MOVIE" as const,
|
||||||
total: items.length + skip,
|
total: page.total,
|
||||||
items: items.slice(skip, skip + take),
|
items: page.items,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue