diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 4c08cae..b3b30c3 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 35 - versionCode = 13 - versionName = "0.7.6" + versionCode = 14 + versionName = "0.7.7" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 37268f2..b684c0c 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -521,6 +521,7 @@ private fun HomeScreen( var home by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var continueMenu by remember { mutableStateOf(null) } + var menuReady by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val menuFocus = remember { FocusRequester() } @@ -538,8 +539,12 @@ private fun HomeScreen( LaunchedEffect(continueMenu) { if (continueMenu != null) { - delay(40) + menuReady = false + delay(500) + menuReady = true runCatching { menuFocus.requestFocus() } + } else { + menuReady = false } } @@ -652,11 +657,8 @@ private fun HomeScreen( Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) } Spacer(Modifier.height(4.dp)) - McButton( - "Markeren als gezien", - primary = true, - focusRequester = menuFocus, - ) { + McButton("Markeren als gezien") { + if (!menuReady) return@McButton scope.launch { runCatching { if (menuItem.type == "movie") { @@ -670,6 +672,7 @@ private fun HomeScreen( } } McButton("Verbergen") { + if (!menuReady) return@McButton scope.launch { val pid = menuItem.progressId if (!pid.isNullOrBlank()) { @@ -681,7 +684,7 @@ private fun HomeScreen( continueMenu = null } } - McButton("Annuleren") { continueMenu = null } + McButton("Annuleren", focusRequester = menuFocus) { continueMenu = null } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt index 8191530..6bc4569 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -93,70 +96,85 @@ fun SearchScreen( "567890", ) - Column( + Row( Modifier .fillMaxSize() .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) - Spacer(Modifier.height(8.dp)) - Text( - if (query.isBlank()) "Typ een titel…" else query, - color = if (query.isBlank()) McColors.TextDim else McColors.Accent, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(16.dp)) - - rows.forEach { row -> - Row( - Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - row.forEach { ch -> - McButton(ch.toString()) { scheduleSearch(query + ch) } + // Links: toetsenbord + Column( + Modifier + .width(400.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Zoeken", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold) + Text( + if (query.isBlank()) "Typ een titel…" else query, + color = if (query.isBlank()) McColors.TextDim else McColors.Accent, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + 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)) { - McButton("Spatie") { scheduleSearch("$query ") } - McButton("Wis") { - if (query.isNotEmpty()) scheduleSearch(query.dropLast(1)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + McButton("Spatie") { scheduleSearch("$query ") } + McButton("Wis") { + if (query.isNotEmpty()) scheduleSearch(query.dropLast(1)) + } + McButton("Leeg") { scheduleSearch("") } } - McButton("Leeg") { scheduleSearch("") } } - Spacer(Modifier.height(20.dp)) - Text( - when { - busy -> "Zoeken…" - error != null -> error!! - 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(), + // Rechts: resultaten + Column( + Modifier + .weight(1f) + .fillMaxHeight(), ) { - items(results, key = { "${it.type}:${it.id}" }) { item -> - PosterCard( - item = item, - width = 140.dp, - height = 210.dp, - onClick = { - if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) - }, - ) + Text( + when { + busy -> "Zoeken…" + error != null -> error!! + query.isBlank() -> "Resultaten" + 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 = 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) + }, + ) + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index 831b995..55ae60f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -9,12 +9,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -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) @Composable fun PosterCard( @@ -89,10 +123,19 @@ fun PosterCard( 0f } + val useKeyHandler = onLongClick != null Surface( - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier.width(width), + onClick = if (useKeyHandler) ({}) else onClick, + onLongClick = null, + modifier = modifier + .width(width) + .then( + if (useKeyHandler) { + Modifier.tvShortOrLongPress(onClick = onClick, onLongPress = onLongClick!!) + } else { + Modifier + }, + ), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f), border = ClickableSurfaceDefaults.border(