diff --git a/VERIFICATION-0.72.0.md b/VERIFICATION-0.72.0.md new file mode 100644 index 0000000..185d3d0 --- /dev/null +++ b/VERIFICATION-0.72.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5b7b92d..3dcce07 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt index bd6c11c..c2ad1e1 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt @@ -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) - } -} 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 59b8bf5..dbd6bf7 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.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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 40c5d63..60b29d8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -106,7 +106,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var scheduledChannels by remember { mutableStateOf>(emptyMap()) } var suggesting by remember { mutableStateOf(null) } var suggestions by remember { mutableStateOf>(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>(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 { diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index eba687c..010e910 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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" }) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 7951f1b..21ac3fc 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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(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(null) } var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) } LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } } 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 97c27d7..f3b2a1e 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.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt new file mode 100644 index 0000000..b325311 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt index 8351e35..fa6056d 100644 --- a/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt +++ b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt @@ -27,9 +27,13 @@ internal data class ChannelEntry(val digits: List, 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): 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 } diff --git a/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt b/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt index 3efdf95..fd87a0f 100644 --- a/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt @@ -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)) + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index eb84524..06d0882 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.