Skip to content

fix(player): address audio by the server's contract, and own the viewer's pick - #173

Merged
RXWatcher merged 11 commits into
Silo-Server:mainfrom
RXWatcher:codex/player-audio-selection
Aug 6, 2026
Merged

fix(player): address audio by the server's contract, and own the viewer's pick#173
RXWatcher merged 11 commits into
Silo-Server:mainfrom
RXWatcher:codex/player-audio-selection

Conversation

@RXWatcher

@RXWatcher RXWatcher commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Audio selection was addressed the wrong way on both clients, and the viewer's pick had no single owner.

The server contract

The server sends no wire index for audio tracks — subtitles carry one, audio does not. Audio is addressed by its ordinal in the version's audio_tracks. Both clients were mapping picks through AudioTrack.index, a field the server never populates for audio, so every selection collapsed to 0. Choosing any track played the first one.

One owned intent

The desired track was spread across several nullable fields that raced to decide the same thing — the detail page's launch pick, a persisted fingerprint, the HUD, and the remote each wrote their own. This replaces them with one generation-owned intent that every entry point writes to, reconciled against each track snapshot by a single resolver.

That matters because a pick has to survive things that can each fail independently: staging, validation, adoption, mount, and rollback. A flag raised on intent carried whatever track survived a failure into the next episode as though it had been chosen; it is now set on commit.

Direct-play streams

A direct-play stream carries every audio track, so Media3 still picked its own default even when the start request named the viewer's choice — selecting Dutch on the detail page and pressing Play mounted English. Both clients now switch on the player when the stream already carries the track, rather than assuming the server's plan settled it.

Labelling

Player audio is labelled by source identity rather than delivered format. A transcoded stream reports what the transcoder produced, so a 5.1 source delivered as stereo was labelled stereo — the viewer could no longer find the track they picked.

Shared logic

The reconcile decision and its intent types are extracted and shared between the clients, with the TV path's failure modes covered by tests, so the two implementations cannot drift apart again.

Testing

Full suite green on all four modules. One flake surfaced under parallel load in PlayerViewModelLoadOwnershipIntegrationTest — a 5s real-time awaitCondition deadline, unrelated to this change; it passes consistently on re-run, and the harness is reworked later in the stack.

Merged main in to resolve one adjacency conflict in TvPlayerViewModel where this branch's audio block met a KDoc relocated by #170; both sides kept.

🤖 Generated with Claude Code

https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW

Summary by CodeRabbit

  • New Features

    • Improved audio-track selection and persistence across Android mobile and TV playback.
    • Added clearer audio labels, default indicators, and support for tracks unavailable in the current stream.
    • Preserved explicitly chosen audio across navigation, remounts, episode handoffs, and file changes.
    • Added reliable local switching with confirmation and recovery when tracks become available.
  • Bug Fixes

    • Fixed incorrect audio selections caused by unreliable track identifiers.
    • Improved handling of reordered, duplicate, missing, or ambiguous audio tracks.
    • Prevented restored selections from being mistaken for newly chosen tracks.

RXWatcher and others added 11 commits August 5, 2026 13:02
The HUD Audio row described what the transcode delivered rather than
what the file holds. "Reasonable Doubt" carries English DTS 5.1; the
session transcodes it to stereo AAC, and the row read "UND AAC Stereo"
while the detail page and its AUDIO selector both correctly said
"English - DTS - 5.1".

The row now resolves the source track from the playback plan's stable
server audio index and labels it from the catalog, falling back to the
mounted Media3 track only when there is no plan identity or no catalog
audio metadata.

audioSummaryForServerIndex looks the track up by AudioTrack.index rather
than by list position. Those are different numbers: the server index is
a serialized field and is not constrained to equal its position, so a
positional lookup silently resolves the wrong row whenever indices are
non-contiguous.

Verified on device: a DTS 5.1 source in a transcoded session now reads
"English - DTS - 5.1".

Deliberately NOT included, because they are a separate change rather
than a labelling fix -- an adjudication with Codex established the blast
radius is wider than the HUD:

- selectedServerAudioTrackIndex still prefers the Media3 ordinal over
  the plan index and maps it positionally into the catalog. That is the
  documented UNRESOLVED note at TvPlayerViewModel: it is structurally
  guaranteed to pick the wrong source track for a one-audio remux of a
  non-first track.
- The HUD picker is still gated and keyed on the Media3 track count, so
  a transcoded multi-audio title cannot be switched at all.
- The detail screen sends a catalog-list ORDINAL through navigation and
  the player submits it as the server's stable index.
- Episode carry-over captures language/codec/channels from the delivered
  track, so a transcoded DTS source becomes an AAC preference for the
  next episode.
- The remote command contract names its value audio_track_index but TV
  handles it as a player ordinal.

None of that is verifiable here: every audio file in this library has a
single track, so the multi-track path cannot be exercised on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Probing the running server (official latest, sha256:5f83c105, 2026-08-04)
for a two-audio-track file settles this:

  AUDIO {"title":"English DTS 5.1","language":"en","codec":"dts",...}
  AUDIO {"title":"Dutch AAC Stereo","language":"nl","codec":"aac",...}
  SUB   {"index":2,"language":"en",...}

Audio tracks carry NO index on the wire. Subtitles do. So
AudioTrack.index deserialises to its `0` default on every audio row and
identifies nothing, while `effective_audio_track_index` and the V3
start/replan `audio_track_index` are ORDINALS into audio_tracks --
`audio_track_id` is even built as `file:<id>:audio:<ordinal>`.

Every consumer that resolved audio through AudioTrack.index was
therefore reading 0:

- selectAudioOption mapped the pick through getOrNull(ordinal).index, so
  every explicit audio choice asked the server for track 0. Picking
  Dutch played English.
- The HUD picker keyed rows on that index, so a two-track file rendered
  both rows as "English - DTS - 5.1 - Default".
- tvAudioTrackPersistenceUpdate matched on it and found nothing above 0,
  so a committed choice was silently never persisted and reopening the
  item lost it.
- resolveTvRemoteAudioIntent returned it, so remote set_audio_track
  always requested 0.
- Episode carry-over built every EpisodeAudioCandidate index from it,
  collapsing all candidates to 0.

All of the above now use the catalog ordinal. The HUD is driven from the
catalog rather than from Media3, so the row is gated on what the FILE
holds instead of what this stream delivered -- a transcode collapses
Media3 to one track, which disabled the row outright and made audio
unswitchable for the whole session.

selectedServerAudioTrackIndex also now prefers the plan over the mounted
Media3 ordinal, validated against the catalog. That resolves the
UNRESOLVED note in place: a transcoded stream carries only the chosen
track and reports ordinal zero, so preferring it made the next replan
ask for track zero and reverted the audio to the first language.

Verified on device against a purpose-built two-track fixture (English
DTS 5.1 / Dutch AAC stereo at non-contiguous stream indices): the picker
lists both correctly, and selecting Dutch commits with the HUD following.

Still on AudioTrack.index and NOT migrated here -- phone player
selection/persistence, the shared audio fingerprint, and the media-info
dialog. AudioTrack.index should ultimately be deleted so the remaining
assumptions become compile errors rather than silent zeroes.

Open: after the switch the plan and HUD report Dutch, but the Info tab
still shows the mounted format as AUDIO/VND.DTS. That is either stale
stats or the server acknowledging the ordinal while still delivering
track 0; the Media3 track snapshot after the replan tells them apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Groundwork for making TV audio selection actually switch audio.

Device evidence (Shield, two-audio-track fixture): selecting "Dutch AAC
Stereo" updated the plan and the HUD, but the Media3 track snapshot still
read `2:audio[selected=true audio/vnd.dts language=en]` with Dutch
`selected=false`, and the audio HW config stayed at 6 channels. The
stream direct-plays and carries BOTH tracks; TV audio selection only ever
stages a server replan and never calls AudioTrackManager.selectAudioTrack,
so the local selection never moves.

matchMountedAudioTrack answers "which mounted track IS this catalog row",
which is the piece any fix needs and which cannot be done positionally:
Media3 ordinals and catalog ordinals are different spaces, and in the
fixture the mounted order is the reverse of the catalog's.

canonicalAudioCodecFamily exists because the existing episode-handoff
normaliser only strips to the last path segment, leaving `mp4a-latm`
against a catalog `aac` -- it matches nothing. Both sides now reduce to a
family, so catalog `dts`/`aac` meet Media3 `audio/vnd.dts`/
`audio/mp4a-latm`.

The matcher is deliberately conservative. A transcoded representation
must NOT match its own source -- a DTS 5.1 row delivered as
undetermined-language stereo AAC returns null, because the right answer
there is a server replan, not playing the wrong track. Unresolved ties
return null for the same reason resolveEpisodeAudioIntent does.

Lives in android-shared, not the TV app: phone stages the identical
replan-only path and is exposed to the same bug whenever its direct-play
stream carries multiple audio tracks.

No behaviour change yet -- nothing calls this. The wiring is a larger
piece: it needs generation-tagged requests, apply-then-confirm through
onTracksChanged before committing, pending vs committed state kept
separate, re-application after every remount (the Media3 override is
bound to a MediaTrackGroup that setMediaItem replaces), HUD selection
read from committed state rather than the plan, and the positional
assumption in persisted-selection restore fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Audio selection now actually changes the audio.

Verified on device (Shield, two-audio-track fixture, diagnostics build):

  want=[lang=nl codec=aac ch=2 title=Dutch AAC Stereo]
  mounted=[ord=0 lang=nl codec=audio/mp4a-latm ch=2 label=Dutch AAC Stereo],
          [ord=1 lang=en codec=audio/vnd.dts  ch=6 label=English DTS 5.1]
  -> match=0
  Track snapshot: 1:audio[selected=true  mp4a.40.2     nl Dutch AAC Stereo]
                  2:audio[selected=false audio/vnd.dts en English DTS 5.1]
  onTracksChanged audioCount=2 selectedOrdinal=0 pending=0

Media3 switched in place, with no remount and no player error.

Before this, TV audio selection only ever staged a server replan. For a
direct-play stream carrying several audio tracks the server acknowledged
the new ordinal and the plan and HUD moved, while the renderer kept
decoding the original -- the HUD claimed Dutch over audible English.

selectAudioOption now asks matchMountedAudioTrack whether the mounted
stream already carries the chosen catalog row. If it does, the track is
selected on the player and the replan skipped: rebuilding the session to
deliver audio the viewer is already receiving is pointless, and the
replan path is heavier and remounts. If it does not -- a genuine
single-track transcode -- it falls through to the replan exactly as
before.

Nothing commits on request. AudioTrackManager.selectAudioTrack returns
Unit and does nothing silently when the group is absent, so "we asked" is
not evidence; the choice is committed only once onTracksChanged shows the
target selected. Requests carry a generation so a stale callback cannot
commit a newer choice, and the row renders a pending selection as "…"
rather than claiming a track the player has not switched to.

The one case with no confirmation event is re-selecting the track already
playing: Media3 emits no onTracksChanged for a no-op override, which
would strand the row on "…" forever, so that commits immediately. Found
by driving it on the device.

On commit the choice outranks the plan for the HUD and for every later
replan request, so a subtitle/quality/output-route replan can no longer
quietly reinstate the server's track, and it is persisted as an audio
fingerprint with the subtitle side left Preserved. A remount installs a
new MediaTrackGroup and the override was bound to the old one, so the
committed choice is re-applied from onTracksChanged rather than assumed
to survive.

Separately observed and NOT fixed here: starting playback with Dutch
already chosen on the detail page still mounts English
(selectedOrdinal=1), so the start path does not apply the detail
selection to the player either. Phone remains on the replan-only path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Choosing audio on the detail page and pressing Play still mounted
English: the start request carried the pick and the server honoured it in
the plan, but a direct-play stream delivering every track let Media3
choose its own default and nothing applied the choice to the player.

Verified on device (Shield, two-track fixture), 76ms apart:
  1:audio[selected=false nl Dutch]  2:audio[selected=true  en English]
  1:audio[selected=true  nl Dutch]  2:audio[selected=false en English]

Four entry points were each deciding the same thing through their own
nullable field -- the launch ordinal, a persisted fingerprint, the HUD,
and the remote -- so they raced, and the winner was whichever ran last.
They now write one generation-owned TvDesiredAudio and a single resolver
reconciles it against every track snapshot.

The intent is never cleared on read. Clearing it on the first callback
discarded it whenever that callback was empty or partial, which
reproduced the original bug exactly. Because it stays live it also serves
as the remount re-application path: setMediaItem replaces the
MediaTrackGroup the override was bound to, so a confirmed choice has to
be applied again rather than assumed to survive.

Confirmation resolves the wanted row once against the whole mounted
snapshot and compares ordinals within that snapshot. Matching against a
one-element list instead asks a different question -- the matcher stops
as soon as one candidate remains, so a main mix and its commentary, same
language and codec, would confirm each other.

The intent is scoped to a fileId. The outgoing version stays interactive
while a replacement loads, and audio ordinals are per-file, so a pick
made in that window would otherwise be reconciled against the new file
and silently change language. onSelectFileVersion now validates before
mutating: a no-op or unknown id used to drop the intent and then return
without switching anything.

Requests carry the mount nonce, so a remount that resolves the wanted
track to the same ordinal still reissues instead of matching the
in-flight request and leaving the override on a replaced group.

An explicit choice claims the durable restore, so a later fingerprint
resolution cannot mint a newer generation for an older decision.
Launch and persisted restores commit without raising
manualAudioSelectionApplied: putting playback back where it was is not a
fresh decision and must not become an episode-carry-over preference.

Known gaps, deliberately not fixed here:
- Mount identity keys on transportMountNonce, which tracks intended
  primary mounts. A subtitle-refresh remount replaces the MediaItem
  without changing it, so re-application can be missed there. The right
  fix is a backend-owned MediaItem generation incremented on every
  setMediaItem, passed with the track snapshot.
- Launch provenance: a fresh detail-page pick and a durable value seeded
  onto the detail page arrive as the same nullable ordinal, so both are
  treated as restores. A genuine pick therefore restores correctly but
  does not carry to the next episode. Distinguishing them needs a flag
  threaded through the nav route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Closes the two gaps left by the unified audio intent.

Mount identity. Reapplication after a remount was keyed on
transportMountNonce, which tracks INTENDED primary mounts -- a subtitle
refresh replaces the media item without moving it. Threading a
backend-owned setMediaItem counter instead looked like the answer but is
not sound either: the counter is read separately from the Tracks object,
so a callback queued for the old item can observe the new value and
suppress exactly the reissue that mattered, and a backend rebuilt by
Compose restarts its count and collides with the old one.

There is no reliable mount identity available from outside the backend,
so the request no longer claims one. Each issuance carries a monotonic
attempt instead, purely so StateFlow cannot conflate it -- an identical
value is dropped, and re-applying after a remount looks identical. The
resolver now simply reissues on any snapshot where the wanted track is
present and unselected. Applying the override is idempotent and
onTracksChanged only fires when tracks actually change, so it cannot
spin, and no mount bookkeeping is needed at all. The backend surface
added for this is removed again rather than left as a misleading handle.

Provenance. A fresh detail-page pick and a durable value seeded onto that
page arrived as the same ordinal, so both were treated as restores and a
real pick never carried to the next episode. TvItemDetailViewModel now
records audioPickedThisSession -- set only by onAudioTrackSelected,
cleared on version switch, never by seedPersistedTrackSelection -- and it
travels through onPlay, the Player route (audioPicked=true, emitted only
when true) and into TvDesiredAudio.explicit.

Series and season detail select through a separate next-up path, whose
handler set no provenance while Play forwarded the unrelated
container-level flag, so a fresh next-up pick was misreported. It now has
its own nextUpAudioPickedThisSession, and the screen reads whichever
matches the ordinal it is actually sending.

The route argument is declared in the graph with a default rather than
left to inference, so a restored back stack has a defined value.

Verified on device: launching with Dutch restored, Media3 selects English
at mount and the resolver corrects it 72ms later
  1:audio[selected=false nl]  2:audio[selected=true  en]
  1:audio[selected=true  nl]  2:audio[selected=false en]

Tests: route emits audioPicked only for a fresh pick, and the ROUTE
pattern declares the argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Every review round of this area produced a case that neither reasoning
nor the pure-matcher tests caught -- a discarded intent, a false
confirmation, a cross-file ordinal. The decision logic lived inside a
ViewModel with fourteen constructor dependencies, so none of it was
reachable from a test.

reconcileDesiredAudioAction is that decision as a pure function over
(desired, activeFileId, catalog, mounted, selectedOrdinal,
planAudioOrdinal), returning None / DropForeignFile / Confirm /
Apply(ordinal). The ViewModel keeps the orchestration -- generations,
persistence, the request flow -- and now just dispatches on the result.

The cases are the bugs, written down:

- an empty or partial snapshot decides nothing and does NOT consume the
  intent, which is what made a launch pick silently fail
- the wanted track present but unselected applies its MOUNTED ordinal,
  with catalog order deliberately reversed against mounted order so a
  positional answer fails the test
- an intent belonging to another file is dropped rather than applied
- an absent track is satisfied only when the plan names that catalog row,
  since a transcode's recoded output cannot identity-match its source
- commentary does not confirm the main mix: same language, same codec,
  and resolving against a one-element list let them confirm each other
- a reorder moves the target and invalidates the ordinal that used to be
  right
- a confirmed choice is reapplied once the player is no longer on it

Mutation-checked: making an empty snapshot consume the intent fails
emptySnapshotDecidesNothing and nothing else.

Device re-verified after the extraction: Media3 selects English at mount,
the resolver corrects to Dutch 22ms later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
The phone player had the same phantom-index bug TV did. Audio is
addressed by ORDINAL into audio_tracks -- the wire carries no index for
audio tracks, only subtitles get one -- so AudioTrack.index deserialises
to its 0 default on every row.

- selectedServerAudioTrackIndex returned audioTracks.getOrNull(ordinal)
  .index, i.e. 0 for every track: every explicit pick asked the server
  for track 0, so choosing the second language played the first. It now
  returns the range-checked ordinal.
- selectedAudioTrackOrdinal searched by .index with two fallbacks; it is
  the identity mapping.
- Persistence resolved committed.audioTrackIndex through
  firstOrNull { it.index == ... }, which matched nothing above row zero,
  so a committed choice was silently never written and reopening the item
  lost it. Extracted as mobileAudioTrackPersistenceUpdate and resolved by
  ordinal, mirroring TV.
- Subtitle auto-selection searched .index first and only worked because
  of the ordinal fallback behind it; it is a direct ordinal lookup now.

Test fixtures across the phone suite fabricated AudioTrack(index = 2) and
index = 7 -- shapes the server never sends -- and were preserving the
mistake. They now leave index unset and make the ordinal meaningful by
supplying two rows.

Mutation-checked: restoring the .index lookup in persistence fails the
new committed-ordinal test and nothing else.

Restore needed no change: it already resolved the fingerprint to a
catalog ordinal, which is now consistent with the rest of the chain.
Stored fingerprints stay valid -- audioTrackFingerprint includes
track.index, which has always been 0 in practice, so current rows
generate the same string and no migration is required.

Still open on phone: direct-play multi-audio. The server plan now records
the right ordinal, but phone's onTracksChanged handles subtitles only and
never applies a Media3 override, so a file delivering several audio
tracks can show the chosen row while playback stays on Media3's default.
Transcoded and server-materialised audio are fixed by this change; direct
play needs the local-selection path TV now has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Moves the desired-audio decision out of androidTvApp into android-shared
so phone can reuse it rather than growing a second copy:
TvAudioReconcile -> AudioReconcile, TvDesiredAudio -> DesiredAudio,
TvLocalAudioSelection -> LocalAudioSelection, TvAudioReconcileAction ->
AudioReconcileAction. The tests move with it.

No behaviour change: TV imports the same functions it called before, and
the full suite is unchanged.

Phone's local (no-replan) selection is NOT included. It was written and
reviewed, and is held back deliberately -- see below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
…s it

Verified on a Pixel 10 Pro XL against the two-track fixture. Selecting
"Dutch AAC Stereo" in the tracks sheet:

  1:audio[selected=true   mp4a.40.2      nl  Dutch AAC Stereo]
  2:audio[selected=false  audio/vnd.dts  en  English DTS 5.1]

One snapshot, no remount -- switched in place.

Phone's audio selection only ever staged a server replan, and its
onTracksChanged handled subtitles alone, so a direct-play file carrying
several audio tracks showed the chosen row while the renderer kept
decoding Media3's default.

It now runs the same machinery as TV: one generation-owned DesiredAudio
that every entry point writes, reconciled against each track snapshot by
the shared decision, applied through the backend and committed only once
a snapshot shows the target selected.

Three things this needed beyond copying TV, each found in review:

- A local confirmation has to publish what a replan commit publishes.
  uiState.selectedAudioIndex, routeIntentState.applyCommittedTracks and
  the transaction's committed audio all previously moved only when the
  adapter published a snapshot, so a local switch left the sheet
  checkmark, route redelivery, recovery and Cast on stale audio.
  updatePlaybackContext was the wrong tool for the last of those -- it
  only replaces context, and on a content or session mismatch it resets.
  MobileSubtitleTransactionAdapter.commitLocallyAppliedAudio moves the
  reducer's committed audio directly, and queues while a commit is in
  flight so it cannot race that transaction.

- A failed local switch needs a way out. AudioTrackManager returns Unit
  and does nothing silently when the group has gone, and a no-op produces
  no callback, so an unbounded local path could leave the request
  unapplied forever. After MAX_LOCAL_AUDIO_ATTEMPTS snapshots that have
  not taken, it falls back to the server replan.

- Launch, persisted restore and offline playback all have to seed the
  intent. Restore previously ran only when the persisted ordinal differed
  from the server's, but equality with the plan is not evidence the
  RENDERER is on that track -- which is the whole case this exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
…selection

# Conflicts:
#	androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4565a93f-c5d6-45a1-aab7-50fd14566653

📥 Commits

Reviewing files that changed from the base of the PR and between 24dc139 and d219d82.

📒 Files selected for processing (28)
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/backend/VideoPlaybackBackend.kt
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/AudioReconcile.kt
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/MountedAudioMatching.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/AudioReconcileTest.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MountedAudioMatchingTest.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/MountedAudioReorderTest.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelection.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerScreen.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileAudioTrackSelectionTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAudiobookRouting.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvAudiobookDetailHero.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailScreen.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvItemDetailViewModel.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormatting.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerHud.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvVideoPlaybackStarter.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/navigation/TvPlayerRouteTest.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/TvPlaybackFormattingTest.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/PlayerTrackEntriesTest.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt

📝 Walkthrough

Walkthrough

The PR standardizes audio selection on catalog ordinals. It adds mounted-track identity matching and reconciliation, integrates local switching and persistence on mobile and TV, and carries explicit audio-selection provenance through TV navigation and playback UI.

Changes

Audio selection and playback

Layer / File(s) Summary
Mounted audio matching and reconciliation
android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/video/*, android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/video/*
Adds mounted audio identities, codec and language normalization, catalog-to-mounted matching, reconciliation actions, and tests for ambiguity, remounts, incomplete snapshots, and confirmation.
Mobile ordinal selection and local commits
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/*, androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/*
Uses positional audio ordinals for selection and persistence. Applies local changes through the backend and drains queued changes across transaction outcomes.
TV selection provenance and catalog formatting
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/*, androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/detail/*, androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/navigation/*, androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/detail/*
Propagates explicit audio-selection state through detail callbacks and player routes. Adds catalog-ordinal summaries, labels, fallback resolution, and related tests.
TV player reconciliation and HUD integration
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/*, androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/player/*
Reconciles launch, persisted, remote, and HUD audio intents with mounted tracks. Applies local selections, retries before server fallback, persists confirmed choices, and displays catalog-based pending state.
Formatting cleanup
android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/backend/VideoPlaybackBackend.kt
Adds a blank line before the mount method.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: quick104

Sequence Diagram(s)

sequenceDiagram
  participant DetailScreen
  participant TvRoute
  participant TvPlayerScreen
  participant TvPlayerViewModel
  participant TvPlayerHud
  participant VideoPlaybackBackend
  DetailScreen->>TvRoute: pass audioPickedThisSession
  TvRoute->>TvPlayerScreen: parse and forward audio provenance
  TvPlayerScreen->>TvPlayerViewModel: create launch state
  TvPlayerHud->>TvPlayerViewModel: submit catalog audio ordinal
  TvPlayerViewModel->>VideoPlaybackBackend: apply mounted audio ordinal
  VideoPlaybackBackend-->>TvPlayerViewModel: publish selected track
  TvPlayerViewModel-->>TvPlayerHud: update desired ordinal and confirmation
Loading
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@RXWatcher

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Pull request is closed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@RXWatcher
RXWatcher merged commit d321514 into Silo-Server:main Aug 6, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant