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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user