fix(android): draw the subtitles the player already decodes
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s

Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer
decodes subtitles and delivers them to a listener; it draws none itself. A
PlayerView would supply the view that does, but native video here is a bare
TextureView the WebView composites over — so nothing held the cues and every
one was decoded, delivered and dropped. There was no onCues, no TextOutput and
no SubtitleView anywhere in the app, and media3-ui was not even a dependency.

The gap was invisible for as long as every subtitle URL 404ed: with no text
track to select there was never a cue to lose, so fixing the URL is what
exposed it.

media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of
the content view — above the video, still below the WebView, so cues sit over
the picture and under the app's own controls. It is fitted to the letterboxed
video rect rather than the screen, so cues stay inside the picture and follow
it on rotation, and is removed by the same teardown that detaches the surface
(the defect DR-184 exists to prevent).

Verified on a device: track selected with no "Invalid subtitle track index",
SubtitleView attached at the fitted rect per the live view hierarchy, and cues
legible on screen during playback.

TRACES: UR-020, UR-003 | DR-260
This commit is contained in:
2026-08-23 20:20:01 +02:00
parent a94632461b
commit 0187ee179e
5 changed files with 115 additions and 0 deletions
+1
View File
@@ -453,6 +453,7 @@ Internal architecture, components, and application logic.
| DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done |
| DR-258 | An audio-track change is honoured by **re-opening the stream** when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — but the native path only ever called `setAudioTrack(n)`, which indexes ExoPlayer's audio track *groups*. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: ExoPlayer held one audio track while the menu listed every track in the file, so every selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. `determine_audio_track_switch_strategy` now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer, not the UI's: the native path has no `<video>` element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning | Player | UR-021, UR-005 | Done |
| DR-259 | Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. Since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a *mock helper* duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release | Repository | UR-020 | Done |
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---