diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6466d592..d8c4cdd1 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 = 120 - versionName = "0.95.1-early-end-ident" + versionCode = 121 + versionName = "0.95.2-audio-clock-reset" } 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 267ad0c9..b49c9671 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.95.1\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.95.2\"") 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 276fb1bb..d772bffe 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -38,6 +38,25 @@ import kotlinx.coroutines.withContext /** A decoder fault normally repeats on the same file, so one more try is enough. */ private const val MAX_PLAYER_RETRIES = 1 +/** + * The box's audio clock can go wrong deep into a long film (seen 3 November on Godzilla's TrueHD passthrough, about + * 1 hour 40 in): every position report the sound hardware gives back is nonsense, ExoPlayer rejects them twice a second + * ("Spurious audio timestamp"), and the sound drifts out of sync with the picture until the track is reopened. + * While a scheduled programme has been playing steadily, Orbit asks ExoPlayer to stop on that instead, and reopens the + * sound at the same point: a one-second blip rather than a drifting film. Not during the first moments of a programme, + * where a single stray report is normal, and only a few times a programme, after which the old tolerant behaviour returns. + */ +internal const val AUDIO_CLOCK_SETTLE_MS = 30_000L +internal const val MAX_AUDIO_CLOCK_RESETS = 3 + +/** True when a playback error is ExoPlayer giving up on the sound hardware's position reports. */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal fun isAudioClockFault(error: Throwable): Boolean = + generateSequence(error) { it.cause }.any { it is androidx.media3.exoplayer.audio.DefaultAudioSink.InvalidAudioTrackTimestampException } + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal fun watchAudioClock(on: Boolean) { androidx.media3.exoplayer.audio.DefaultAudioSink.failOnSpuriousAudioTimestamp = on } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @Composable internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null, @@ -64,6 +83,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl var handedBack by remember { mutableStateOf(false) } var playerRetries by remember { mutableIntStateOf(0) } var audioRetries by remember { mutableIntStateOf(0) } + var audioClockResets 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") } @@ -118,9 +138,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl else numberProblem = "Channel ${entry.number} isn't available" } LaunchedEffect(trackProblem) { if (trackProblem != null) { delay(6_000); trackProblem = null } } - LaunchedEffect(playerRetries, audioRetries) { - if ((playerRetries > 0 || audioRetries > 0) && scheduled != null) { - delay(800) + LaunchedEffect(playerRetries, audioRetries, audioClockResets) { + if ((playerRetries > 0 || audioRetries > 0 || audioClockResets > 0) && scheduled != null) { + delay(if (audioClockResets > 0 && playerRetries == 0 && audioRetries == 0) 200 else 800) player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs())) player.prepare() } @@ -154,6 +174,15 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl playing = player.isPlaying val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { + // The sound hardware's clock went wrong: reopen at the same point, not a retry and never a failure. + if (scheduled != null && isAudioClockFault(error) && audioClockResets < MAX_AUDIO_CLOCK_RESETS) { + android.util.Log.w("OrbitSchedule", "channel=${scheduled.channel.number} audioClockReset=${audioClockResets + 1} positionMs=${player.currentPosition}") + PlaybackNotes.add(notes, "channel=${scheduled.channel.number} item=${plan.item.id} audioClockReset=${audioClockResets + 1} positionMs=${player.currentPosition}") + watchAudioClock(false) + ready = false + audioClockResets++ + return + } 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. @@ -230,6 +259,15 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl LaunchedEffect(player, ready) { if (ready) { delay(2_000); tuningNumberVisible = false } } + // Watch the audio clock only once the programme has played steadily for a while, and never under its ident. + LaunchedEffect(player, ready, ident != null, audioClockResets) { + watchAudioClock(false) + if (scheduled != null && ready && ident == null && audioClockResets < MAX_AUDIO_CLOCK_RESETS) { + delay(AUDIO_CLOCK_SETTLE_MS) + watchAudioClock(true) + } + } + DisposableEffect(player) { onDispose { watchAudioClock(false) } } val covered = ident != null LaunchedEffect(player, intro?.showing) { if (intro != null) player.volume = if (intro.showing) 0f else 1f } LaunchedEffect(player, covered) { 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 9fd2e688..c5d08ae4 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.95.1") + next.setRequestProperty("User-Agent", "OrbitTV/0.95.2") 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/AudioClockTest.kt b/android/app/src/test/java/tv/orbit/classic/AudioClockTest.kt new file mode 100644 index 00000000..8fdfdf44 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/AudioClockTest.kt @@ -0,0 +1,27 @@ +package tv.orbit.classic + +import androidx.media3.exoplayer.audio.DefaultAudioSink +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class AudioClockTest { + @Test fun anAudioClockFaultIsRecognisedThroughTheErrorChain() { + // ExoPlayer makes this one itself; its constructor is private, so the test reaches it the same way. + val fault = DefaultAudioSink.InvalidAudioTrackTimestampException::class.java.getDeclaredConstructor(String::class.java) + .apply { isAccessible = true }.newInstance("Spurious audio timestamp (frame position mismatch)") + // ExoPlayer wraps it in its own playback error; the check walks the chain of causes. + assertTrue(isAudioClockFault(RuntimeException("playback error", fault))) + assertTrue(isAudioClockFault(fault)) + } + + @Test fun otherPlaybackErrorsAreNotTreatedAsAudioClockFaults() { + assertFalse(isAudioClockFault(RuntimeException("playback error", IllegalStateException("decoder")))) + } + + @Test fun watchingCanBeSwitchedOnAndOff() { + watchAudioClock(true); assertTrue(DefaultAudioSink.failOnSpuriousAudioTimestamp) + watchAudioClock(false); assertFalse(DefaultAudioSink.failOnSpuriousAudioTimestamp) + } +} diff --git a/docs/VERIFICATION-0.95.2.md b/docs/VERIFICATION-0.95.2.md new file mode 100644 index 00000000..74413c23 --- /dev/null +++ b/docs/VERIFICATION-0.95.2.md @@ -0,0 +1,13 @@ +# Orbit TV 0.95.2: reopen the sound when the box's audio clock goes wrong + +A small fix of its own, approved by Paul on 3 November 2026. No scheduler, artwork or plan changes. + +**What happened:** on the Drive-In (300) on 3 November, Godzilla (HEVC 4K with Dolby TrueHD 8-channel sent straight to the amplifier) started on time at 20:00. By about 21:37 the sound had drifted behind the picture. The box's log showed ExoPlayer warning twice a second: "Spurious audio timestamp (frame position mismatch)", with the hardware's playback position reading about 89,478 seconds (2^32 frames at 48 kHz, a wrapped counter). The sound hardware's position reports had become nonsense, so the player could no longer keep the picture in step. Retuning (a fresh audio track) fixed it, which Paul did at 21:48. + +**The change:** once a scheduled programme has played steadily for 30 seconds (`AUDIO_CLOCK_SETTLE_MS`), Orbit sets ExoPlayer's `DefaultAudioSink.failOnSpuriousAudioTimestamp`, so a bad position report stops the player instead of being tolerated. That error (`isAudioClockFault`) is handled on its own: the player reopens at the programme's live position after 200 ms, with no failure message, and not counted as a retry. Logged as `audioClockReset=N positionMs=…` in logcat (OrbitSchedule) and the playback notes. At most 3 resets a programme (`MAX_AUDIO_CLOCK_RESETS`), then the old tolerant behaviour returns. Watching is off during the first 30 seconds of a programme (a single stray report is normal there), off under the ident, and switched off when the player closes. + +**Unchanged:** schedules, plans, the guide, idents, preloading, and every other error path (the one retry, then "won't play"). + +Files: EmbyPlaybackDialog.kt (`watchAudioClock`, `isAudioClockFault`, the reset path and the settle timer); AudioClockTest (3 tests). 363 unit tests pass; lint has no errors. Not installed. + +**To check on the TV:** a long film with TrueHD or DTS passthrough. If the fault returns, there should be a one-second blip and the sound back in sync, with `audioClockReset` in logcat, rather than a drift. If resets show up often early in films, raise the settle time.