Replace the old gear menu with a proper Settings screen (0.72.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Orbit TV 0.72.0: proper settings
|
||||
|
||||
The gear now opens one Settings screen, with a rail of sections and the chosen one beside it. Includes 0.71's tickets and reminders.
|
||||
|
||||
- Channels: every smart channel, grouped by block, with logo, On or Off and Add more. Wording is plain, with the old request counts and sizes gone.
|
||||
- Watching: subtitles on or off by default. On picks English where a programme has it, and never a foreign track unasked. It's saved on the box.
|
||||
- Emby account: connection status and sign out, which now asks first and says what it removes.
|
||||
- Tools: library checks and the old connection test, out of the way.
|
||||
- About: version and what's on the box.
|
||||
- Back from Settings goes to the main menu.
|
||||
|
||||
Checked: build, lint and 280 unit tests pass, including the subtitle choice and block names.
|
||||
|
||||
Not checked until installed: focus moving through the screen, and how it looks. Sign out wasn't run, as it would clear the box's real setup.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 74
|
||||
versionName = "0.71.0-drive-in-tickets"
|
||||
versionCode = 75
|
||||
versionName = "0.72.0-settings"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -119,28 +118,3 @@ internal fun SuggestionsScreen(programming: ChannelProgramming, suggestions: Lis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every smart channel in one list that scrolls, however many there are. */
|
||||
@Composable
|
||||
internal fun SmartChannelsScreen(busy: Boolean, message: String?, modifier: Modifier, isOn: (Int) -> Boolean, canStart: (ChannelProgramming) -> Boolean,
|
||||
onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
val firstFocus = androidx.compose.runtime.remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
||||
Column(modifier.fillMaxWidth().background(Navy).padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Smart channels", color = Color.White, fontSize = 20.sp)
|
||||
message?.let { Text(it, color = Yellow, fontSize = 13.sp, maxLines = 4) }
|
||||
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(SmartChannels.sortedBy { it.number }, key = { it.number }) { programming ->
|
||||
val on = isOn(programming.number)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val switch = if (programming == SmartChannels.minByOrNull { it.number }) Modifier.focusRequester(firstFocus) else Modifier
|
||||
OrbitButton("${programming.number} ${programming.name}: smart schedule is ${if (on) "ON. Press for its loop" else "OFF. Press to turn on"}", switch.weight(1f),
|
||||
enabled = !busy && canStart(programming), onClick = { onSwitch(programming, !on) })
|
||||
if (programming.suggestionRules.isNotEmpty()) OrbitButton("Bigger pool: review", enabled = !busy && canStart(programming), onClick = { onSuggestions(programming) })
|
||||
}
|
||||
}
|
||||
}
|
||||
OrbitButton("Back", onClick = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.71.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.72.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -106,7 +106,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
var scheduledChannels by remember { mutableStateOf<Map<Int, ScheduledChannel>>(emptyMap()) }
|
||||
var suggesting by remember { mutableStateOf<ChannelProgramming?>(null) }
|
||||
var suggestions by remember { mutableStateOf<List<Suggestion>>(emptyList()) }
|
||||
var managingChannels by remember { mutableStateOf(false) }
|
||||
var connectionTest by remember { mutableStateOf(false) }
|
||||
var settingsSection by remember { mutableStateOf(SettingsSection.Channels) }
|
||||
var savingSuggestions by remember { mutableStateOf(false) }
|
||||
var tickedSuggestions by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var lastSmartPress by remember { mutableLongStateOf(0L) }
|
||||
@@ -146,6 +147,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
try { catalogue.clear() }
|
||||
catch (_: Exception) { message = "The library data could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." }
|
||||
}
|
||||
driveInPlan = null; tickets = emptyMap(); reminder = null; connectionTest = false
|
||||
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
|
||||
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
|
||||
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null; reviewingHealth = false; libraryHealth = null; lastNumber = null
|
||||
@@ -476,7 +478,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
onChannelAvailable(true); onGuide()
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (failure: EmbyCheckException) { message = failure.message }
|
||||
catch (_: Exception) { message = "Channel 102 could not be prepared. Try again later." }
|
||||
catch (_: Exception) { message = "After Dark could not be prepared. Try again later." }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
@@ -524,16 +526,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
}
|
||||
onChannelAvailable(true)
|
||||
message = "${programming.name} is scheduled: ${report.programmes} programmes from ${report.episodes} titles: ${report.shows.take(10).joinToString()}${if (report.shows.size > 10) " and ${report.shows.size - 10} more" else ""}. " +
|
||||
"${report.requests} requests, ${report.fetchMs / 1000} s to read, ${report.planMs} ms to plan, ${report.bytes / 1024} KB stored."
|
||||
message = "${programming.name} is on, with ${report.programmes} programmes planned from ${report.shows.size} ${if (report.shows.size == 1) "title" else "titles"}."
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }
|
||||
scheduledChannels = scheduledChannels - programming.number
|
||||
message = "${programming.name} is back on its loop. Its schedule and place are kept."
|
||||
message = "${programming.name} is off, and back to a short repeat. Its schedule and place are kept."
|
||||
}
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (failure: EmbyCheckException) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = failure.message }
|
||||
catch (_: Exception) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = "${programming.name} could not be scheduled, so it stays on its loop." }
|
||||
catch (_: Exception) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = "${programming.name} could not be scheduled, so it stays off." }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
@@ -581,7 +582,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
}
|
||||
}
|
||||
suggesting = null
|
||||
message = "${programming.name} now has ${report.shows.size} titles and ${report.programmes} programmes planned. ${report.requests} requests, ${report.fetchMs / 1000} s to read, ${report.bytes / 1024} KB stored."
|
||||
message = "${programming.name} now has ${report.shows.size} titles and ${report.programmes} programmes planned."
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (failure: Exception) {
|
||||
android.util.Log.w("OrbitSchedule", "channel=${programming.number} saveFailed=${failure.javaClass.simpleName}")
|
||||
@@ -619,12 +620,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
onBack = { if (!savingSuggestions) { job?.cancel(); busy = false }; suggesting = null })
|
||||
return
|
||||
}
|
||||
if (managingChannels) {
|
||||
SmartChannelsScreen(busy, message, modifier, isOn = { scheduledChannels.containsKey(it) },
|
||||
canStart = { smartLoop(it) != null || ChannelPresets.any { preset -> preset.number == it.number } },
|
||||
onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, onBack = { managingChannels = false })
|
||||
return
|
||||
}
|
||||
if (reviewingSeries) {
|
||||
SeriesReviewScreen(seriesMatches.orEmpty(), approvedSeries, episodeSamples, busy, message, modifier,
|
||||
onToggle = { id ->
|
||||
@@ -663,13 +658,22 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
reviewingSeries = false; onChannelAvailable(true); onGuide()
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (failure: EmbyCheckException) { message = failure.message }
|
||||
catch (_: Exception) { message = "Channel 103 could not be prepared. Try again later." }
|
||||
catch (_: Exception) { message = "Vortex could not be prepared. Try again later." }
|
||||
finally { busy = false }
|
||||
}
|
||||
}
|
||||
}, onBack = { job?.cancel(); busy = false; reviewingSeries = false })
|
||||
return
|
||||
}
|
||||
if (session != null && !connectionTest) {
|
||||
SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total,
|
||||
isOn = { scheduledChannels.containsKey(it) }, canStart = { smartLoop(it) != null || ChannelPresets.any { preset -> preset.number == it.number } },
|
||||
onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
|
||||
onSignOut = { job?.cancel(); closeSession(forget = true) }, onCoverage = { message = null; reviewingCoverage = true }, onImport = { message = null; reviewingCatalogue = true },
|
||||
onConnectionTest = { message = null; connectionTest = true })
|
||||
return
|
||||
}
|
||||
BackHandler(connectionTest) { connectionTest = false }
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Column(Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connect Emby", color = Yellow, fontSize = 27.sp)
|
||||
@@ -748,7 +752,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
OrbitButton("Forget saved connection", Modifier.fillMaxWidth(), enabled = !restoring && !busy,
|
||||
onClick = { closeSession(forget = true) })
|
||||
if (restoring) Text("Restoring saved connection…", color = Yellow, fontSize = 14.sp)
|
||||
OrbitButton("Return to Menu", Modifier.fillMaxWidth(), onClick = onBack)
|
||||
OrbitButton(if (connectionTest) "Return to Settings" else "Return to Menu", Modifier.fillMaxWidth(), onClick = { if (connectionTest) connectionTest = false else onBack() })
|
||||
}
|
||||
Column(Modifier.weight(1.15f).fillMaxHeight().background(Panel).padding(16.dp)) {
|
||||
Text(if (sample != null) "Sample received" else "Connection check", color = Yellow, fontSize = 24.sp)
|
||||
@@ -762,7 +766,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
message?.let { Text(it, color = Yellow, fontSize = 14.sp) }
|
||||
Text("${result.items.size} records received${result.total?.let { " • $it available to this account" }.orEmpty()}", color = Color.White, fontSize = 16.sp)
|
||||
Text("Select a title, or create one channel from up to three playable titles.", color = Muted, fontSize = 13.sp)
|
||||
OrbitButton("Smart channels", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { message = null; managingChannels = true })
|
||||
OrbitButton("Channels", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { message = null; settingsSection = SettingsSection.Channels; connectionTest = false })
|
||||
OrbitButton(if (channel == null) "Create sample channel" else "Open TV Guide",
|
||||
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
|
||||
if (channel != null) { selectedNumber = 101; onGuide() } else {
|
||||
|
||||
@@ -136,14 +136,14 @@ private fun OrbitApp() {
|
||||
}
|
||||
// Back on the main menu, or on the demo guide, closes the app.
|
||||
BackHandler(fullscreen || pinned || (section != "TV Guide" && section != "Home")) {
|
||||
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = "Menu"; else -> section = if (realChannelAvailable) "Home" else "TV Guide" }
|
||||
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = if (realChannelAvailable) "Home" else "Menu"; else -> section = if (realChannelAvailable) "Home" else "TV Guide" }
|
||||
}
|
||||
LaunchedEffect(section, fullscreen, realChannelAvailable, restoreChecked) {
|
||||
if (!fullscreen && restoreChecked) {
|
||||
if (!realChannelAvailable) {
|
||||
val tab = when (section) { "TV Guide" -> 1; "Drive-In" -> 2; else -> 0 }
|
||||
tabFocus[tab].requestFocus()
|
||||
} else if (section != "Home" && section != "TV Guide" && section != "Drive-In") gearFocus.requestFocus()
|
||||
} else if (section != "Home" && section != "TV Guide" && section != "Drive-In" && section != "Connect Emby") gearFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(Navy)) {
|
||||
@@ -162,7 +162,7 @@ private fun OrbitApp() {
|
||||
Text(SimpleDateFormat("EEE d MMM HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontSize = 17.sp)
|
||||
if (realChannelAvailable) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
HomeCard(Modifier.size(38.dp, 32.dp), gearFocus, onClick = { section = "Menu"; pinned = false }) { focused ->
|
||||
HomeCard(Modifier.size(38.dp, 32.dp), gearFocus, onClick = { section = "Connect Emby"; pinned = false }) { focused ->
|
||||
androidx.compose.foundation.Image(androidx.compose.ui.res.painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp),
|
||||
colorFilter = if (focused) androidx.compose.ui.graphics.ColorFilter.tint(Navy) else null)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ private fun OrbitApp() {
|
||||
mainHome = section == "Home" && realChannelAvailable,
|
||||
mainDriveIn = section == "Drive-In" && realChannelAvailable,
|
||||
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
|
||||
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }, onBack = { section = "Menu" })
|
||||
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }, onBack = { section = if (realChannelAvailable) "Home" else "Menu" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene
|
||||
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null, onUnplayable: ((String) -> Unit)? = null) {
|
||||
// Outlives each programme and channel, and ends when the viewer leaves full screen.
|
||||
val subtitlePreference = remember { mutableStateOf<String?>(null) }
|
||||
val settingsContext = androidx.compose.ui.platform.LocalContext.current
|
||||
val subtitlePreference = remember { mutableStateOf(if (OrbitSettings(settingsContext).subtitlesOn) SUBTITLES_BY_DEFAULT else null) }
|
||||
val audioPreference = remember { mutableStateOf<String?>(null) }
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
|
||||
LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } }
|
||||
|
||||
@@ -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.71.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.72.0")
|
||||
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()) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
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.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.Text
|
||||
|
||||
/** Choices about how Orbit behaves on this box. Nothing here is private, so it needs no encrypted storage. */
|
||||
internal class OrbitSettings(context: Context) {
|
||||
private val saved = context.getSharedPreferences("orbit_settings", Context.MODE_PRIVATE)
|
||||
var subtitlesOn: Boolean
|
||||
get() = saved.getBoolean("subtitles_on", false)
|
||||
set(value) { saved.edit().putBoolean("subtitles_on", value).apply() }
|
||||
}
|
||||
|
||||
internal enum class SettingsSection(val label: String, val icon: Int) {
|
||||
Channels("Channels", R.drawable.orbit_icon_all),
|
||||
Viewing("Watching", R.drawable.orbit_icon_entertainment),
|
||||
Account("Emby account", R.drawable.orbit_icon_profile),
|
||||
Tools("Tools", R.drawable.orbit_icon_gear),
|
||||
About("About", R.drawable.orbit_icon_documentaries)
|
||||
}
|
||||
|
||||
/** The heading a channel number sits under, matching the blocks in the guide. */
|
||||
internal fun channelBlock(number: Int) = when (number / 100) {
|
||||
1 -> "Entertainment and comedy"
|
||||
2 -> "Factual"
|
||||
3 -> "Films"
|
||||
4 -> "Sport"
|
||||
6 -> "Cartoons and kids"
|
||||
else -> "Other"
|
||||
}
|
||||
|
||||
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) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Row(modifier.fillMaxWidth().height(46.dp).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)
|
||||
Text(section.label, color = if (focused) Navy else Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** One thing you can do, and in plain words what it's for. */
|
||||
@Composable
|
||||
private fun SettingLine(title: String, detail: String, button: @Composable () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().clip(RailCorner).background(Panel).padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
Text(detail, color = Muted, fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
button()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boolean, canStart: (ChannelProgramming) -> Boolean,
|
||||
onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, 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)) {
|
||||
items(all, key = { it.number }) { programming ->
|
||||
val on = isOn(programming.number)
|
||||
val brand = ChannelBrands.forNumber(programming.number)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (programming == all.firstOrNull { channelBlock(it.number) == channelBlock(programming.number) })
|
||||
Text(channelBlock(programming.number), Modifier.padding(top = 6.dp), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row(Modifier.fillMaxWidth().clip(RailCorner).background(Panel).padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(programming.number.toString(), Modifier.width(30.dp), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
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("Add more", enabled = !busy && canStart(programming), onClick = { onSuggestions(programming) })
|
||||
OrbitButton(if (on) "On" else "Off", Modifier.width(64.dp), selected = on, enabled = !busy && canStart(programming), onClick = { onSwitch(programming, !on) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewingDetail(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { OrbitSettings(context) }
|
||||
var subtitlesOn by remember { mutableStateOf(settings.subtitlesOn) }
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, modifier: Modifier) {
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SettingLine("Connected", server + (titles?.let { ", $it titles available" }.orEmpty())) {}
|
||||
Text("Your password is never saved. This box only keeps a sign-in token, in encrypted storage.", color = Muted, fontSize = 13.sp)
|
||||
if (confirming) {
|
||||
SettingLine("Sign out of this box?", "This also removes your channel schedules, review choices and tickets from this box. Your Emby library isn't touched.") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val keep = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { keep.requestFocus() }
|
||||
OrbitButton("Keep", Modifier.focusRequester(keep), onClick = { confirming = false })
|
||||
OrbitButton("Sign out", onClick = onSignOut)
|
||||
}
|
||||
}
|
||||
} else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", onClick = { confirming = true }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolsDetail(busy: Boolean, onCoverage: () -> Unit, onImport: () -> Unit, onConnectionTest: () -> Unit, modifier: Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("You won't need these day to day.", color = Muted, fontSize = 13.sp)
|
||||
SettingLine("Check library details", "See how complete the details are for your films and shows.") { OrbitButton("Open", enabled = !busy, onClick = onCoverage) }
|
||||
SettingLine("Library import and health", "Copy your library's titles to this box, and check that copy.") { OrbitButton("Open", enabled = !busy, onClick = onImport) }
|
||||
SettingLine("Connection test", "The early sample of titles, and older ways of building channels.") { OrbitButton("Open", enabled = !busy, onClick = onConnectionTest) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutDetail(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val version = remember { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(192.dp).height(48.dp))
|
||||
Text("Your own TV network, from your own Emby library.", color = Color.White, fontSize = 17.sp)
|
||||
Text("Version $version", color = Muted, fontSize = 14.sp)
|
||||
Text("${SmartChannels.size} channels and the Orbit Drive-In", color = Muted, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything behind the gear: a rail of sections on the left, and the chosen one beside it. */
|
||||
@Composable
|
||||
internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSection) -> Unit, modifier: Modifier, busy: Boolean, message: String?, server: String, titles: Long?,
|
||||
isOn: (Int) -> Boolean, canStart: (ChannelProgramming) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit,
|
||||
onSignOut: () -> Unit, onCoverage: () -> Unit, onImport: () -> Unit, onConnectionTest: () -> Unit) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
val detail = Modifier.weight(1f).fillMaxHeight()
|
||||
when (section) {
|
||||
SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, canStart, onSwitch, onSuggestions, detail)
|
||||
SettingsSection.Viewing -> ViewingDetail(detail)
|
||||
SettingsSection.Account -> AccountDetail(server, titles, onSignOut, detail)
|
||||
SettingsSection.Tools -> ToolsDetail(busy, onCoverage, onImport, onConnectionTest, detail)
|
||||
SettingsSection.About -> AboutDetail(detail)
|
||||
}
|
||||
}
|
||||
Text("▲▼ Move ► Into this section OK Select Back Main menu", color = Muted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,13 @@ internal data class ChannelEntry(val digits: List<Int>, val slot: Int = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The setting's choice: English where a programme has it, and otherwise none, so a foreign track never appears unasked. */
|
||||
internal const val SUBTITLES_BY_DEFAULT = "default-english"
|
||||
|
||||
/** Subtitles stay on across programmes: the same language if it's there, otherwise the first track. */
|
||||
internal fun subtitleFor(wanted: String?, languages: List<String>): Int? {
|
||||
if (wanted == null || languages.isEmpty()) return null
|
||||
if (wanted == SUBTITLES_BY_DEFAULT) return languages.indexOfFirst { it.lowercase(java.util.Locale.ROOT).startsWith("en") }.takeIf { it >= 0 }
|
||||
return languages.indexOf(wanted).takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
|
||||
|
||||
@@ -52,4 +52,21 @@ class WatchingControlsTest {
|
||||
assertEquals("Track 2", trackLabel(null, "und", 1))
|
||||
assertEquals("Track 1", trackLabel(null, null, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSubtitlesSettingOnlyEverPicksEnglish() {
|
||||
assertEquals(1, subtitleFor(SUBTITLES_BY_DEFAULT, listOf("fre", "eng")))
|
||||
assertEquals(0, subtitleFor(SUBTITLES_BY_DEFAULT, listOf("en-GB")))
|
||||
assertNull(subtitleFor(SUBTITLES_BY_DEFAULT, listOf("fre", "spa")))
|
||||
assertNull(subtitleFor(SUBTITLES_BY_DEFAULT, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelsAreGroupedByTheirBlock() {
|
||||
assertEquals("Entertainment and comedy", channelBlock(101))
|
||||
assertEquals("Factual", channelBlock(204))
|
||||
assertEquals("Films", channelBlock(305))
|
||||
assertEquals("Sport", channelBlock(401))
|
||||
assertEquals("Cartoons and kids", channelBlock(613))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
|
||||
- The highlighted left-menu item could reflect the channel or theme, while yellow stays the clear focus signal.
|
||||
- Orbit Drive-In gets its own full-screen destination, opened from the teaser card.
|
||||
- **Rings around the planet (Paul, 1 November 2026):** Orbit 1's background had its rings off to one side of the planet. The app's copy is fixed, from orbit-1/sources/orbit_1_background_v2.svg. Orbit 1's ident and the other channels built from the same planet layout (Orbit 2, Orbit Sci-Fi and any others on that theme) still need checking and the same fix before they join the app.
|
||||
- **Settings (Paul, 2 November 2026):** built in 0.72 behind the gear, with Channels, Watching, Emby account, Tools and About.
|
||||
- **Subtitles default (Paul, 1 November 2026):** a setting behind the gear for subtitles on or off by default, in the viewer's language where a programme has it.
|
||||
- **Orbit Drive-In name (Paul, 1 November 2026):** decided, replacing OrbitOffice. Event types inside it are Orbit Marquee, Orbit Double Bill, Orbit Triple Bill and The Late Show. The briefs and design/interface still show the old name.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user