0.81.2: right from a Settings section goes to its first switch
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m52s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m33s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 00:44:30 +00:00
co-authored by Claude Opus 5.5
parent 78c889b004
commit ecfcfbe3e2
6 changed files with 44 additions and 17 deletions
+8
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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)
@@ -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()) {
@@ -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)
+2
View File
@@ -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.