0.81.3: a failed audio choice recovers and no longer marks the file, and playback notes
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m46s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m29s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 00:52:39 +00:00
co-authored by Claude Opus 5.5
parent ecfcfbe3e2
commit 963b7b0a0e
8 changed files with 83 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.81.3: a failed audio choice no longer condemns the programme
Paul changed the audio on Misery at the Drive-In and the player stopped. Android recorded no crash. Misery was on the box's list of files that won't play, so the player had failed, retried the same sound, then failed again and marked the file.
- If playback fails after the viewer chose a sound, the player goes back to the sound it had, says "That sound won't play on this box" and rejoins the programme. The file isn't marked.
- `no_backup/schedule/playback-notes.txt` now records each programme's chosen audio and video format and any player error, trimmed at 32 KB, because the system log here only reaches back a few minutes. Formats and item ids only. Sign out removes it.
- Unit tests: 308 passed, 0 failed. Lint passed.
Not proven:
- The recovery itself, which needs the same failure on the box.
- Why Misery's sound was out of sync. The log had gone by the time I looked, so its format is unknown. Worth checking: on the one programme logged after 0.81.2, the box was sending E-AC3 straight to the HDMI ARC output without decoding it, which may or may not apply to Misery.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 93
versionName = "0.81.2-settings-focus"
versionCode = 94
versionName = "0.81.3-audio-choice"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.81.2\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.81.3\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -55,6 +55,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
fun showInfo() { infoVisible = true; lastInteraction = SystemClock.elapsedRealtime() }
var handedBack by remember { mutableStateOf(false) }
var playerRetries by remember { mutableIntStateOf(0) }
var audioRetries by remember { mutableIntStateOf(0) }
var audioChosen by remember { mutableStateOf(false) }
var trackProblem by remember { mutableStateOf<String?>(null) }
val notes = remember { java.io.File(context.noBackupFilesDir, "schedule") }
val openedAt = remember { SystemClock.elapsedRealtime() }
var numberEntry by remember { mutableStateOf<ChannelEntry?>(null) }
var numberProblem by remember { mutableStateOf<String?>(null) }
@@ -84,6 +88,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(TrackSelectionOverride(group.mediaTrackGroup, 0)).build()
audioPreference?.value = group.getTrackFormat(0).language
audioChosen = true
}
fun trackRows(): List<TrackRow> {
val subtitles = textTracks()
@@ -104,8 +109,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
if (onNumber(entry.number)) { numberEntry = null; numberProblem = null }
else numberProblem = "Channel ${entry.number} isn't available"
}
LaunchedEffect(playerRetries) {
if (playerRetries > 0 && scheduled != null) {
LaunchedEffect(trackProblem) { if (trackProblem != null) { delay(6_000); trackProblem = null } }
LaunchedEffect(playerRetries, audioRetries) {
if ((playerRetries > 0 || audioRetries > 0) && scheduled != null) {
delay(800)
player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
player.prepare()
@@ -141,6 +147,17 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
android.util.Log.w("OrbitSchedule", "channel=${scheduled?.channel?.number} playerError=${error.errorCodeName} retry=$playerRetries")
PlaybackNotes.add(notes, "channel=${scheduled?.channel?.number} item=${plan.item.id} error=${error.errorCodeName} audioChosen=$audioChosen")
// The viewer's choice of sound failed, not the programme. Back to the sound that was working, and the file keeps its good name.
if (scheduled != null && audioChosen) {
audioChosen = false
player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().clearOverridesOfType(C.TRACK_TYPE_AUDIO).build()
audioPreference?.value = null
trackProblem = "That sound won't play on this box"
ready = false
audioRetries++
return
}
// A decoder can also be taken away by a quick channel change, so a channel tries once more before giving up.
if (scheduled != null && playerRetries < MAX_PLAYER_RETRIES) {
ready = false
@@ -159,6 +176,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying }
override fun onTracksChanged(tracks: Tracks) {
tracks.groups.filter { it.isSelected && (it.type == C.TRACK_TYPE_AUDIO || it.type == C.TRACK_TYPE_VIDEO) }.forEach { group ->
val format = (0 until group.length).firstOrNull(group::isTrackSelected)?.let(group::getTrackFormat) ?: return@forEach
PlaybackNotes.add(notes, "channel=${scheduled?.channel?.number} item=${plan.item.id} " + PlaybackNotes.describe(if (group.type == C.TRACK_TYPE_AUDIO) "audio" else "video",
format.sampleMimeType, format.channelCount, format.sampleRate, format.language, format.width, format.height))
}
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
// Sound with no picture: the box can't decode this file's video.
if (scheduled != null && video.isNotEmpty() && video.none { it.isSupported }) {
@@ -324,6 +346,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled)
ident?.invoke()
trackMenu?.let { TrackPanel(it, trackCursor, Modifier.align(Alignment.CenterEnd).padding(end = 28.dp)) }
trackProblem?.let { Text(it, Modifier.align(Alignment.TopCenter).padding(top = 44.dp).background(Navy.copy(alpha = .85f)).padding(horizontal = 14.dp, vertical = 8.dp), color = Color.White, fontSize = 16.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)
@@ -0,0 +1,25 @@
package tv.orbit.classic
import java.io.File
/** What the box actually played, kept past the system log's few minutes. Formats and item ids only: never titles, addresses or the account. */
internal object PlaybackNotes {
private const val KEEP_BYTES = 32 * 1024
private const val TRIM_TO_LINES = 150
private const val FILE = "playback-notes.txt"
fun add(directory: File, line: String) {
runCatching {
directory.mkdirs()
val file = File(directory, FILE)
file.appendText("${java.time.Instant.now()} $line\n")
if (file.length() > KEEP_BYTES) file.writeText(keepLast(file.readLines(), TRIM_TO_LINES))
}
}
internal fun keepLast(lines: List<String>, count: Int) = lines.takeLast(count).joinToString("\n", postfix = "\n")
/** Each selected track, such as audio/eac3 6ch 48000Hz en. */
internal fun describe(type: String, mime: String?, channels: Int, rate: Int, language: String?, width: Int, height: Int) =
if (type == "audio") "audio=$mime ${channels}ch ${rate}Hz ${language.orEmpty()}".trim() else "video=$mime ${width}x$height"
}
@@ -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.81.2")
next.setRequestProperty("User-Agent", "OrbitTV/0.81.3")
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,16 @@
package tv.orbit.classic
import org.junit.Assert.assertEquals
import org.junit.Test
class PlaybackNotesTest {
@Test fun describesEachKindOfTrack() {
assertEquals("audio=audio/eac3 6ch 48000Hz en", PlaybackNotes.describe("audio", "audio/eac3", 6, 48000, "en", -1, -1))
assertEquals("audio=audio/aac 2ch 44100Hz", PlaybackNotes.describe("audio", "audio/aac", 2, 44100, null, -1, -1))
assertEquals("video=video/hevc 1920x1080", PlaybackNotes.describe("video", "video/hevc", -1, -1, null, 1920, 1080))
}
@Test fun trimmingKeepsTheNewest() {
assertEquals("c\nd\n", PlaybackNotes.keepLast(listOf("a", "b", "c", "d"), 2))
}
}
+2
View File
@@ -72,3 +72,5 @@ The player's NEXT line asks the channel for the following programme, and between
- Child safety isn't a Tots feature. A PIN and lock should exist before this app goes to anyone else with children.
- Tots' first plan took about 13 s to work out, on a pool of 4,141 episodes, in the background. Fine here, but a build-your-own channel with a big pool should not block anything on it.
- In Settings, right from the rail could land on the top bar (fixed 0.81.2). Screens with a bar above and tall content beside a rail want explicit focus routes.
- A viewer's audio choice failing marked the whole file as unplayable. Anything that writes to the failed list should be sure the file, not a choice or a moment, was at fault.
- The box's main log only reaches back about 10 minutes during playback, so anything worth diagnosing later needs its own small file.