From ecfcfbe3e2ce99b96aa44eb82e7fcb114f0b8ef9 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Tue, 3 Nov 2026 00:44:30 +0000 Subject: [PATCH] 0.81.2: right from a Settings section goes to its first switch Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.81.2.md | 8 ++++ android/app/build.gradle.kts | 4 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../main/java/tv/orbit/classic/Settings.kt | 43 +++++++++++++------ docs/refinement-notes.md | 2 + 6 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 VERIFICATION-0.81.2.md diff --git a/VERIFICATION-0.81.2.md b/VERIFICATION-0.81.2.md new file mode 100644 index 0000000..50fd95d --- /dev/null +++ b/VERIFICATION-0.81.2.md @@ -0,0 +1,8 @@ +# Orbit TV 0.81.2: Settings focus + +Pressing right on a Settings section goes straight to that section's first switch, instead of possibly landing on the top bar. Up is unchanged. + +- Unit tests: 306 passed, 0 failed. Lint passed. No new test: this is remote focus, which needs the TV to check. +- Not yet checked on the box: right from all three sections, including Channels after scrolling, and up to the top bar. + +Tots on 0.81.1, read from the box: all 36 listed shows found with 4,141 episodes. A week holds 740 programmes, every one different, with no gaps. Morning, Playtime, Wind Down and Night each hold only their own shows. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4222fc6..112bea3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 92 - versionName = "0.81.1-orbit-tots" + versionCode = 93 + versionName = "0.81.2-settings-focus" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 144bcab..d9e3595 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.1\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.2\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index dc4e9d3..c50547a 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.81.1") + next.setRequestProperty("User-Agent", "OrbitTV/0.81.2") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 62de883..0874948 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -16,6 +18,11 @@ 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.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.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -24,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text +import kotlinx.coroutines.launch /** Choices about how Orbit behaves on this box. Nothing here is private, so it needs no encrypted storage. */ internal class OrbitSettings(context: Context) { @@ -68,9 +76,12 @@ private val RailCorner = RoundedCornerShape(6.dp) /** Moving onto a section shows it straight away, and right steps across into it. */ @Composable -private fun RailItem(section: SettingsSection, selected: Boolean, modifier: Modifier, onSelect: () -> Unit) { +private fun RailItem(section: SettingsSection, selected: Boolean, modifier: Modifier, onRight: () -> Unit, onSelect: () -> Unit) { var focused by remember { mutableStateOf(false) } - Row(modifier.fillMaxWidth().height(46.dp).onFocusChanged { focused = it.isFocused; if (it.isFocused) onSelect() }.clip(RailCorner) + Row(modifier.fillMaxWidth().height(46.dp).onPreviewKeyEvent { event -> + // Straight to the section's first switch. Left alone, right could land on the top bar instead. + if (event.key == Key.DirectionRight) { if (event.type == KeyEventType.KeyDown) onRight(); true } else false + }.onFocusChanged { focused = it.isFocused; if (it.isFocused) onSelect() }.clip(RailCorner) .background(if (focused) Yellow else if (selected) Blue else Panel).clickable(onClick = onSelect).padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Image(painterResource(section.icon), null, Modifier.size(26.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null) @@ -92,13 +103,13 @@ private fun SettingLine(title: String, detail: String, button: @Composable () -> @Composable private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boolean, - onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, modifier: Modifier) { + onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, firstSwitch: FocusRequester, list: LazyListState, modifier: Modifier) { val all = SmartChannels.sortedBy { it.number } Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("${all.count { isOn(it.number) }} of ${all.size} channels on", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Text("A channel that's on runs its full schedule. Once it's been on, turning it off leaves a short repeat.", color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) message?.let { Text(it, color = Yellow, fontSize = 13.sp, maxLines = 3, overflow = TextOverflow.Ellipsis) } - LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + LazyColumn(Modifier.weight(1f), state = list, verticalArrangement = Arrangement.spacedBy(4.dp)) { items(all, key = { it.number }) { programming -> val on = isOn(programming.number) val brand = ChannelBrands.forNumber(programming.number) @@ -110,7 +121,7 @@ private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boole if (brand != null) Image(painterResource(brand.logo), brand.displayName, Modifier.width(88.dp).height(28.dp)) Text(brand?.displayName ?: programming.name, Modifier.weight(1f), color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) if (programming.suggestionRules.isNotEmpty()) OrbitButton("Titles", enabled = !busy, onClick = { onSuggestions(programming) }) - OrbitButton(if (on) "On" else "Off", Modifier.width(64.dp), selected = on, enabled = !busy, onClick = { onSwitch(programming, !on) }) + OrbitButton(if (on) "On" else "Off", Modifier.width(64.dp).then(if (programming == all.first()) Modifier.focusRequester(firstSwitch) else Modifier), selected = on, enabled = !busy, onClick = { onSwitch(programming, !on) }) } } } @@ -119,7 +130,7 @@ private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boole } @Composable -private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Boolean) -> Unit, modifier: Modifier) { +private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Boolean) -> Unit, firstSwitch: FocusRequester, modifier: Modifier) { val context = LocalContext.current val settings = remember { OrbitSettings(context) } var subtitlesOn by remember { mutableStateOf(settings.subtitlesOn) } @@ -129,7 +140,7 @@ private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Bo var confirming by remember { mutableStateOf(false) } Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingLine("Subtitles", "Turn on English subtitles whenever you tune in, if the programme has them. You can still change them while watching, by pressing right.") { - OrbitButton(if (subtitlesOn) "On" else "Off", Modifier.width(64.dp), selected = subtitlesOn, onClick = { subtitlesOn = !subtitlesOn; settings.subtitlesOn = subtitlesOn }) + OrbitButton(if (subtitlesOn) "On" else "Off", Modifier.width(64.dp).focusRequester(firstSwitch), selected = subtitlesOn, onClick = { subtitlesOn = !subtitlesOn; settings.subtitlesOn = subtitlesOn }) } SettingLine("Channel logo", "Show the channel's small logo in the corner during programmes.") { OrbitButton(if (channelLogo) "On" else "Off", Modifier.width(64.dp), selected = channelLogo, onClick = { channelLogo = !channelLogo; settings.channelLogo = channelLogo }) @@ -156,7 +167,7 @@ private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Bo } @Composable -private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, modifier: Modifier) { +private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, firstSwitch: FocusRequester, modifier: Modifier) { val context = LocalContext.current val version = remember { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() } var confirming by remember { mutableStateOf(false) } @@ -172,7 +183,7 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, OrbitButton("Sign out", onClick = onSignOut) } } - } else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", onClick = { confirming = true }) } + } else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", Modifier.focusRequester(firstSwitch), onClick = { confirming = true }) } Text("Orbit TV $version", color = Muted, fontSize = 12.sp) Text("Snake graphics by Bas de Reuver, bdrgames.nl", color = Muted, fontSize = 12.sp) } @@ -184,20 +195,26 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit, windingDown: Boolean, onWindDown: (Boolean) -> Unit) { val firstFocus = remember { FocusRequester() } + val firstSwitch = remember { FocusRequester() } + val channelList = rememberLazyListState() + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { firstFocus.requestFocus() } Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Settings", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.width(176.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { SettingsSection.entries.forEach { item -> - RailItem(item, item == section, if (item == section) Modifier.focusRequester(firstFocus) else Modifier) { onSection(item) } + RailItem(item, item == section, if (item == section) Modifier.focusRequester(firstFocus) else Modifier, onRight = { + // The channel list may have been scrolled, taking its first switch off the screen. + scope.launch { if (item == SettingsSection.Channels) channelList.scrollToItem(0); runCatching { firstSwitch.requestFocus() } } + }) { onSection(item) } } } val detail = Modifier.weight(1f).fillMaxHeight() when (section) { - SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, onSwitch, onSuggestions, detail) - SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, detail) - SettingsSection.Account -> AccountDetail(server, titles, onSignOut, detail) + SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, onSwitch, onSuggestions, firstSwitch, channelList, detail) + SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, firstSwitch, detail) + SettingsSection.Account -> AccountDetail(server, titles, onSignOut, firstSwitch, detail) } } Text("▲▼ Move ► Into this section OK Select Back Main menu", color = Muted, fontSize = 11.sp) diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md index 06e679a..c347ea6 100644 --- a/docs/refinement-notes.md +++ b/docs/refinement-notes.md @@ -70,3 +70,5 @@ The player's NEXT line asks the channel for the following programme, and between - Exact title matching needs the library's own spelling, years included, such as Bluey (2018). A later tool that checks a channel against a new user's library shouldn't depend on this. - Idents, logo and sound had no settings until Tots needed them. Every new user will want these from day one. - Child safety isn't a Tots feature. A PIN and lock should exist before this app goes to anyone else with children. +- Tots' first plan took about 13 s to work out, on a pool of 4,141 episodes, in the background. Fine here, but a build-your-own channel with a big pool should not block anything on it. +- In Settings, right from the rail could land on the top bar (fixed 0.81.2). Screens with a bar above and tall content beside a rail want explicit focus routes.