From 963b7b0a0e416ebf205e7bbfd9bf68f4c87316e7 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Tue, 3 Nov 2026 00:52:39 +0000 Subject: [PATCH] 0.81.3: a failed audio choice recovers and no longer marks the file, and playback notes Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.81.3.md | 11 ++++++++ android/app/build.gradle.kts | 4 +-- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyPlaybackDialog.kt | 27 +++++++++++++++++-- .../java/tv/orbit/classic/PlaybackNotes.kt | 25 +++++++++++++++++ .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../tv/orbit/classic/PlaybackNotesTest.kt | 16 +++++++++++ docs/refinement-notes.md | 2 ++ 8 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 VERIFICATION-0.81.3.md create mode 100644 android/app/src/main/java/tv/orbit/classic/PlaybackNotes.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/PlaybackNotesTest.kt diff --git a/VERIFICATION-0.81.3.md b/VERIFICATION-0.81.3.md new file mode 100644 index 0000000..e233318 --- /dev/null +++ b/VERIFICATION-0.81.3.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 112bea3..6349759 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 = 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 } 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 d9e3595..c2719ec 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.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) 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 01d3050..88016bb 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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(null) } + val notes = remember { java.io.File(context.noBackupFilesDir, "schedule") } val openedAt = remember { SystemClock.elapsedRealtime() } var numberEntry by remember { mutableStateOf(null) } var numberProblem by remember { mutableStateOf(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 { 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) diff --git a/android/app/src/main/java/tv/orbit/classic/PlaybackNotes.kt b/android/app/src/main/java/tv/orbit/classic/PlaybackNotes.kt new file mode 100644 index 0000000..c860fd7 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/PlaybackNotes.kt @@ -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, 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" +} 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 c50547a..2cacfc7 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.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/PlaybackNotesTest.kt b/android/app/src/test/java/tv/orbit/classic/PlaybackNotesTest.kt new file mode 100644 index 0000000..67293ee --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PlaybackNotesTest.kt @@ -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)) + } +} diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md index c347ea6..dce6d30 100644 --- a/docs/refinement-notes.md +++ b/docs/refinement-notes.md @@ -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.