Add subtitles on right, hold OK for channel number, selectable picture (0.29.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m33s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m26s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 23:04:07 +00:00
co-authored by Claude Opus 5.5
parent 247262b726
commit 18967e1531
11 changed files with 225 additions and 11 deletions
+18
View File
@@ -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.
+2 -2
View File
@@ -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()))
}
}