Add subtitles on right, hold OK for channel number, selectable picture (0.29.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String?>? = null) {
|
||||
val context = LocalContext.current
|
||||
var failure by remember { mutableStateOf<String?>(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<ChannelEntry?>(null) }
|
||||
var numberProblem by remember { mutableStateOf<String?>(null) }
|
||||
var okHeld by remember { mutableStateOf(false) }
|
||||
var subtitleIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var subtitleNote by remember { mutableStateOf<String?>(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<Tracks.Group>) {
|
||||
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))
|
||||
|
||||
@@ -86,7 +86,9 @@ internal fun HomeScreen(channels: List<SampleChannel>, 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)
|
||||
|
||||
@@ -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<String?>(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)) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<Int>, 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<String>): 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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user