Fix TV continue-watching long-press menu and Netflix-style search layout.

Long-press no longer auto-selects the first action; search shows keyboard left and results right.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 03:04:44 +02:00
parent 0eb0c5d367
commit 4d96940b20
4 changed files with 130 additions and 66 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 13 versionCode = 14
versionName = "0.7.6" versionName = "0.7.7"
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")

View file

@ -521,6 +521,7 @@ private fun HomeScreen(
var home by remember { mutableStateOf<HomePayload?>(null) } var home by remember { mutableStateOf<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) } var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() } val menuFocus = remember { FocusRequester() }
@ -538,8 +539,12 @@ private fun HomeScreen(
LaunchedEffect(continueMenu) { LaunchedEffect(continueMenu) {
if (continueMenu != null) { if (continueMenu != null) {
delay(40) menuReady = false
delay(500)
menuReady = true
runCatching { menuFocus.requestFocus() } runCatching { menuFocus.requestFocus() }
} else {
menuReady = false
} }
} }
@ -652,11 +657,8 @@ private fun HomeScreen(
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
McButton( McButton("Markeren als gezien") {
"Markeren als gezien", if (!menuReady) return@McButton
primary = true,
focusRequester = menuFocus,
) {
scope.launch { scope.launch {
runCatching { runCatching {
if (menuItem.type == "movie") { if (menuItem.type == "movie") {
@ -670,6 +672,7 @@ private fun HomeScreen(
} }
} }
McButton("Verbergen") { McButton("Verbergen") {
if (!menuReady) return@McButton
scope.launch { scope.launch {
val pid = menuItem.progressId val pid = menuItem.progressId
if (!pid.isNullOrBlank()) { if (!pid.isNullOrBlank()) {
@ -681,7 +684,7 @@ private fun HomeScreen(
continueMenu = null continueMenu = null
} }
} }
McButton("Annuleren") { continueMenu = null } McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null }
} }
} }
} }

View file

@ -7,10 +7,12 @@ 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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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
@ -24,6 +26,7 @@ import androidx.compose.runtime.setValue
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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -93,70 +96,85 @@ fun SearchScreen(
"567890", "567890",
) )
Column( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(McColors.Bg) .background(McColors.Bg)
.padding(36.dp), .padding(horizontal = 36.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
) { ) {
Text("Zoeken", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) // Links: toetsenbord
Spacer(Modifier.height(8.dp)) Column(
Text( Modifier
if (query.isBlank()) "Typ een titel…" else query, .width(400.dp)
color = if (query.isBlank()) McColors.TextDim else McColors.Accent, .fillMaxHeight(),
fontSize = 22.sp, verticalArrangement = Arrangement.spacedBy(8.dp),
fontWeight = FontWeight.SemiBold, ) {
) Text("Zoeken", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp)) Text(
if (query.isBlank()) "Typ een titel…" else query,
rows.forEach { row -> color = if (query.isBlank()) McColors.TextDim else McColors.Accent,
Row( fontSize = 20.sp,
Modifier.fillMaxWidth().padding(bottom = 8.dp), fontWeight = FontWeight.SemiBold,
horizontalArrangement = Arrangement.spacedBy(8.dp), maxLines = 2,
) { overflow = TextOverflow.Ellipsis,
row.forEach { ch -> )
McButton(ch.toString()) { scheduleSearch(query + ch) } Spacer(Modifier.height(8.dp))
rows.forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(bottom = 4.dp),
) {
row.forEach { ch ->
McButton(ch.toString()) { scheduleSearch(query + ch) }
}
} }
} }
} Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { McButton("Spatie") { scheduleSearch("$query ") }
McButton("Spatie") { scheduleSearch("$query ") } McButton("Wis") {
McButton("Wis") { if (query.isNotEmpty()) scheduleSearch(query.dropLast(1))
if (query.isNotEmpty()) scheduleSearch(query.dropLast(1)) }
McButton("Leeg") { scheduleSearch("") }
} }
McButton("Leeg") { scheduleSearch("") }
} }
Spacer(Modifier.height(20.dp)) // Rechts: resultaten
Text( Column(
when { Modifier
busy -> "Zoeken…" .weight(1f)
error != null -> error!! .fillMaxHeight(),
query.isBlank() -> "Resultaten verschijnen hier"
results.isEmpty() -> "Niets gevonden"
else -> "${results.size} resultaten"
},
color = if (error != null) McColors.Danger else McColors.TextDim,
fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
state = gridState,
contentPadding = PaddingValues(bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) { ) {
items(results, key = { "${it.type}:${it.id}" }) { item -> Text(
PosterCard( when {
item = item, busy -> "Zoeken…"
width = 140.dp, error != null -> error!!
height = 210.dp, query.isBlank() -> "Resultaten"
onClick = { results.isEmpty() -> "Niets gevonden"
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) else -> "${results.size} resultaten"
}, },
) color = if (error != null) McColors.Danger else McColors.TextDim,
fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
state = gridState,
contentPadding = PaddingValues(bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxSize(),
) {
items(results, key = { "${it.type}:${it.id}" }) { item ->
PosterCard(
item = item,
width = 120.dp,
height = 180.dp,
onClick = {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
},
)
}
} }
} }
} }

View file

@ -9,12 +9,21 @@ 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.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
@ -73,6 +82,31 @@ fun McButton(
} }
} }
private fun Key.isTvSelectKey(): Boolean =
this == Key.DirectionCenter || this == Key.Enter || this == Key.NumPadEnter
@Composable
private fun Modifier.tvShortOrLongPress(
onClick: () -> Unit,
onLongPress: () -> Unit,
): Modifier {
var downAt by remember { mutableLongStateOf(0L) }
return this.onPreviewKeyEvent { ev ->
when {
ev.type == KeyEventType.KeyDown && ev.key.isTvSelectKey() -> {
downAt = System.currentTimeMillis()
false
}
ev.type == KeyEventType.KeyUp && ev.key.isTvSelectKey() -> {
val held = System.currentTimeMillis() - downAt
if (held >= 450) onLongPress() else onClick()
true
}
else -> false
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun PosterCard( fun PosterCard(
@ -89,10 +123,19 @@ fun PosterCard(
0f 0f
} }
val useKeyHandler = onLongClick != null
Surface( Surface(
onClick = onClick, onClick = if (useKeyHandler) ({}) else onClick,
onLongClick = onLongClick, onLongClick = null,
modifier = modifier.width(width), modifier = modifier
.width(width)
.then(
if (useKeyHandler) {
Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!)
} else {
Modifier
},
),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
border = ClickableSurfaceDefaults.border( border = ClickableSurfaceDefaults.border(