From 18967e1531264d433f1d921490d46eb6bbc2d77b Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 23:04:07 +0000 Subject: [PATCH] Add subtitles on right, hold OK for channel number, selectable picture (0.29.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.29.0.md | 18 +++++ android/app/build.gradle.kts | 4 +- .../tv/orbit/classic/ClassicProgrammeStrip.kt | 6 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 5 ++ .../tv/orbit/classic/EmbyPlaybackDialog.kt | 76 ++++++++++++++++++- .../main/java/tv/orbit/classic/HomeScreen.kt | 4 +- .../tv/orbit/classic/SampleChannelScreens.kt | 6 +- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../java/tv/orbit/classic/WatchingControls.kt | 59 ++++++++++++++ .../tv/orbit/classic/WatchingControlsTest.kt | 54 +++++++++++++ 11 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 VERIFICATION-0.29.0.md create mode 100644 android/app/src/main/java/tv/orbit/classic/WatchingControls.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt diff --git a/VERIFICATION-0.29.0.md b/VERIFICATION-0.29.0.md new file mode 100644 index 0000000..0da79be --- /dev/null +++ b/VERIFICATION-0.29.0.md @@ -0,0 +1,18 @@ +# Orbit TV 0.29.0 verification + +Watching controls, and a selectable picture on the main menu. + +## Changed +- The menu's picture can now be selected; OK goes full screen. +- While watching a channel, right steps through the programme's subtitle tracks and then off, with a short note saying which. The choice carries across programmes and channels until the viewer leaves full screen. +- Holding OK opens the Go to channel box, starting on the current channel. Up and down change a digit, right or OK moves on, left moves back, and OK on the last digit tunes. Back cancels, and it closes itself after 10 seconds without a press. Number keys work too, if a remote has them. +- A number with no channel shows a message and leaves the box open. +- The programme bar's legend lists the new controls. + +## Checked +- assembleDebug, testDebugUnitTest and lintDebug pass; 138 tests, 0 failures. +- APK signature verified with apksigner. +- Unit tests cover digit dialling, wrapping and limits, the subtitle cycle and keeping a language across programmes. Key handling itself has no automated test. + +## Not checked +- Not yet run on the box. Whether Paul's remote sends key repeats for a held OK, and whether his programmes have subtitle tracks the player supports, can only be seen there. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cd934b8..14b6115 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 = 29 - versionName = "0.28.0-home" + versionCode = 30 + versionName = "0.29.0-watching-controls" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt index 34309ef..d7ffc1b 100644 --- a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt +++ b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt @@ -44,7 +44,11 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier: horizontalArrangement = Arrangement.spacedBy(30.dp), verticalAlignment = Alignment.CenterVertically) { Legend("▲ ▼", "Channel") Legend("↶", "TV guide") - if (scheduled != null) Legend("◄", "Last channel") + if (scheduled != null) { + Legend("◄", "Last channel") + Legend("►", "Subtitles") + Legend("Hold OK", "Number") + } } } } 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 b478952..c3d823b 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -41,7 +41,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.28.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.29.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 5508f9c..7e80e04 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -230,6 +230,11 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea onLast = { val previous = lastNumber if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 } + }, + onNumber = { number -> + val exists = availableChannels.any { it.number == number } + if (exists && number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number; channelClockOffset = 0 } + exists }) fun tune(number: Int, offset: Long) { if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 176e13f..df501c0 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -19,7 +19,10 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.MediaItem +import androidx.media3.common.C import androidx.media3.common.PlaybackException +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.ExoPlayer @@ -35,7 +38,7 @@ import kotlinx.coroutines.withContext @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @Composable internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null, - onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) { + onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState? = null) { val context = LocalContext.current var failure by remember { mutableStateOf(null) } var tuningNumberVisible by remember { mutableStateOf(true) } @@ -44,6 +47,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl var infoVisible by remember { mutableStateOf(true) } var lastInteraction by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } fun showInfo() { infoVisible = true; lastInteraction = SystemClock.elapsedRealtime() } + var numberEntry by remember { mutableStateOf(null) } + var numberProblem by remember { mutableStateOf(null) } + var okHeld by remember { mutableStateOf(false) } + var subtitleIndex by remember { mutableStateOf(null) } + var subtitleNote by remember { mutableStateOf(null) } val player = remember(session, plan) { val factory = DataSource.Factory { SecureMediaDataSource(session) } ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)) @@ -56,6 +64,31 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl prepare(); playWhenReady = true } } + fun textTracks() = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT && it.isSupported } + fun chooseSubtitle(index: Int?, tracks: List) { + subtitleIndex = index + val builder = player.trackSelectionParameters.buildUpon().clearOverridesOfType(C.TRACK_TYPE_TEXT).setTrackTypeDisabled(C.TRACK_TYPE_TEXT, index == null) + if (index != null) builder.addOverride(TrackSelectionOverride(tracks[index].mediaTrackGroup, 0)) + player.trackSelectionParameters = builder.build() + } + fun cycleSubtitles() { + val tracks = textTracks() + val choice = nextSubtitle(subtitleIndex, tracks.size) + chooseSubtitle(choice, tracks) + val format = choice?.let { tracks[it].getTrackFormat(0) } + subtitlePreference?.value = format?.let { it.language.orEmpty() } + subtitleNote = when { + tracks.isEmpty() -> "No subtitles on this programme" + format == null -> "Subtitles off" + else -> "Subtitles: ${format.label ?: format.language ?: "Track ${choice + 1}"}" + } + } + fun submitNumber(entry: ChannelEntry) { + if (onNumber(entry.number)) { numberEntry = null; numberProblem = null } + else numberProblem = "Channel ${entry.number} isn't available" + } + LaunchedEffect(numberEntry) { if (numberEntry != null) { delay(10_000); numberEntry = null; numberProblem = null } } + LaunchedEffect(subtitleNote) { if (subtitleNote != null) { delay(3_000); subtitleNote = null } } val lifecycle = LocalLifecycleOwner.current.lifecycle var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } DisposableEffect(player, lifecycle) { @@ -79,6 +112,12 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl ready = false } override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying } + override fun onTracksChanged(tracks: Tracks) { + if (subtitleIndex == null) { + val available = textTracks() + subtitleFor(subtitlePreference?.value, available.map { it.getTrackFormat(0).language.orEmpty() })?.let { chooseSubtitle(it, available) } + } + } override fun onPlaybackStateChanged(state: Int) { if (state == Player.STATE_READY) { if (scheduled != null && !joinedSchedule) { @@ -116,13 +155,41 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl LaunchedEffect(infoVisible, lastInteraction, playing, ready) { if (infoVisible && playing && ready) { delay(5_000); infoVisible = false } } - Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false, + Dialog(onDismissRequest = { if (numberEntry != null) { numberEntry = null; numberProblem = null } else onClose() }, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { Box(Modifier.fillMaxSize().background(Color.Black)) { AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext -> PlayerView(nativeContext).apply { useController = false setOnKeyListener { _, code, event -> + val isOk = code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER + val typedDigit = if (code in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) code - KeyEvent.KEYCODE_0 else null + // Number keys and a held OK open the box. A short OK still shows the programme bar. + if (scheduled != null && event.action == KeyEvent.ACTION_DOWN) { + if (isOk && event.repeatCount > 0 && numberEntry == null && !okHeld) { + okHeld = true; numberProblem = null; numberEntry = ChannelEntry.from(scheduled.channel.number) + return@setOnKeyListener true + } + if (typedDigit != null && numberEntry == null) { numberProblem = null; numberEntry = ChannelEntry.from(scheduled.channel.number) } + } + if (isOk && okHeld) { + if (event.action == KeyEvent.ACTION_UP) okHeld = false + return@setOnKeyListener true + } + numberEntry?.let { entry -> + val advance = isOk && event.action == KeyEvent.ACTION_UP || code == KeyEvent.KEYCODE_DPAD_RIGHT && event.action == KeyEvent.ACTION_DOWN + if (advance) { if (entry.onLastSlot) submitNumber(entry) else numberEntry = entry.next() } + else if (event.action == KeyEvent.ACTION_DOWN) { + numberProblem = null + when { + code == KeyEvent.KEYCODE_DPAD_UP -> numberEntry = entry.dial(1) + code == KeyEvent.KEYCODE_DPAD_DOWN -> numberEntry = entry.dial(-1) + code == KeyEvent.KEYCODE_DPAD_LEFT -> numberEntry = entry.previous() + typedDigit != null -> if (entry.onLastSlot) entry.type(typedDigit).let { numberEntry = it; submitNumber(it) } else numberEntry = entry.type(typedDigit) + } + } + return@setOnKeyListener code != KeyEvent.KEYCODE_BACK + } val supported = code in listOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_UP, KeyEvent.KEYCODE_CHANNEL_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, @@ -137,7 +204,8 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_DOWN -> if (scheduled != null) onStep(-1) KeyEvent.KEYCODE_DPAD_LEFT -> if (scheduled != null) onLast() else player.seekBack() KeyEvent.KEYCODE_MEDIA_REWIND -> if (scheduled == null) player.seekBack() - KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> if (scheduled == null) player.seekForward() + KeyEvent.KEYCODE_DPAD_RIGHT -> if (scheduled != null) cycleSubtitles() else player.seekForward() + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> if (scheduled == null) player.seekForward() KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> if (scheduled == null) { if (player.isPlaying) player.pause() else player.play() } KeyEvent.KEYCODE_MEDIA_PLAY -> player.play() KeyEvent.KEYCODE_MEDIA_PAUSE -> if (scheduled == null) player.pause() @@ -164,6 +232,8 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl Image(painterResource(bug), null, Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 28.dp).width(108.dp).height(36.dp), alpha = .8f) if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing, Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled) + subtitleNote?.let { Text(it, Modifier.align(Alignment.TopCenter).padding(top = 28.dp).background(Navy).padding(horizontal = 14.dp, vertical = 6.dp), color = Color.White, fontSize = 15.sp) } + numberEntry?.let { ChannelNumberBox(it, numberProblem, Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) } if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) { Text(failure.orEmpty(), color = Color.White, fontSize = 20.sp) Spacer(Modifier.height(16.dp)) diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index dd6fc1d..b3a11b4 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -86,7 +86,9 @@ internal fun HomeScreen(channels: List, tuned: SampleChannel, mod } } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(HomeCorner).background(Panel)) { picture() } + HomeCard(Modifier.fillMaxWidth().aspectRatio(16f / 9f), onClick = { onWatch(tuned.number) }) { + Box(Modifier.fillMaxSize().padding(3.dp)) { picture() } + } val show = tuned.at(now) val next = tuned.at(show.endMs) val branding = ChannelBrands.forNumber(tuned.number) 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 a34579e..3819ba2 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -90,7 +90,9 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene @Composable internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long, - onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) { + onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }) { + // Outlives each programme and channel, and ends when the viewer leaves full screen. + val subtitlePreference = remember { mutableStateOf(null) } var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) } LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } } val shows = channel.guide(now, 2) @@ -121,7 +123,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC if (plan != null) { key(current.startMs) { EmbyPlaybackDialog(session, plan, onClose, - scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast) + scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference) } } else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { 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 4f4a1a0..a218cec 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.28.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.29.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/WatchingControls.kt b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt new file mode 100644 index 0000000..c04c994 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt @@ -0,0 +1,59 @@ +package tv.orbit.classic + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text + +/** The go-to-channel box. The Xiaomi remote may have no number keys, so up and down dial each digit. */ +internal data class ChannelEntry(val digits: List, val slot: Int = 0) { + val number get() = digits.fold(0) { total, digit -> total * 10 + digit } + val onLastSlot get() = slot == digits.lastIndex + fun dial(step: Int) = copy(digits = digits.mapIndexed { index, digit -> if (index == slot) Math.floorMod(digit + step, 10) else digit }) + fun type(digit: Int) = copy(digits = digits.mapIndexed { index, old -> if (index == slot) digit else old }, slot = minOf(slot + 1, digits.lastIndex)) + fun next() = copy(slot = minOf(slot + 1, digits.lastIndex)) + fun previous() = copy(slot = maxOf(slot - 1, 0)) + companion object { + /** Starts on the channel being watched, so a nearby channel is only a press or two away. */ + fun from(number: Int) = ChannelEntry(number.coerceIn(0, 999).toString().padStart(3, '0').map { it - '0' }) + } +} + +/** Right steps through the tracks and then off. */ +internal fun nextSubtitle(current: Int?, count: Int): Int? = when { + count == 0 -> null + current == null -> 0 + current + 1 < count -> current + 1 + else -> null +} + +/** 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 + return languages.indexOf(wanted).takeIf { it >= 0 } ?: 0 +} + +@Composable +internal fun ChannelNumberBox(entry: ChannelEntry, problem: String?, modifier: Modifier = Modifier) { + val corner = RoundedCornerShape(6.dp) + Column(modifier.background(Navy, corner).border(2.dp, Yellow, corner).padding(horizontal = 20.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Go to channel", color = Muted, fontSize = 14.sp) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + entry.digits.forEachIndexed { index, digit -> + Box(Modifier.size(36.dp, 42.dp).background(if (index == entry.slot) Yellow else Panel, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center) { + Text(digit.toString(), color = if (index == entry.slot) Navy else Color.White, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) + } + } + } + Text(problem ?: "▲▼ Change OK Next Back Cancel", color = if (problem != null) Yellow else Muted, fontSize = 11.sp) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt b/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt new file mode 100644 index 0000000..195e76a --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/WatchingControlsTest.kt @@ -0,0 +1,54 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test + +class WatchingControlsTest { + @Test + fun theNumberBoxStartsOnTheCurrentChannel() { + assertEquals(listOf(1, 0, 3), ChannelEntry.from(103).digits) + assertEquals(listOf(0, 0, 7), ChannelEntry.from(7).digits) + assertEquals(103, ChannelEntry.from(103).number) + } + + @Test + fun dialingWrapsWithinTheChosenDigit() { + val entry = ChannelEntry.from(103).next().next() + assertEquals(104, entry.dial(1).number) + assertEquals(102, entry.dial(-1).number) + assertEquals(100, ChannelEntry.from(109).next().next().dial(1).number) + assertEquals(903, ChannelEntry.from(103).dial(8).number) + } + + @Test + fun movingBetweenDigitsStopsAtTheEnds() { + val entry = ChannelEntry.from(103) + assertEquals(0, entry.previous().slot) + assertEquals(2, entry.next().next().next().slot) + assertTrue(entry.next().next().onLastSlot) + assertFalse(entry.onLastSlot) + } + + @Test + fun typedDigitsFillInOrder() { + val typed = ChannelEntry.from(101).type(2).type(5).type(9) + assertEquals(259, typed.number) + assertEquals(2, typed.slot) + } + + @Test + fun subtitlesStepThroughTracksThenOff() { + assertEquals(0, nextSubtitle(null, 2)) + assertEquals(1, nextSubtitle(0, 2)) + assertNull(nextSubtitle(1, 2)) + assertNull(nextSubtitle(null, 0)) + } + + @Test + fun subtitlesKeepTheirLanguageAcrossProgrammes() { + assertEquals(1, subtitleFor("eng", listOf("fre", "eng"))) + assertEquals(0, subtitleFor("eng", listOf("fre", "spa"))) + assertNull(subtitleFor(null, listOf("eng"))) + assertNull(subtitleFor("eng", emptyList())) + } +}