Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e98e1c37a | ||
|
|
440d7a01a9 | ||
|
|
dccb5f53dd | ||
|
|
c142568230 | ||
|
|
95129d04a3 | ||
|
|
f0f98feae8 | ||
|
|
d9e1e256e9 | ||
|
|
42868fc2e6 | ||
|
|
c0c6c5023e | ||
|
|
521acc75fd | ||
|
|
886cbcb29a | ||
|
|
2cc39cd7fd | ||
|
|
e457a9884c | ||
|
|
de1c13e72f | ||
|
|
5096c01960 | ||
|
|
2d67b0e4f5 | ||
|
|
13264e225b | ||
|
|
041969f446 | ||
|
|
1a9805f0f3 | ||
|
|
82b6982d68 | ||
|
|
3363ff7f08 | ||
|
|
9858b7cb92 | ||
|
|
f46d7bf676 | ||
|
|
74bffea650 | ||
|
|
7e1f0e0547 | ||
|
|
e015c4c9b1 | ||
|
|
0861523015 | ||
|
|
1e599627b5 | ||
|
|
fa7cb6e908 |
+810
-7
@@ -6,6 +6,354 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
|
[docs/defect-windows.md](docs/defect-windows.md).
|
||||||
|
|
||||||
|
## v0.6.0
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Android native video actually shows a picture.** It shipped once as *audio
|
||||||
|
with no picture* and was reverted with the compositing named as the suspect
|
||||||
|
(DR-172). The compositing was not at fault; five independent defects sat
|
||||||
|
between ExoPlayer and the screen, each able to produce that symptom alone. The
|
||||||
|
app shell painted over the video surface through a CSS rule targeting
|
||||||
|
`[data-app-shell]`, an attribute no component had ever set in any commit
|
||||||
|
(DR-185). The poster/title card had no way to lift on a path that renders no
|
||||||
|
`<video>` element, so a black card covered the surface for the whole session
|
||||||
|
(DR-182). The JavaScript bridges were installed by a 500 ms tree walk that
|
||||||
|
raced the page load — and lost permanently when it lost, because the
|
||||||
|
re-injection guard then declined to retry — so `setTransparent(true)` could
|
||||||
|
never arrive (DR-183). The `SurfaceView` was never detached, leaking one per
|
||||||
|
video and leaving picture-in-picture's gate stuck open (DR-184). Verified on a
|
||||||
|
device: logcat now carries `WebView transparent = true` and
|
||||||
|
`Marking media ready` with video on screen, the pair the original
|
||||||
|
investigation went looking for and could not find.
|
||||||
|
(UR-003, UR-004, UR-041 → DR-182, DR-183, DR-184, DR-185)
|
||||||
|
|
||||||
|
- **Play and pause reach the player that is actually rendering.** Transport did
|
||||||
|
nothing on the native video path — from the on-screen tap, from the control
|
||||||
|
bar, and from a direct command invocation — while seek and skip kept working,
|
||||||
|
because those decide elsewhere. Rust routes play/pause to the webview `<video>`
|
||||||
|
whenever it believes one is active, and the player route mirrored element state
|
||||||
|
into that belief unconditionally, including from a ten-second progress
|
||||||
|
interval. So on the native path the frontend re-declared every ten seconds that
|
||||||
|
an element was playing when none existed, and every intent was emitted at
|
||||||
|
something that was not there. The mirror now lives where `useHtml5Element` is
|
||||||
|
known. This also explains the flashing transport controls, since they key off
|
||||||
|
the play state that was being contradicted on every tick.
|
||||||
|
(UR-005, UR-003 → DR-193, DR-195)
|
||||||
|
|
||||||
|
- **The player's controls hide themselves on a touchscreen.** The auto-hide timer
|
||||||
|
was armed only from `mousemove`, which a touch device never fires, so the
|
||||||
|
control bar stayed over the video for the whole film. It is now armed on entry
|
||||||
|
and on every touch, and pinned open while paused, seeking, or with a menu open.
|
||||||
|
(UR-003, UR-066 → DR-189)
|
||||||
|
|
||||||
|
- **The system bars go away with the player.** Immersive mode had exactly one
|
||||||
|
caller — the fullscreen button — so opening a video left the status and
|
||||||
|
navigation bars painted over it until the user pressed a control most never
|
||||||
|
press. (UR-066, UR-003 → DR-187)
|
||||||
|
|
||||||
|
### 🔬 Internal
|
||||||
|
|
||||||
|
- Native video presents through a `TextureView` rather than a `SurfaceView`. A
|
||||||
|
SurfaceView renders on its own layer outside the app window and punches a
|
||||||
|
transparent region through it, and Android's own graphics documentation warns
|
||||||
|
that overlays do not composite reliably above one. (UR-003, UR-004 → DR-192)
|
||||||
|
|
||||||
|
- Native Android video remains **opt-in**, and is not yet the default. Turning it
|
||||||
|
on surfaced a further unverified path: returning from background audio is
|
||||||
|
implemented only for the webview element, so playback stays dead on the native
|
||||||
|
path (DR-190, proposed). Rotation still needs device confirmation (DR-194).
|
||||||
|
(UR-003 → DR-188)
|
||||||
|
|
||||||
|
## v0.5.5
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Library artwork is laid out as a mosaic instead of cropped to one box.** The
|
||||||
|
library overview and the home shortcut strip showed three different artwork
|
||||||
|
shapes — square music covers, 16:9 backdrops, 2:3 posters — in grids that pick
|
||||||
|
one box and crop everything to it; the home strip lined its row up by cutting
|
||||||
|
the music covers down. Both surfaces now justify rows to a shared height with
|
||||||
|
each tile as wide as its own artwork, packing from the *decoded* aspect ratio
|
||||||
|
and committing one debounced batch so the grid does not reshuffle as artwork
|
||||||
|
lands. The last row is deliberately left unstretched, so one leftover tile does
|
||||||
|
not inflate into a banner. Favourites also gain a tile per category beside the
|
||||||
|
library it belongs to — which collection type maps to which category is
|
||||||
|
Jellyfin vocabulary, so it is derived in Rust rather than rebuilding the exact
|
||||||
|
leak `SearchScope::item_types` was extracted to close.
|
||||||
|
(UR-075, UR-067 → DR-163, DR-164)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The server no longer burns subtitles into the picture.** Reported as
|
||||||
|
"subtitles are shown even when off", with no toggle in the app clearing them —
|
||||||
|
because they were never the app's subtitles. `PlaybackInfo` omitted
|
||||||
|
`SubtitleStreamIndex`, which does not mean "none": the server then honours the
|
||||||
|
source's own default flag, and on the reported episode that default was a PGS
|
||||||
|
bitmap track, which cannot go out as a sidecar. So it composited the track onto
|
||||||
|
every frame. The cost landed on the *video*: burn-in rules out remuxing, so an
|
||||||
|
HEVC stream that needed only its audio transcoded was re-encoded frame by
|
||||||
|
frame, which the server could not sustain — playback stalled every few seconds
|
||||||
|
and seeks took five to nine seconds to draw a frame. The negotiation and the
|
||||||
|
stream URL now both ask for `-1` and advertise every text format the app can
|
||||||
|
render as `External`, and the picker offers only subtitles the app can actually
|
||||||
|
draw, with the codec verdict decided in Rust and carried across the boundary.
|
||||||
|
Nothing is lost: the app already fetches text tracks and draws them itself.
|
||||||
|
(UR-020, UR-004 → DR-176)
|
||||||
|
|
||||||
|
- **Switching bitrate mid-film no longer stalls playback.** Jellyfin keys a
|
||||||
|
transcode job by device and play session, but every stream URL carried the same
|
||||||
|
hardcoded `DeviceId` and no `PlaySessionId` at all — so a second stream for an
|
||||||
|
item was indistinguishable from the first and nothing ever stopped the old
|
||||||
|
ffmpeg. The server served the new playlist and then answered 400 for its
|
||||||
|
segments. Re-opening a stream is not rare: a quality switch, a transcoded seek
|
||||||
|
and an audio-track switch all do it. Each open now mints a session id and stops
|
||||||
|
the job it supersedes, in the URL builder so every re-open path is covered by
|
||||||
|
construction. Two client faults that made the same incident worse go with it:
|
||||||
|
the fatal-HLS-error handler double-counted the transcode seek offset, so past
|
||||||
|
roughly halfway through a film any transient network error read as
|
||||||
|
end-of-stream and autoplay skipped to the next item; and the HTML5 reload
|
||||||
|
primitive resolved on its own timeout, reporting success for a reload the
|
||||||
|
server never served. (UR-074, UR-004 → DR-177)
|
||||||
|
|
||||||
|
- **Downloading an album gets the whole album.** `download_album` read its track
|
||||||
|
list from the local catalog cache, but Jellyfin does not return `AlbumId` on
|
||||||
|
every listing endpoint, so tracks cached from one of those were invisible to
|
||||||
|
the query — three albums in the reported database had it NULL on every track.
|
||||||
|
The frontend then resolved stream URLs from its *own* list and paired them with
|
||||||
|
the returned rows by position, so a row could be handed another track's URL and
|
||||||
|
anything past the end of the shorter list never started. The same missing link
|
||||||
|
hid downloaded tracks under their album offline. The operation now belongs to
|
||||||
|
Rust end to end — the server is asked what the album contains, the album link
|
||||||
|
is written onto every track queued, URLs resolve in the backend scoped to the
|
||||||
|
rows just queued, and each track gets its own file so a title repeated across
|
||||||
|
two discs stops overwriting itself. Re-tapping download on a broken album heals
|
||||||
|
it. (DR-173)
|
||||||
|
|
||||||
|
- **Playback positions reported to Jellyfin are real ones.** Returning to the
|
||||||
|
foreground before the background-audio stream had started playing handed the
|
||||||
|
frontend 0.0s, so the episode restarted from the beginning and the stop report
|
||||||
|
wrote that zero to the server as the resume point. The same blind spot covered
|
||||||
|
webview-rendered media, whose native position is a permanent 0 — 14 of 14 stop
|
||||||
|
reports in a 35-minute trace were zeroes, one landing 40s after the frontend
|
||||||
|
had correctly reported 15:22 for the same episode. Position is now the maximum
|
||||||
|
of the backend's reading, the last position webview media reported and the
|
||||||
|
handoff base (at most one is ever meaningful); zero-position stop reports are
|
||||||
|
withheld, since a zero is never information and only ever destroys a real
|
||||||
|
resume point; and progress is reported from the controller's own ticks —
|
||||||
|
`/Sessions/Playing/Progress` had previously been requested zero times in those
|
||||||
|
35 minutes. A finished audio-only episode is also reported stopped at its
|
||||||
|
runtime so Jellyfin's 90% rule marks it played, which nothing else could do
|
||||||
|
once the webview was suspended. (UR-005, UR-025, UR-040, UR-071 → DR-178,
|
||||||
|
DR-179, DR-180)
|
||||||
|
|
||||||
|
- **The streaming quality button uses a speedometer icon**, not the
|
||||||
|
cloud-download glyph that read as a download action.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two commits in this range (fa7cb6e9, 1e599627) are a June 2026 duplicate of the
|
||||||
|
v0.0.2 autoplay fix — same parent, same diff, a second commit object created by
|
||||||
|
the Gitea PR merge. A merge chain dragged them into master's history here; they
|
||||||
|
changed no file in this release. Deliberately not listed.
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
## v0.5.4
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Native Android video is opt-in again — enabling it by default shipped sound
|
||||||
|
with a blank screen.** The decode path was never at fault: ExoPlayer ran and
|
||||||
|
fed a live SurfaceView the whole time, behind an opaque page. The step that
|
||||||
|
clears the layers above it never took effect — the WebView was logged going
|
||||||
|
transparent `= false` and never `= true`. This is precisely what the flag
|
||||||
|
existed to contain, and v0.5.3 had turned it on so picture-in-picture would
|
||||||
|
have a real surface to shrink. Reverting costs nothing that matters: PiP drives
|
||||||
|
from the WebView `<video>` (DR-160) and working video outranks PiP showing a
|
||||||
|
native surface. The flag stays in Settings, described as incomplete rather than
|
||||||
|
as a performance win. Fixing the compositing is the prerequisite for trying the
|
||||||
|
default again. (DR-172)
|
||||||
|
|
||||||
|
## v0.5.3
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Streaming bandwidth can be capped at a chosen bitrate ceiling.** Video
|
||||||
|
streams opened at a fixed allowance nobody could change — 20 Mbps on the HLS
|
||||||
|
URL and in the negotiation, and a device profile that let the server
|
||||||
|
direct-play a source of any size — so on a metered or slow connection there was
|
||||||
|
no way to spend less. `StreamingQuality` is a ladder (Original, 20/10/8/4/2/1
|
||||||
|
Mbps, 720 kbps) where each step bundles the total ceiling, the audio share of
|
||||||
|
it and the resolution that budget can carry; those are Jellyfin encoding
|
||||||
|
vocabulary, so they live in Rust and the frontend only names a variant. The cap
|
||||||
|
reaches the *negotiation*, not just the transcode URL — `max_static_bitrate` is
|
||||||
|
what makes the server refuse to direct-play a file fatter than the cap, and
|
||||||
|
without it a 30 Mbps remux is handed over untouched and every downstream
|
||||||
|
parameter is moot. Settings holds the durable default (persisted, unlike the
|
||||||
|
rest of VideoSettings — a limit set for a metered connection must not silently
|
||||||
|
revert on the next launch); the in-player menu is the "this film, this
|
||||||
|
connection" override, which re-opens the stream and resumes at the current
|
||||||
|
position. (UR-074 → DR-162)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Four separate defects behind "downloads are still flaky".** Libraries mixed
|
||||||
|
their media — cached items carry no link back to their library, so the library
|
||||||
|
branch matched a clause asserting only that the *library* exists, listing films
|
||||||
|
under Music and albums under TV; the query deciding which libraries appear
|
||||||
|
already had the right rule, and the two now share one constant (DR-167). Pause
|
||||||
|
and resume did nothing: `pause_download` wrote a status and stopped there with
|
||||||
|
no cancellation anywhere in the stack, so the streaming task ran on and
|
||||||
|
overwrote the row, and `resume_download` flipped a row to pending without
|
||||||
|
pumping a queue that is not a poller. A per-download stop flag now really stops
|
||||||
|
the worker, keeping the `.part` file that resume continues from (DR-168).
|
||||||
|
Partial files were never reaped, because the writer named its sidecar with
|
||||||
|
`with_extension("part")` — `movie.mp4` became `movie.part` — while every
|
||||||
|
cleanup path deleted `movie.mp4.part` (DR-169). And bitrate downloads corrupted
|
||||||
|
themselves: a transcode is served chunked and cannot byte-seek, so the server
|
||||||
|
ignored `Range` and answered 200 with the whole stream while the worker
|
||||||
|
appended it anyway, concatenating a full copy per retry. The response now
|
||||||
|
decides — append only on 206, otherwise truncate and start over (DR-170).
|
||||||
|
|
||||||
|
- **A downloaded video keeps audio the device can actually decode.** `original`
|
||||||
|
quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD track came down
|
||||||
|
untouched and the webview had nothing to play it with. The download URL is also
|
||||||
|
built against the media source actually chosen rather than the item's default.
|
||||||
|
(DR-171)
|
||||||
|
|
||||||
|
- **A batch of reported UI and playback bugs.** Pages no longer inherit the
|
||||||
|
previous page's scroll position — the shell keeps its scrollers alive across
|
||||||
|
navigation by design, so the element never remounts and its `scrollTop` survived
|
||||||
|
the route change, while SvelteKit restores a window scroll this app never uses;
|
||||||
|
offsets are now recorded per route and per container, reset going forward and
|
||||||
|
restored on Back (UR-072 → DR-156). Full-screen video on Android hides the
|
||||||
|
system bars: `requestFullscreen()` cannot touch the Activity window from inside
|
||||||
|
a WebView, so the control did nothing visible while the bars stayed painted over
|
||||||
|
the video (UR-066 → DR-157). A watched toggle appears on the episode row, season
|
||||||
|
header, series and movie hero and the Episode Focus View — both backend halves
|
||||||
|
already existed with no caller (UR-073 → DR-158). The background-audio handoff
|
||||||
|
stops leaking its relative timeline: the correction was applied in two
|
||||||
|
display-only places while progress reports, the frontend and media3's own seeks
|
||||||
|
all treated the relative timeline as absolute, each crossing losing exactly the
|
||||||
|
base (DR-159). And picture-in-picture works on the path that actually plays
|
||||||
|
video — it had demanded a native ExoPlayer surface, which sat behind a flag
|
||||||
|
defaulting to off, and now accepts the WebView `<video>` (DR-160).
|
||||||
|
|
||||||
|
- **0.5.x can install over v0.5.2 on Android.** v0.5.2 shipped `versionCode` 5002
|
||||||
|
under an earlier `minor*1000` scheme; the `minor*100` formula that replaced it
|
||||||
|
yields 1502 for that same version and 1503 for 0.5.3 — lower than what is
|
||||||
|
already installed, so Android refuses the update as a downgrade, and every
|
||||||
|
0.5.x release built from that script was un-installable for anyone on v0.5.2.
|
||||||
|
This is the exact failure the guard was written to prevent; its floor had gone
|
||||||
|
stale, because the highest code the formula *produces* is not the same as the
|
||||||
|
highest code in the field. The scheme widens to
|
||||||
|
`10000 + major*1000000 + minor*1000 + patch`, and the guard test now pins
|
||||||
|
"clears what shipped" rather than a literal that can go stale again.
|
||||||
|
|
||||||
|
### 📋 Documentation
|
||||||
|
|
||||||
|
- The traceability matrix is regenerated (87% coverage, 265/303, no orphaned
|
||||||
|
IDs), and three comments still describing native video as defaulting to off are
|
||||||
|
corrected — one of them load-bearing, sitting directly above a `load()` that
|
||||||
|
returned true.
|
||||||
|
|
||||||
|
## v0.5.2
|
||||||
|
|
||||||
|
### 🔧 Internal
|
||||||
|
|
||||||
|
- **Gradle ships in the builder image instead of being downloaded per build.**
|
||||||
|
The release APK job died at the Gradle wrapper step after the 11-minute Rust
|
||||||
|
compile had already succeeded, on a socket exception mid-transfer.
|
||||||
|
`tauri android init` regenerates a wrapper pointing at services.gradle.org, so
|
||||||
|
every Android job re-downloaded ~130MB — slow on a good day, a hard build
|
||||||
|
failure when the CDN drops the connection, and a standing violation of the rule
|
||||||
|
that every build tool already lives in the image. The sync script now repoints
|
||||||
|
the regenerated wrapper at the local distribution, parsing the version the
|
||||||
|
wrapper actually requests so a future Tauri bump logs a miss instead of pointing
|
||||||
|
at a missing file. Dev machines are untouched.
|
||||||
|
|
||||||
|
## v0.5.1
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Resume position crosses devices.** The resume check reads the local
|
||||||
|
`user_data` row and nothing else, but the only path by which server `UserData`
|
||||||
|
lands in that table mirrored `is_favorite` alone and returned early whenever
|
||||||
|
that field was absent — exactly the shape of an ordinary watched episode. The
|
||||||
|
position was write-only from this device's perspective: watch 40 minutes in a
|
||||||
|
browser, open JellyTau, and it resumed from whatever this device last saw, or
|
||||||
|
offered no resume at all. The mirror now carries the position under the same
|
||||||
|
conflict rule, so a local position still waiting to be pushed is never pulled
|
||||||
|
backwards. Mirroring alone was not enough: `get_item` — the call the player
|
||||||
|
route makes — returned the cached copy on a hit and never consulted the server,
|
||||||
|
so for an already-cached item the mirror never ran. It now refreshes in the
|
||||||
|
background on a cache hit, which is why browsing a season picked up other
|
||||||
|
devices' state while opening the episode directly did not. (DR-155)
|
||||||
|
|
||||||
|
- **A watch position the server could not be told about is queued rather than
|
||||||
|
lost.** The sync queue and its drain were built, tested and running, but the
|
||||||
|
stop-report path never fed them — the hybrid repository passed reporting
|
||||||
|
straight through to the online repository, and on failure the error surfaced to
|
||||||
|
a frontend `catch` whose own comment read "could queue, but for now just log".
|
||||||
|
Closing a video while the server was unreachable lost the resume point outright.
|
||||||
|
The pending row for an item is superseded in place rather than appended to,
|
||||||
|
since progress reports every 10s would otherwise add a row per tick — the
|
||||||
|
unbounded queue the drain exists to prevent. Queueing is best-effort and never
|
||||||
|
fails the command: the local position is already saved. (DR-154)
|
||||||
|
|
||||||
|
- **Android's native video path resumes at the saved position.** Two layers each
|
||||||
|
assumed the other did the seek: the only code acting on `initialPosition` was an
|
||||||
|
HTML5 `<video>` event handler, and `canplay` never fires where there is no
|
||||||
|
`<video>` element; the native adapter's own branch merely recorded the number,
|
||||||
|
claiming the backend seeks internally, which it does not; and the player never
|
||||||
|
called that branch at all. The frontend therefore believed it had resumed — the
|
||||||
|
seek bar showed the resume point — while ExoPlayer played from the beginning.
|
||||||
|
Live streams are excluded, since seeking knocks the HLS window off its live edge.
|
||||||
|
|
||||||
|
- **Downloading at a chosen quality honours it.** The download URL builder spelled
|
||||||
|
the transcode parameters `videoBitrate`/`audioBitrate`, but Jellyfin binds
|
||||||
|
`videoBitRate`/`audioBitRate` — with a capital R. Query-key binding is
|
||||||
|
case-insensitive, so this is not a casing preference: the lowercase-r form is a
|
||||||
|
different token that fails to bind, and the server discards it without error and
|
||||||
|
stream-copies the source. Picking "480p" produced an original-quality file with
|
||||||
|
no failure surfaced anywhere, while `maxHeight` and `videoCodec` were unaffected
|
||||||
|
— which is why the height cap applied and the bitrate cap vanished. The presets
|
||||||
|
also set `allowVideoStreamCopy=false` to force a real re-encode. The
|
||||||
|
pre-existing unit tests asserted the broken spellings, so they passed against
|
||||||
|
broken code. (UR-071 → DR-123)
|
||||||
|
|
||||||
|
- **A series resumes after the furthest-watched episode, not at the first gap.** A
|
||||||
|
viewer who skipped the pilot but is three seasons deep was sent back to S1E1 —
|
||||||
|
the gap was a deliberate skip, not where they stopped. It read as flaky rather
|
||||||
|
than consistently wrong because that rung only fires when the server's Next Up
|
||||||
|
yields nothing, and its errors are swallowed, so any unreachable-server moment
|
||||||
|
silently degraded to an empty list: same series, same watch state, different
|
||||||
|
answer depending on one request's outcome. Season crossing comes free from the
|
||||||
|
already-flat series ordering, and specials stay last so a watched special cannot
|
||||||
|
mark a show finished.
|
||||||
|
|
||||||
|
- **Volume control returns to the local speaker when a remote session stops.**
|
||||||
|
`player_stop`'s remote branch sent Stop to the session and returned without
|
||||||
|
touching the playback mode, so the manager stayed in Remote; and volume routing
|
||||||
|
was torn down at a single call site, so every *other* exit from remote mode
|
||||||
|
leaked the Android volume provider. Routing is now derived from the transition
|
||||||
|
itself, covering the frontend disconnect and local-playback-start paths too.
|
||||||
|
|
||||||
|
- **A new album appears once in Recently Added, not once per track.** Importing a
|
||||||
|
14-track album filled the whole row with that one album. Both code paths had the
|
||||||
|
same symptom from separate causes: online, Jellyfin's `/Items/Latest` defaults
|
||||||
|
to `GroupItems=false`; offline, the downloaded-items CTE deliberately matches
|
||||||
|
leaves *and* their container, which is right for browsing and wrong here. Items
|
||||||
|
with no container are unaffected either way.
|
||||||
|
|
||||||
|
- **Uniform card heights in the home Your Libraries row.** Artwork aspect ratio is
|
||||||
|
derived from the item, so a music library rendered square (144px) next to video
|
||||||
|
libraries at 16:9 (81px), leaving the row ragged. The per-type ratios elsewhere
|
||||||
|
are unchanged.
|
||||||
|
|
||||||
## v0.5.0
|
## v0.5.0
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
@@ -88,6 +436,74 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
so music is not transcoded needlessly.
|
so music is not transcoded needlessly.
|
||||||
(UR-004 → DR-148)
|
(UR-004 → DR-148)
|
||||||
|
|
||||||
|
## v0.4.6
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Downloaded video plays offline.** Four separate defects each stopped it on
|
||||||
|
their own. A completed download's `file_path` is already absolute — the worker
|
||||||
|
rewrites it on completion — but the player rooted it a second time and handed
|
||||||
|
the webview `/data/user/0/app//data/user/0/app/videos/x.mp4`; audio was
|
||||||
|
unaffected because it resolves the same column through Rust, which is why this
|
||||||
|
read as a video-only fault (DR-133). The asset protocol was never enabled at
|
||||||
|
all: `convertFileSrc` rewrites a path to `asset.localhost` unconditionally, but
|
||||||
|
Tauri only answers that origin when the cargo feature *and* the config are both
|
||||||
|
present, and neither was — which also silently defeated the cached-thumbnail
|
||||||
|
path, whose soft fallback to the server copy hid the breakage whenever the
|
||||||
|
server was reachable (DR-134). Tauri's asset protocol then answers a range-less
|
||||||
|
request by reading the whole file into memory and only advertises
|
||||||
|
`Accept-Ranges` from inside its range branch, so the first request never learns
|
||||||
|
ranges exist and Chromium gave up after ~31s; local media now comes from a
|
||||||
|
loopback HTTP server streaming bounded 4 MiB chunks, confined by a per-session
|
||||||
|
token and to the app data directory, because loopback is shared between apps on
|
||||||
|
Android (DR-137). And release builds set `usesCleartextTraffic=false`, so
|
||||||
|
Android rejected the request before any I/O — a network-security config now
|
||||||
|
exempts 127.0.0.1 only, and a remote server must still be HTTPS (DR-138).
|
||||||
|
|
||||||
|
Known limitation: a download taken at `original` quality is a byte copy, so it
|
||||||
|
can be any container — an AVI holding XVID is served correctly and refused by
|
||||||
|
the webview regardless.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **A video queued from a media card no longer downloads as audio.**
|
||||||
|
`download_item` never recorded `media_type`, and the reconnect resolver read
|
||||||
|
that NULL as `'audio'`, so a movie's URL was resolved by the audio builder and
|
||||||
|
completed as an audio-only transcode. The item's own type now decides, and rows
|
||||||
|
already downloaded that way are requeued on reconnect — prevention alone leaves
|
||||||
|
them reading "downloaded" and still unplayable. (DR-135, DR-136)
|
||||||
|
|
||||||
|
- **Some videos no longer play with no sound.** Jellyfin's `MediaStream.Index` is
|
||||||
|
global across every stream in a media source, so index 0 is the video stream on
|
||||||
|
virtually all files — and `AudioStreamIndex=0` was sent as "the first audio
|
||||||
|
track" on the HLS transcode URL, the background-audio handoff URL, the
|
||||||
|
direct-play fallback and the negotiation body, asking the server to use the
|
||||||
|
video stream as audio. Servers that honour it produce a picture with no sound;
|
||||||
|
only those that silently correct the index hid it, which is why it surfaced as
|
||||||
|
"*some* videos have no audio". The parameter is now omitted unless a track was
|
||||||
|
actually chosen. (DR-140)
|
||||||
|
|
||||||
|
- **A multichannel track is no longer direct-played to a two-channel sink.**
|
||||||
|
`MediaCodecList` answers "can this device decode 5.1", which is not the question
|
||||||
|
that decides whether anything is audible: a phone decodes AC-3 5.1 happily and
|
||||||
|
still has two channels to play it out of. The profile carried no
|
||||||
|
`MaxAudioChannels`, so the server was free to hand over the multichannel track —
|
||||||
|
silence, or dialogue folded into surround channels that go nowhere. The route's
|
||||||
|
actual channel count now bounds the profile; no codec is ever removed, so a
|
||||||
|
device with genuine surround output keeps direct-playing it. (DR-141)
|
||||||
|
|
||||||
|
- **Video waits for audio focus instead of rolling silently.** Video manages focus
|
||||||
|
by hand, and all three outcomes of the request were treated as success —
|
||||||
|
including `REQUEST_DELAYED`, which means the system is withholding our audio
|
||||||
|
until it calls back. The picture rolled with no sound, indistinguishable from a
|
||||||
|
broken stream. (DR-145)
|
||||||
|
|
||||||
|
- **The no-audio fallback picks a track the device can decode.** When ExoPlayer
|
||||||
|
selected no audio track, recovery forced group 0 / track 0 unconditionally — but
|
||||||
|
the most likely reason nothing was selected is that this very track cannot be
|
||||||
|
decoded here, so the override reinstated the silence it was meant to fix.
|
||||||
|
(DR-146)
|
||||||
|
|
||||||
## v0.4.1
|
## v0.4.1
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
@@ -168,6 +584,182 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
|
|
||||||
- **Android system bars and display cutout are handled correctly.** (UR-066)
|
- **Android system bars and display cutout are handled correctly.** (UR-066)
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Opening a series lands on the current episode, not season 1.** The viewer was
|
||||||
|
dumped at the top of season 1, and the Play button played nothing at all: it
|
||||||
|
resolved the first *season* by SortName and navigated to `/player/<seasonId>`,
|
||||||
|
which the player route bounced straight back to the library. The backend could
|
||||||
|
already answer "where is this viewer in this show" — `get_next_up_episodes` had
|
||||||
|
accepted a `series_id` since it was written and no caller had ever passed one.
|
||||||
|
`pick_current_episode` now resolves in progress → Next Up → first unwatched →
|
||||||
|
the premiere, with the third rung serving offline where Next Up is always empty,
|
||||||
|
and specials sorted after the numbered seasons. Seasons collapse to the current
|
||||||
|
one, the current episode is badged and scrolled into view, and the hero button
|
||||||
|
reads `Resume S2E4` / `Play S1E1`. Seasons stop being a destination — a season
|
||||||
|
URL redirects into the series — and the "More Episodes" strip spans the whole
|
||||||
|
series, so a finale offers the next premiere instead of dead-ending. Six video
|
||||||
|
routes collapse to two via `?view=` tabs. Clear-history is wired to Jellyfin's
|
||||||
|
recursive mark-unplayed, and refuses to run offline rather than diverging state
|
||||||
|
the next sync would undo. (UR-062, UR-063, UR-064 → DR-101, DR-102, DR-103,
|
||||||
|
DR-104, DR-105, DR-106, DR-107)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Re-entering a video no longer opens the audio player.** Closing a
|
||||||
|
webview-rendered video deliberately emits no "stopped" state — that would break
|
||||||
|
the autoplay handoff — and the direct-play path does not stop the backend on
|
||||||
|
unmount, so the controller still reported that item as its loaded media.
|
||||||
|
Re-entering took the "already playing, just show the UI" shortcut, which returns
|
||||||
|
before a stream URL is fetched, and the render fell through to the audio
|
||||||
|
surface. Mostly visible on Android, where video direct-plays; Linux transcodes
|
||||||
|
and stops the backend on unmount. (DR-100)
|
||||||
|
|
||||||
|
## v0.2.9
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **A backgrounded audio-only episode advances instead of stalling.** It stopped
|
||||||
|
at the episode boundary and ExoPlayer parked in `STATE_ENDED`, where any later
|
||||||
|
play intent — lockscreen, headset, Bluetooth reconnect — replays the ended item,
|
||||||
|
surfacing as the episode randomly restarting. End-of-playback is dispatched from
|
||||||
|
two places and they disagreed: the Android JNI callback carried the
|
||||||
|
background-audio branch but can never reach it, because every load sets
|
||||||
|
`EndReason::NewTrackLoaded` and nothing clears it, so the first real end consumes
|
||||||
|
it and the decision is always Stop. The path that actually decides is the
|
||||||
|
frontend's echo, which had no background-audio case at all and started a
|
||||||
|
countdown whose advance is a `goto()` that cannot start audio while
|
||||||
|
backgrounded. Both dispatchers now share one `auto_advance_to_next_episode`.
|
||||||
|
(UR-040)
|
||||||
|
|
||||||
|
## v0.2.8
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The video seek bar works by touch.** Dragging or tapping the progress bar
|
||||||
|
moved the thumb while playback stayed where it was — two touch-only defects,
|
||||||
|
which is why the mouse-driven scrub tests never caught either. `handleTouchMove`
|
||||||
|
kept running for touches the tap guard had already excluded, measuring against
|
||||||
|
the *previous* gesture's start point, so a seek-bar drag produced a bogus
|
||||||
|
vertical delta: read as a brightness swipe, it dimmed the screen to the floor
|
||||||
|
and fired a spurious play/pause correction mid-drag. And the seek was committed
|
||||||
|
only from `change`, which Android's WebView does not reliably fire for a touch
|
||||||
|
interaction on a range input — so the thumb moved to the tapped position and no
|
||||||
|
seek ever ran. (DR-099)
|
||||||
|
|
||||||
|
## v0.2.7
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Video stopped pausing itself roughly once a second.** The frontend facade
|
||||||
|
short-circuited play/pause straight into the adapter, whose `toggle()` decided
|
||||||
|
play-vs-pause by reading `el.paused` off the DOM — so the Rust controller never
|
||||||
|
saw the intent and could not serialise competing ones. `el.paused` flips
|
||||||
|
transiently while an element buffers or settles a seek, so two intents ~150ms
|
||||||
|
apart read *different* values and performed *opposing* actions, a loop that
|
||||||
|
needed no further input to sustain itself. On device the element was fully
|
||||||
|
healthy at every pause (`readyState=4`, not seeking, not buffering, not ended),
|
||||||
|
which is what ruled out a stall. The root cause was that Rust held no state at
|
||||||
|
all for webview-rendered media, despite the comment above `report_html5_state`
|
||||||
|
claiming the controller was the single source of truth. (DR-097)
|
||||||
|
|
||||||
|
- **Tap gestures act immediately, with no deferral timer.** Tapping the video
|
||||||
|
surface pause-looped — it unpaused and bounced back about a second later, while
|
||||||
|
long-press unpaused fine, which pinned it to the tap path rather than the media
|
||||||
|
pipeline. The handler deferred the first tap behind a 300ms double-tap window,
|
||||||
|
but the timer callback cleared its own handle *before* invoking the toggle, and
|
||||||
|
the click-suppression guard keyed on exactly that handle — so the guard was
|
||||||
|
already open when Android's synthesized compatibility click arrived. There are
|
||||||
|
only first and second taps: the first toggles, the second seeks and toggles
|
||||||
|
back, so a double tap seeks while leaving the play state exactly as it was. A
|
||||||
|
swipe now undoes the touchstart toggle, keeping brightness swipes from changing
|
||||||
|
the play state. (UR-061 → DR-092, DR-098)
|
||||||
|
|
||||||
|
- **Three follow-on tap defects, each a second click target over the video.**
|
||||||
|
Pausing renders a full-screen play-overlay button, and Android's synthesized
|
||||||
|
click arrives 30–130ms later — by which time that button exists, so the click
|
||||||
|
landed on the overlay, which called toggle with no guard and resumed
|
||||||
|
immediately. Unpausing was unaffected because it removes the overlay: an
|
||||||
|
asymmetry that pointed straight at it. Then the bottom play/pause button did
|
||||||
|
nothing, because the gesture listener on the outer container and the button's
|
||||||
|
own handler both fired and cancelled out. Then the control-surface guard added
|
||||||
|
to fix *that* killed double-tap-to-seek, since the second tap lands on the
|
||||||
|
overlay. The overlay is now marked as player surface — visually it *is* the
|
||||||
|
video — and gesture rules live in pure, unit-tested functions. The suite gained
|
||||||
|
a test that renders the real component and dispatches real touch events at
|
||||||
|
whatever element is genuinely on top: the pure unit tests all passed throughout
|
||||||
|
these four bugs, because each helper behaved exactly as specified and every
|
||||||
|
defect was in the composition. (DR-098)
|
||||||
|
|
||||||
|
- **An HLS stall no longer produces an AbortError storm.** Every interrupted play
|
||||||
|
attempt was reported as a player error, but while a stream stalls hls.js nudges
|
||||||
|
the element to recover, cancelling the pending `play()` promise — transient, yet
|
||||||
|
it hit the error handler roughly once a second for the whole stall and left the
|
||||||
|
UI stuck reporting paused. The in-flight attempt is now memoised so the UI and
|
||||||
|
recovery share one call. (DR-096)
|
||||||
|
|
||||||
|
- **Seeks are clamped inside the media** to stop an end-of-stream pause loop.
|
||||||
|
(DR-095)
|
||||||
|
|
||||||
|
### 🔧 Internal
|
||||||
|
|
||||||
|
- `--device`/`--abi` build only the architecture actually needed. An on-device
|
||||||
|
test build compiled all four ABIs, throwing three of the four Rust compiles
|
||||||
|
away, which dominated iteration time against a connected phone.
|
||||||
|
|
||||||
|
## v0.2.1
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The traceability gate was dead and reported 158% coverage.** It divided traced
|
||||||
|
counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, total 114) that had
|
||||||
|
fallen out of date as requirements grew to 211 — JA alone printed 800% — so the
|
||||||
|
50% threshold was mathematically unreachable and the job could not fail.
|
||||||
|
Coverage could have collapsed to 30% behind a green tick. Real coverage was 86%:
|
||||||
|
the number was fine, the gate was not. Both sides of the fraction are now
|
||||||
|
derived from requirements.md, IDs are deduplicated (every UR is listed twice), a
|
||||||
|
TRACES comment naming a deleted requirement is reported as orphaned rather than
|
||||||
|
inflating the ratio, and a reading above 100% is a hard error rather than the
|
||||||
|
condition that hid this. Verified empirically — forcing the threshold to 99%
|
||||||
|
fails, adding a requirement moves coverage 86%→85%. (DR-093)
|
||||||
|
|
||||||
|
- **The search scope→item-type taxonomy moves into Rust.** The spec that diagnosed
|
||||||
|
this leak became the justification for the boundary rule, the `check:boundary`
|
||||||
|
tripwire and the spec-review checklist — and the fix itself was never built, so
|
||||||
|
the rule's own founding violation was still shipping. `SearchScope` now owns the
|
||||||
|
expansion, resolved once before the cache and server paths diverge so online and
|
||||||
|
offline cannot filter differently. `All` expands to no filter rather than the
|
||||||
|
union of the other scopes, which would silently drop People, folders and any
|
||||||
|
type nobody enumerated. Verified by hashing every `src/` file, adding a type to
|
||||||
|
the Music scope in Rust, and re-hashing: zero frontend files change — a
|
||||||
|
criterion that failed before this commit. (UR-049 → DR-063)
|
||||||
|
|
||||||
|
- **`check:boundary` passed on the very leak it was written for.** The pattern was
|
||||||
|
anchored to `includeItemTypes:` at the query site, so assigning the same array to
|
||||||
|
a named const one indirection away was invisible — through every green CI run.
|
||||||
|
It now matches an item-type array literal anywhere in `src/`, catching a const, a
|
||||||
|
Record value and a function return alike, with the deliberate limits kept so
|
||||||
|
single-type presentation stays legal. The allowlist is capped, so the next
|
||||||
|
exception forces a conversation rather than a one-line append, and the header now
|
||||||
|
names what the check still cannot see. (DR-094)
|
||||||
|
|
||||||
|
### 🔧 Internal
|
||||||
|
|
||||||
|
- Three orphaned traceability scripts are removed. All shared one root cause — an
|
||||||
|
unscoped `grep -r src-tauri/` walking ~40GB of build artifacts — and two hung
|
||||||
|
indefinitely while the third reported "Total Requirements: 1" and then printed
|
||||||
|
"All requirements have implementations!" from an empty result set. They were
|
||||||
|
salvageable, but read an undocumented second tag convention parallel to
|
||||||
|
`TRACES:`, and repairing them would have re-established the second source of
|
||||||
|
truth that let "1 requirement" and "211 requirements" coexist unnoticed.
|
||||||
|
|
||||||
|
- Five remediation specs from a design-principles audit of CLAUDE.md and the
|
||||||
|
architecture docs against the actual code. The principles with a working
|
||||||
|
automated check all held up; the two that had drifted are exactly the two whose
|
||||||
|
checks were broken or too narrow.
|
||||||
|
|
||||||
## v0.2.0
|
## v0.2.0
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
@@ -220,10 +812,45 @@ were wrong.
|
|||||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||||
by a spec rather than asserted as an upstream blocker.
|
by a spec rather than asserted as an upstream blocker.
|
||||||
|
|
||||||
<!--
|
## v0.1.5
|
||||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
|
||||||
and docs/traceability.md.
|
_v0.1.3 and v0.1.4 were never tagged; their work is included here._
|
||||||
-->
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **A single tap is deferred so a double tap does not also toggle pause.** A tap
|
||||||
|
cannot be classified when it lands — it may still turn out to be the first half
|
||||||
|
of a double tap — so play/pause waits for the 300ms window to close and is
|
||||||
|
cancelled if a second tap arrives. Forward skip moves from 10s to 30s; back
|
||||||
|
stays 10s. (Superseded in v0.2.7, where the deferral turned out to race the
|
||||||
|
WebView's synthesized click.) (UR-005, UR-061 → DR-092)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Locking the screen no longer kills audio during video playback**, even with
|
||||||
|
the background-audio toggle armed. `configureWebViewForMedia()` ran from both
|
||||||
|
the delayed post in `onCreate` and every `onResume`, re-registering the JS
|
||||||
|
bridges each pass — five times in a 45s session. A WebView binds injected
|
||||||
|
objects at page-load time, so re-injecting over a live page leaves JS holding a
|
||||||
|
stale proxy: still truthy, and every method gone. The toggle turned blue and
|
||||||
|
never reached native, so the handoff never ran. Bridges are now registered
|
||||||
|
exactly once per WebView, and `setBackgroundAudioEnabled` reports whether native
|
||||||
|
was actually reached, so a dead bridge can never again masquerade as an armed
|
||||||
|
toggle. Removing the re-injection then revived a latent conflict it had been
|
||||||
|
masking — three audio-focus requesters inside one uid, with the grant followed
|
||||||
|
~45ms later by a loss whose handler paused playback. The WebView already manages
|
||||||
|
focus for `<video>`, so the redundant bridge is dropped entirely, consistent with
|
||||||
|
the player-is-authoritative principle. WebView console output is now forwarded to
|
||||||
|
logcat, which is what made this diagnosable at all. (UR-040 → IR-025, DR-051)
|
||||||
|
|
||||||
|
- **An expired sleep timer stops without triggering autoplay.** Stopping the
|
||||||
|
backend makes the native player fire its ended callback, and the timer thread
|
||||||
|
cancels the timer first — so by the time the callback inspects it the mode reads
|
||||||
|
Off, the sleep-timer branch is skipped, and the episode path ran, showing a
|
||||||
|
next-episode popup right after the user's sleep timer expired. The stop is now
|
||||||
|
recorded as user-initiated before it reaches the backend, which is the honest
|
||||||
|
label: via the timer they set rather than the stop button. (UR-023, UR-026 →
|
||||||
|
DR-029)
|
||||||
|
|
||||||
## v0.1.2
|
## v0.1.2
|
||||||
|
|
||||||
@@ -260,7 +887,183 @@ were wrong.
|
|||||||
**Linux:** 64-bit, GLIBC 2.29+
|
**Linux:** 64-bit, GLIBC 2.29+
|
||||||
**Android:** 8.0+
|
**Android:** 8.0+
|
||||||
|
|
||||||
## v0.1.1 and earlier
|
## v0.1.1
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **"More Episodes" is populated for series without season folders.** The strip
|
||||||
|
collapsed to just the current episode on some series, for two reasons: a series
|
||||||
|
exposing episodes directly as children rather than under season folders yielded
|
||||||
|
an empty season fetch, and `isCurrentEpisode` over-matched, because episodes
|
||||||
|
with no season or episode number compared equal (`undefined === undefined`) and
|
||||||
|
every one of them looked like the focused episode. Flat children are now grouped
|
||||||
|
by season number under synthesized headers, and the strip's logic is extracted so
|
||||||
|
both behaviours are unit-tested. (UR-058 → DR-087)
|
||||||
|
|
||||||
|
- **Autoplay advances in background audio mode.** An episode handed off to the
|
||||||
|
audio-only path is a `MediaType::Audio` item, so autoplay's video-only checks
|
||||||
|
stopped recognising it as an episode and playback simply ended at the boundary.
|
||||||
|
Episode identity is now carried through the handoff, and because the frontend's
|
||||||
|
usual advance is a navigation that is unavailable while the WebView is
|
||||||
|
suspended, the backend performs it directly — fetching the next episode,
|
||||||
|
building its audio-only URL and loading it into the native player, preserving
|
||||||
|
identity so the following boundary advances too. (UR-040, UR-023 → DR-052)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Skipping an episode marks it watched rather than paused.** Skipping left a
|
||||||
|
mid-episode resume point behind, so the skipped episode reappeared in Continue
|
||||||
|
Watching with a partial progress bar — but skipping means "done with this one",
|
||||||
|
not "stopped here". A one-shot suppression keeps the player's post-navigation
|
||||||
|
unmount stop report from overwriting the 100% progress with the partial one, and
|
||||||
|
Continue Watching now drops resume entries superseded by Next Up. (UR-059 →
|
||||||
|
DR-088, DR-089)
|
||||||
|
|
||||||
|
### 📋 Documentation
|
||||||
|
|
||||||
|
- CLAUDE.md states the failing-test-first rule explicitly: write a test that
|
||||||
|
reproduces the bug and watch it fail before applying the fix, and extract buried
|
||||||
|
logic into a plain `.ts` module so it can be unit-tested. A test written against
|
||||||
|
already-fixed code can pass for the wrong reason.
|
||||||
|
|
||||||
|
## v0.1.0
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Cross-platform desktop packaging**, with a Windows NSIS installer built on tag.
|
||||||
|
- **A webview audio backend** for platforms without a native one.
|
||||||
|
- **A graphic equalizer** with presets and custom bands.
|
||||||
|
- **Home cards distinguish tap from long-press** — tap opens detail, long-press
|
||||||
|
plays.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- Downloaded browse groups by container and loads on large libraries.
|
||||||
|
|
||||||
|
## v0.0.18
|
||||||
|
|
||||||
|
- The background-audio button shows on all Android video playback.
|
||||||
|
|
||||||
|
## v0.0.17
|
||||||
|
|
||||||
|
This release carried the largest single body of work before v0.1.0 — the
|
||||||
|
provider-neutral domain model and the boundary rule that still governs the
|
||||||
|
frontend.
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **A provider-neutral media model.** The frontend was moved off Jellyfin's own
|
||||||
|
vocabulary in phases: item-type strings give way to a neutral `kind`, Jellyfin
|
||||||
|
ticks become milliseconds end to end (catalog, then player and reporting),
|
||||||
|
`primaryImageTag` becomes `imageId`, media streams get a neutral `StreamKind`,
|
||||||
|
and the user-facing type badge becomes a kind label. This is the work the
|
||||||
|
frontend/backend boundary rule was written to protect, and the tripwire script
|
||||||
|
(`check:boundary`) lands here with it.
|
||||||
|
- **Context-scoped search** with filter chips and group order.
|
||||||
|
- **A browsable downloaded library** with on-disk usage, and WiFi-only,
|
||||||
|
network-type-aware download gating.
|
||||||
|
- **A shared account menu and global app header.**
|
||||||
|
- **A reworked settings page.**
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- Library listing is gated to downloaded-only when offline.
|
||||||
|
|
||||||
|
### 📋 Documentation
|
||||||
|
|
||||||
|
- Specs, requirements, UX flows and traceability for the above, plus the written
|
||||||
|
boundary rule and spec workflow.
|
||||||
|
|
||||||
|
## v0.0.16
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Background-audio handoff for video**, alongside a repository/player refactor.
|
||||||
|
- **Android picture-in-picture** (and three dead Android config files corrected).
|
||||||
|
- **An mdBook docs site**, its publish workflow, and the release-notes tooling that
|
||||||
|
turns TRACES into grouped notes.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- Resuming video playback after background-audio-only mode.
|
||||||
|
|
||||||
|
## v0.0.15
|
||||||
|
|
||||||
|
- Navigation splits up from back; faster startup; a POSIX-sh-compatible CI
|
||||||
|
`versionCode` step.
|
||||||
|
|
||||||
|
## v0.0.14
|
||||||
|
|
||||||
|
- Layout and remote-playback fixes.
|
||||||
|
|
||||||
|
## v0.0.13
|
||||||
|
|
||||||
|
- Layout and search fixes.
|
||||||
|
|
||||||
|
## v0.0.12
|
||||||
|
|
||||||
|
- Offline mode fixes; the Android build uses the signing key.
|
||||||
|
|
||||||
|
## v0.0.11
|
||||||
|
|
||||||
|
- Offline mode and layout fixes; the per-commit Android APK build is replaced with
|
||||||
|
a fast compile check.
|
||||||
|
|
||||||
|
## v0.0.9 / v0.0.10
|
||||||
|
|
||||||
|
_Both tags point at the same commit._
|
||||||
|
|
||||||
|
- **The `PlayerAdapter` contract is introduced**, moving the decision logic into
|
||||||
|
the shared Rust backend — the origin of the unified player boundary the
|
||||||
|
architecture docs describe.
|
||||||
|
- CI APK build fixed; incremental builds enabled.
|
||||||
|
|
||||||
|
## v0.0.8
|
||||||
|
|
||||||
|
- Android playback fixes.
|
||||||
|
|
||||||
|
## v0.0.7
|
||||||
|
|
||||||
|
- **JRay support**, including actor mugshots.
|
||||||
|
- Playback reporting wired up; the duration flash fixed; video hidden from the
|
||||||
|
audio mini player.
|
||||||
|
- Android lockscreen and media controls kept in sync with playback.
|
||||||
|
- Sleep-timer and menu-return fixes.
|
||||||
|
|
||||||
|
## v0.0.6
|
||||||
|
|
||||||
|
Re-tag of v0.0.5 — no commits between the two.
|
||||||
|
|
||||||
|
## v0.0.5
|
||||||
|
|
||||||
|
- **Server-side channel plugins and HLS streaming.**
|
||||||
|
- **JellyLMS zones** can be fused and unfused into synchronized multi-room groups,
|
||||||
|
addressed by MAC.
|
||||||
|
|
||||||
|
## v0.0.4
|
||||||
|
|
||||||
|
- **Genre sliders, artist links and navigation utilities.**
|
||||||
|
- Audio can move between remote players.
|
||||||
|
|
||||||
|
## v0.0.3
|
||||||
|
|
||||||
|
- **Focused music, TV and movie landing screens**, and a self-draining download
|
||||||
|
queue.
|
||||||
|
|
||||||
|
## v0.0.2
|
||||||
|
|
||||||
|
- Autoplay resets time to zero and ignores its trigger if the episode has already
|
||||||
|
started. (The same defect returns in v0.5.5 — see
|
||||||
|
[docs/defect-windows.md](docs/defect-windows.md).)
|
||||||
|
|
||||||
|
## v0.0.1
|
||||||
|
|
||||||
|
First working proof of concept: the Tauri shell, the Rust repository and player
|
||||||
|
layers, and the initial Svelte frontend.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Entries for v0.1.1 and earlier were reconstructed from the git history after
|
||||||
|
the fact, so they are shorter and less specific than later ones — the commit
|
||||||
|
messages of that era did not record causes the way the current convention does.
|
||||||
|
-->
|
||||||
|
|
||||||
Released before this file existed — see the git history and the release notes on
|
|
||||||
each tag.
|
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ bun run android:dev # build + deploy
|
|||||||
bun run android:logs # logcat
|
bun run android:logs # logcat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The **debug** build type carries `applicationIdSuffix ".debug"`, so
|
||||||
|
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
|
||||||
|
build with its own data dir — never uninstall the release app to test a debug
|
||||||
|
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
|
||||||
|
R8-minified *release* build in that same slot, signed with the local debug
|
||||||
|
keystore, for validating minification without the real key. Only the
|
||||||
|
applicationId is suffixed; Kotlin classes stay in the `namespace` package
|
||||||
|
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
|
||||||
|
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
|
||||||
|
|
||||||
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
||||||
only against the mirror if one exists; the canonical remote is
|
only against the mirror if one exists; the canonical remote is
|
||||||
`gitea.tourolle.paris`.
|
`gitea.tourolle.paris`.
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Defect windows — which bugs were present when
|
||||||
|
|
||||||
|
For each fixed defect, the releases it was actually present in. Companion to
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
|
||||||
|
fault had been shipping before it did.
|
||||||
|
|
||||||
|
**"Present since"** is the first *release* containing the defective code, not the
|
||||||
|
first release where a user could hit it — those differ, sometimes by months, and
|
||||||
|
the gap is called out where it matters. **"How dated"** records the evidence, so a
|
||||||
|
row can be re-checked or disputed:
|
||||||
|
|
||||||
|
| Method | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
|
||||||
|
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
|
||||||
|
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
|
||||||
|
|
||||||
|
## Present since the first release
|
||||||
|
|
||||||
|
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
|
||||||
|
for between two weeks and seven weeks short of two months before anyone hit them.
|
||||||
|
That is the dominant pattern here: not regressions, but original assumptions that
|
||||||
|
went unexercised until a later feature leaned on them.
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
||||||
|
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
||||||
|
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
||||||
|
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||||
|
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||||
|
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||||
|
|
||||||
|
### Why they took so long to surface
|
||||||
|
|
||||||
|
Four of these were **latent until a later feature exercised them**, which is why
|
||||||
|
the fix lands so far from the cause:
|
||||||
|
|
||||||
|
- The `videoBitrate` casing was harmless while every download was `original`. It
|
||||||
|
became visible only once a quality picker existed to select against — and then
|
||||||
|
produced no error, just a full-size file, because Jellyfin discards an unbound
|
||||||
|
query key silently.
|
||||||
|
- The unconditional `Range` header was inert for the same reason: `original` is
|
||||||
|
the one rung served with a `Content-Length` and real byte-range support. It
|
||||||
|
started corrupting files in **v0.5.1**, the moment the casing fix made
|
||||||
|
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
|
||||||
|
*corruption* to v0.5.1 — a one-release window for the visible symptom.
|
||||||
|
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
|
||||||
|
item. Nothing re-opened one until quality switching, transcoded seek and
|
||||||
|
audio-track switching existed.
|
||||||
|
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
|
||||||
|
subtitle track is image-based, since that is what forces the server from
|
||||||
|
sidecar to burn-in.
|
||||||
|
|
||||||
|
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
|
||||||
|
was hidden by the thumbnail cache falling back to the server copy whenever the
|
||||||
|
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
|
||||||
|
silently correct an out-of-range index — which is exactly why it was reported as
|
||||||
|
"*some* videos have no audio" rather than as a bug in the client.
|
||||||
|
|
||||||
|
## Introduced by a feature, fixed later
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | How dated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
|
||||||
|
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
|
||||||
|
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
|
||||||
|
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
|
||||||
|
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
|
||||||
|
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
|
||||||
|
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
|
||||||
|
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
|
||||||
|
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
|
||||||
|
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
||||||
|
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||||
|
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||||
|
|
||||||
|
Three of these are worth separating out, because the defect is not a mistake in
|
||||||
|
the code so much as **plumbing that was built and never connected**:
|
||||||
|
|
||||||
|
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
|
||||||
|
written, and no caller passed one until v0.3.0.
|
||||||
|
- The sync queue and its drain were built, tested and running in v0.4.6 with
|
||||||
|
neither of its two would-be producers ever called.
|
||||||
|
- Both halves of the watched-state backend existed with no caller before v0.5.3.
|
||||||
|
|
||||||
|
An automated check cannot see any of these — the code is present, tested and
|
||||||
|
reachable in principle. Only tracing a requirement to a *call site* catches it.
|
||||||
|
|
||||||
|
## Short windows (one release or less)
|
||||||
|
|
||||||
|
| Defect | Present since | Fixed in | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
|
||||||
|
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
|
||||||
|
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
|
||||||
|
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
|
||||||
|
|
||||||
|
## Fixed twice / never actually broken
|
||||||
|
|
||||||
|
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
|
||||||
|
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
|
||||||
|
local original). Both have the same parent `674c8e5c` and the same diff. A merge
|
||||||
|
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
|
||||||
|
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
|
||||||
|
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
|
||||||
|
boundary. The fix shipped in **v0.0.2** and has not regressed.
|
||||||
|
|
||||||
|
This is the one case where reading the changelog off `git log` subjects would
|
||||||
|
have produced a false entry, and it is a good argument for the project's
|
||||||
|
practice of deriving release notes from TRACES rather than commit subjects.
|
||||||
|
|
||||||
|
## Recurring shapes
|
||||||
|
|
||||||
|
Four causes account for most of the table:
|
||||||
|
|
||||||
|
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
|
||||||
|
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
|
||||||
|
default that was actively wrong, and in three of the four the server's choice
|
||||||
|
was more expensive than the one intended — burn-in forcing a full re-encode
|
||||||
|
being the extreme case.
|
||||||
|
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
|
||||||
|
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
|
||||||
|
dividing by a stale denominator.
|
||||||
|
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
|
||||||
|
the position, backend never seeked), end-of-playback dispatch (two paths, one
|
||||||
|
unreachable), and the surface/attach split in v0.5.0's native video.
|
||||||
|
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
|
||||||
|
timer handle the callback had already cleared; the HTML5 toggle keyed
|
||||||
|
play-vs-pause on `el.paused`, which flips while buffering.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
The pickaxe rows can be re-derived directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
|
||||||
|
git tag --contains <sha> | sort -V | head -1 # first release with it
|
||||||
|
```
|
||||||
|
|
||||||
|
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
|
||||||
|
across many commits but was **not** used for the rows above: it reliably lands on
|
||||||
|
whichever commit last touched the adjacent lines, which is usually not the commit
|
||||||
|
that introduced the defect. It was used only to shortlist candidates.
|
||||||
+62
-12
@@ -84,6 +84,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
|
||||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||||
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||||
|
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
||||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -326,9 +327,32 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type` ↔ `item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
|
||||||
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
|
||||||
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
|
||||||
|
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
|
||||||
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
|
||||||
|
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
|
||||||
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
|
||||||
|
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
|
||||||
|
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
|
||||||
|
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
|
||||||
|
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
|
||||||
|
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
|
||||||
|
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
|
||||||
|
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
|
||||||
|
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException` → `400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
|
||||||
|
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
|
||||||
|
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
|
||||||
|
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
|
||||||
|
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
|
||||||
|
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: returning from background audio is HTML5-only (DR-190), so on the native path playback simply stays dead. Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one — so the default stays off and the flip is gated on DR-190 rather than on more confidence | UI | UR-003, UR-004, UR-041 | Blocked by DR-190 |
|
||||||
|
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||||
|
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||||
|
| DR-194 | The previous frame flashing on rotation. It reads as a TextureView artefact — the view retains its last frame, so between a rotation and `fitSurfaceToScreen()` landing that frame sits at the old size — and two fixes were built on that reading: revealing after two `postOnAnimation` hops, then revealing on `onSurfaceTextureUpdated`, which required owning the `SurfaceTextureListener` and handing ExoPlayer the Surface directly rather than via `setVideoTextureView`. **Neither stopped the flash.** The mechanism is the *window's* rotation animation: Android cross-fades a **screenshot of the old orientation**, that screenshot contains the old video frame at the old size, and no TextureView bookkeeping can reach it — nor can the app pre-empt the screenshot, since `onConfigurationChanged` fires after it is taken. The only lever is to stop the animation: `ROTATION_ANIMATION_JUMPCUT`. That was accepted and silently ignored at first, and the platform said why out loud — `VRI[MainActivity]: setLayoutParams: not fullscreen` — because the attribute is honoured only for a fullscreen window. `FLAG_FULLSCREEN` (deprecated for hiding system bars, which immersive mode does instead, but still what marks the window fullscreen for this decision) is therefore set alongside it, scoped to while native compositing is active so the rest of the app keeps its normal animation. The frame-arrival reveal is kept: it replaced a fixed-timeout guess with a real signal, and its timeout is required rather than defensive, since a resize while paused means no new frame is ever coming. **The flash is not confirmed fixed on device** — the forced-rotation harness (`settings put system user_rotation`) proved unreliable, and `screenrecord` fixes its canvas at start so a rotation inside a recording never changes frame dimensions, which defeated two attempts at measuring it | Android | UR-003, UR-066 | Needs device verification |
|
||||||
|
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
|
||||||
|
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
|
||||||
|
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
|
||||||
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
|
||||||
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real − base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
|
||||||
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
|
||||||
@@ -350,9 +374,9 @@ Internal architecture, components, and application logic.
|
|||||||
|----------|-------------------------|-------------------------|
|
|----------|-------------------------|-------------------------|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195 |
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171 |
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
@@ -365,14 +389,14 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-015 | - | DR-005, DR-020 |
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
| UR-016 | - | - |
|
| UR-016 | - | - |
|
||||||
| UR-017 | - | DR-014, DR-021 |
|
| UR-017 | - | DR-014, DR-021 |
|
||||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
|
||||||
| UR-019 | IR-015 | DR-022 |
|
| UR-019 | IR-015 | DR-022 |
|
||||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
| UR-020 | IR-016, IR-018 | DR-023, DR-176 |
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
| UR-027 | IR-020 | DR-030 |
|
| UR-027 | IR-020 | DR-030 |
|
||||||
| UR-028 | - | DR-031 |
|
| UR-028 | - | DR-031 |
|
||||||
@@ -387,8 +411,8 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
|
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190 |
|
||||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161 |
|
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055 |
|
| UR-043 | IR-027 | DR-055 |
|
||||||
| UR-044 | - | DR-056 |
|
| UR-044 | - | DR-056 |
|
||||||
@@ -402,7 +426,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||||
| UR-053 | IR-029 | DR-074 |
|
| UR-053 | IR-029 | DR-074 |
|
||||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087, DR-142 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
@@ -412,15 +436,16 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-063 | - | DR-105 |
|
| UR-063 | - | DR-105 |
|
||||||
| UR-064 | - | DR-106 |
|
| UR-064 | - | DR-106 |
|
||||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||||
| UR-066 | IR-031 | DR-112, DR-157 |
|
| UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
|
||||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||||
| UR-068 | - | DR-119 |
|
| UR-068 | - | DR-119 |
|
||||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||||
| UR-070 | - | DR-121, DR-122 |
|
| UR-070 | - | DR-121, DR-122 |
|
||||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171 |
|
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180 |
|
||||||
| UR-072 | - | DR-156 |
|
| UR-072 | - | DR-156 |
|
||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162 |
|
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||||
|
| UR-075 | - | DR-174, DR-175 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -573,6 +598,9 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||||
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
|
||||||
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
|
||||||
|
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
|
||||||
|
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
|
||||||
|
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
|
||||||
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
|
||||||
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
|
||||||
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
|
||||||
@@ -580,6 +608,21 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
|
||||||
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
|
||||||
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
|
||||||
|
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
|
||||||
|
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
|
||||||
|
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
|
||||||
|
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
|
||||||
|
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
|
||||||
|
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
|
||||||
|
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
|
||||||
|
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
|
||||||
|
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
|
||||||
|
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
|
||||||
|
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
|
||||||
|
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
|
||||||
|
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
|
||||||
|
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
|
||||||
|
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
|
||||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
@@ -587,6 +630,13 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||||
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||||
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||||
|
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
|
||||||
|
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
|
||||||
|
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
|
||||||
|
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
|
||||||
|
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
|
||||||
|
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
|
||||||
|
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# Spec: Android native video — transparent-webview spike
|
# Spec: Android native video — transparent-webview spike
|
||||||
|
|
||||||
**Status:** Spike succeeded — native video confirmed working on a physical
|
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
|
||||||
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
|
default off. Flipping that default shipped **audio with no picture** and was
|
||||||
default off. Branch `feat/android-native-video`.
|
reverted (DR-172). Three defects behind that have since been fixed — DR-182
|
||||||
|
(nothing on the native path could lift the poster overlay), DR-183 (the JS
|
||||||
|
bridges raced the page load), DR-184 (the SurfaceView was never detached).
|
||||||
|
Branch `fix/android-native-video-visible`. **The default stays off until the
|
||||||
|
device criteria below are green.**
|
||||||
|
|
||||||
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
|
||||||
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
composited behind a transparent Tauri WebView on Android. Nothing upstream
|
||||||
@@ -227,6 +231,9 @@ The spike is **complete** when one of these is true:
|
|||||||
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
|
||||||
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||||
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
|
||||||
|
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
|
||||||
|
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
|
||||||
|
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
|
||||||
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
|
||||||
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
|
||||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
|
||||||
@@ -238,9 +245,26 @@ The spike is **complete** when one of these is true:
|
|||||||
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||||
|
|
||||||
Either way:
|
Either way:
|
||||||
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
|
||||||
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
|
||||||
|
|
||||||
|
### Why the 2026-08-11 verification and DR-172 do not contradict each other
|
||||||
|
|
||||||
|
The spike was reported working on device; the same path then shipped as audio
|
||||||
|
with no picture. Both are consistent with DR-182: the poster overlay is drawn
|
||||||
|
only while `isMediaReady` is false, and the native path has no way to set it, so
|
||||||
|
what the surface shows depends entirely on **whether that overlay is on screen**
|
||||||
|
— not on whether compositing works. Any run that reached the player through a
|
||||||
|
path leaving `isMediaReady` already true (a handoff return, a re-render, a
|
||||||
|
session that had previously played on the HTML5 path) shows video; a cold start
|
||||||
|
into the native path never does. That is also why DR-172 read the symptom as a
|
||||||
|
compositing failure: on screen the two are identical, and the one piece of
|
||||||
|
evidence separating them — `WebView transparent = true` never being logged —
|
||||||
|
points at DR-183 rather than at the compositing itself.
|
||||||
|
|
||||||
|
**This reasoning is not yet device-confirmed.** It explains the reports and is
|
||||||
|
backed by the code, but the criteria above are what settle it.
|
||||||
|
|
||||||
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
|
||||||
> no `bun`, so all of the above were run inside the CI builder image
|
> no `bun`, so all of the above were run inside the CI builder image
|
||||||
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Spec: Library mosaic (library overview + home shortcuts)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
|
||||||
|
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The library overview and the home "Your Libraries" strip stop being fixed-shape
|
||||||
|
grids and become a **mosaic**: rows share one height, and each tile is as wide as
|
||||||
|
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
|
||||||
|
poster sit in the same row at their own proportions instead of all three being
|
||||||
|
cropped into whichever box the grid picked. Favourites gain a tile per category,
|
||||||
|
placed beside the library that category belongs to, alongside the existing
|
||||||
|
cross-library entry.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Every surface here shows artwork of more than one shape. The grid resolved that
|
||||||
|
by choosing one shape and cropping to it — and the home strip said so out loud:
|
||||||
|
|
||||||
|
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
|
||||||
|
> height in this mixed row.
|
||||||
|
|
||||||
|
Lining them up is right; cropping the covers to do it is not. Holding the
|
||||||
|
**height** fixed and letting the **width** vary achieves the same alignment with
|
||||||
|
no crop at all, which is the whole idea of a justified layout.
|
||||||
|
|
||||||
|
Favourites had one entry for everything. With per-category tiles, "my favourite
|
||||||
|
albums" is one tap from the library page rather than a tap plus a tab.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
|
||||||
|
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
|
||||||
|
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
|
||||||
|
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
|
||||||
|
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
|
||||||
|
|
||||||
|
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
|
||||||
|
any per-collection-type table deserves suspicion. The tie-breaker: it does not
|
||||||
|
decide *what a category means* or what is fetched — it seeds a pixel dimension
|
||||||
|
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
|
||||||
|
not a wrong result. The scope mapping, which does decide what is fetched, went to
|
||||||
|
Rust.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Wire
|
||||||
|
|
||||||
|
`Library` gains one optional field, derived at construction:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Library {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub collection_type: String,
|
||||||
|
pub image_tag: Option<String>,
|
||||||
|
pub favorites_scope: Option<SearchScope>, // ← new
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchScope {
|
||||||
|
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Library = { …; favoritesScope?: SearchScope | null }
|
||||||
|
```
|
||||||
|
|
||||||
|
`Library::new` derives it, so the four construction sites (online views, two
|
||||||
|
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
|
||||||
|
not sent as null. No new command, no new event.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
`src/lib/components/library/mosaic.ts` — pure, no DOM:
|
||||||
|
|
||||||
|
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
|
||||||
|
with pixel boxes. Tiles join a row until the height needed to fill the width
|
||||||
|
drops to the target; the row closes there and is justified to the container
|
||||||
|
width, the rounding remainder absorbed by its widest tile. The **last row is
|
||||||
|
not justified** (one leftover tile would inflate into a banner) — it sits at
|
||||||
|
the target height, left-aligned.
|
||||||
|
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
|
||||||
|
a horizontally scrolling shelf.
|
||||||
|
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
|
||||||
|
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
|
||||||
|
doesn't turn each library into a billboard.
|
||||||
|
- Ratios are clamped to a band (0.5–2.5) so one panorama can't own a row.
|
||||||
|
|
||||||
|
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
|
||||||
|
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
|
||||||
|
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
|
||||||
|
callback for the second. Measured ratios are committed in one debounced batch
|
||||||
|
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
|
||||||
|
image would shuffle the grid under the pointer.
|
||||||
|
|
||||||
|
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
|
||||||
|
**over** the bottom of the artwork. A caption below the box would add height the
|
||||||
|
layout didn't compute, and a caption that wrapped to two lines would break the
|
||||||
|
row alignment the mosaic exists to provide.
|
||||||
|
|
||||||
|
### Composition
|
||||||
|
|
||||||
|
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
|
||||||
|
favourites entry first, then each library followed by its own category tile. A
|
||||||
|
category appears **once** — two movie libraries share one favourites list, so a
|
||||||
|
tile each would be two tiles to the same place. A library whose `favoritesScope`
|
||||||
|
is absent (Live TV, channels, books) gets no tile rather than one opening an
|
||||||
|
unfiltered list.
|
||||||
|
|
||||||
|
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
|
||||||
|
home already carries Favourite Movies / Shows / Music rows of its own, and a
|
||||||
|
second entry point in the strip above them would be redundant.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
|
||||||
|
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
|
||||||
|
them nothing but reflow.
|
||||||
|
- Backdrop/collage artwork for libraries with no image of their own.
|
||||||
|
- Reordering or pinning libraries.
|
||||||
+6140
-4681
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.5.3",
|
"version": "0.6.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ echo ""
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
# Deploy APK — forward the build type and the side-by-side flag (which decides
|
||||||
BUILD_TYPE="debug"
|
# which package to launch), ignoring build-only flags like --clean and --device.
|
||||||
|
DEPLOY_ARGS=("debug")
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) DEPLOY_ARGS[0]="$arg" ;;
|
||||||
|
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
|
||||||
|
|||||||
@@ -23,9 +23,18 @@ echo ""
|
|||||||
# which is what a distributable universal APK needs — but for an on-device test
|
# which is what a distributable universal APK needs — but for an on-device test
|
||||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
# only the connected device's architecture; --abi <t> targets one explicitly.
|
||||||
|
#
|
||||||
|
# Side-by-side: the `debug` build type always installs as
|
||||||
|
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
|
||||||
|
# real install. `release --debug` puts a *release* build — R8-minified, exactly
|
||||||
|
# what ships — into that same slot, signed with the local debug keystore. That
|
||||||
|
# is how you validate minification (R8 stripping JNI-loaded classes has broken
|
||||||
|
# release APKs here before) without the real signing key and without
|
||||||
|
# uninstalling the app you actually use.
|
||||||
BUILD_TYPE="debug"
|
BUILD_TYPE="debug"
|
||||||
CLEAN="${CLEAN:-0}"
|
CLEAN="${CLEAN:-0}"
|
||||||
ABI="${ABI:-}"
|
ABI="${ABI:-}"
|
||||||
|
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
|
||||||
next_is_abi=0
|
next_is_abi=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$next_is_abi" = "1" ]; then
|
if [ "$next_is_abi" = "1" ]; then
|
||||||
@@ -37,10 +46,17 @@ for arg in "$@"; do
|
|||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
--abi) next_is_abi=1 ;;
|
--abi) next_is_abi=1 ;;
|
||||||
--device) ABI="device" ;;
|
--device) ABI="device" ;;
|
||||||
|
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The debug build type is side-by-side unconditionally; the flag only means
|
||||||
|
# something for a release build.
|
||||||
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
|
SIDE_BY_SIDE=1
|
||||||
|
fi
|
||||||
|
|
||||||
# Resolve --device to the attached device's Rust target triple.
|
# Resolve --device to the attached device's Rust target triple.
|
||||||
if [ "$ABI" = "device" ]; then
|
if [ "$ABI" = "device" ]; then
|
||||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
||||||
@@ -78,7 +94,14 @@ echo "🎨 Building frontend..."
|
|||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
# A release build in the debug slot: R8 still runs, but the applicationId is
|
||||||
|
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
||||||
|
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
||||||
|
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
||||||
|
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
||||||
|
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
# Configure release signing from .env (single source of truth). Must run
|
# Configure release signing from .env (single source of truth). Must run
|
||||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
|
|||||||
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
# Build type: debug or release (default: debug). `--debug` alongside `release`
|
||||||
BUILD_TYPE="${1:-debug}"
|
# means the side-by-side release build — same APK path, but it was packaged
|
||||||
|
# under the .debug applicationId, so the package to launch differs.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
SIDE_BY_SIDE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
|
||||||
|
|
||||||
|
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
|
||||||
|
# separate package, so it installs alongside a real release build — no
|
||||||
|
# uninstall dance needed.
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||||
else
|
else
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||||
|
else
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau"
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if APK exists
|
# Check if APK exists
|
||||||
if [ ! -f "$APK_PATH" ]; then
|
if [ ! -f "$APK_PATH" ]; then
|
||||||
echo "❌ APK not found at: $APK_PATH"
|
echo "❌ APK not found at: $APK_PATH"
|
||||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||||
|
echo "Run './scripts/build-android.sh release --debug' first"
|
||||||
|
else
|
||||||
|
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Installing APK: $APK_PATH"
|
echo "📦 Installing APK: $APK_PATH"
|
||||||
adb install -r "$APK_PATH"
|
echo "📛 Package: $APP_PACKAGE"
|
||||||
|
|
||||||
|
if ! adb install -r "$APK_PATH"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Install failed."
|
||||||
|
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
|
||||||
|
echo " '$APP_PACKAGE' signed with a different key is still installed."
|
||||||
|
echo " Uninstall just that one and retry:"
|
||||||
|
echo " adb uninstall $APP_PACKAGE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Deployment complete!"
|
echo "✅ Deployment complete!"
|
||||||
echo "🚀 Launch the app on your device"
|
echo "🚀 Launching..."
|
||||||
|
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|
||||||
|
|| echo " (auto-launch failed — start it from the launcher)"
|
||||||
|
|||||||
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
|
|||||||
);
|
);
|
||||||
const defined = countDefinedRequirements(md);
|
const defined = countDefinedRequirements(md);
|
||||||
|
|
||||||
expect(defined.UR).toBe(74);
|
expect(defined.UR).toBe(75);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(162);
|
expect(defined.DR).toBe(185);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(303);
|
expect(defined.total).toBe(327);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-4
@@ -1,13 +1,34 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# View Android logcat output filtered for the app
|
# View Android logcat output filtered for the app.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
|
||||||
|
#
|
||||||
|
# The debug build has applicationIdSuffix ".debug" so it can be installed
|
||||||
|
# alongside a release build; pick the package to follow accordingly.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
APP_PACKAGE="com.jellytau.app"
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau"
|
||||||
|
else
|
||||||
|
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📱 Showing logcat for $APP_PACKAGE"
|
echo "📱 Showing logcat for $APP_PACKAGE"
|
||||||
echo "Press Ctrl+C to stop"
|
echo "Press Ctrl+C to stop"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Filter logcat for the app's package name
|
# Prefer PID-scoped output when the app is running — it drops the noise that a
|
||||||
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
|
||||||
|
# start the script first and then launch the app).
|
||||||
|
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo " (attached to pid $PID)"
|
||||||
|
adb logcat --pid="$PID"
|
||||||
|
else
|
||||||
|
echo " (app not running — falling back to keyword filter)"
|
||||||
|
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
|
||||||
|
fi
|
||||||
|
|||||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -41,6 +41,57 @@ When you need to modify Android/Kotlin files:
|
|||||||
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
||||||
- **You must edit both** (or edit source and copy to generated)
|
- **You must edit both** (or edit source and copy to generated)
|
||||||
|
|
||||||
|
### Debug and release install side by side
|
||||||
|
|
||||||
|
The **debug** build type sets `applicationIdSuffix = ".debug"` in
|
||||||
|
`app/build.gradle.kts`, so a debug build is a genuinely separate Android app:
|
||||||
|
|
||||||
|
| build | applicationId | launcher name | versionName | signed with |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) |
|
||||||
|
| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore |
|
||||||
|
| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore |
|
||||||
|
|
||||||
|
`release --debug` is the **side-by-side release**: fully R8-minified, exactly
|
||||||
|
what ships, but packaged into the debug slot and signed with the local debug
|
||||||
|
keystore. It exists because R8 has broken release APKs here before (stripping
|
||||||
|
JNI-loaded player/security classes), and reproducing that previously meant
|
||||||
|
building with the real key and clobbering your working install. It shares the
|
||||||
|
applicationId *and* signature with the plain debug build, so the two replace
|
||||||
|
each other cleanly; only the versionName suffix tells you which is installed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-and-deploy.sh release --device --debug # build + install it
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`.
|
||||||
|
CI never sets it, so distributable release builds are untouched.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- **No uninstall step.** Debug builds are signed with the local auto-generated
|
||||||
|
`~/.android/debug.keystore`, release builds with the real key. Two different
|
||||||
|
keys on the *same* package is `INSTALL_FAILED_UPDATE_INCOMPATIBLE`; two
|
||||||
|
different packages is just two apps.
|
||||||
|
- Each has **its own data directory** — separate settings, credentials,
|
||||||
|
downloads and offline cache. A debug experiment cannot corrupt the state of
|
||||||
|
the build you actually use. This is not optional and cannot be shared:
|
||||||
|
Android gives each applicationId its own UID and enforces the boundary in the
|
||||||
|
kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an
|
||||||
|
already-installed app anyway.) You log in again in the debug app, once.
|
||||||
|
- Only the *application* id changes. Kotlin classes stay in the `namespace`
|
||||||
|
package `com.dtourolle.jellytau`, so the JNI class lookups in
|
||||||
|
`src-tauri/src/player/android/mod.rs`, the manifest `<service>` entry and the
|
||||||
|
R8 keep rules in `proguard-jellytau.pro` are all unaffected. The FileProvider
|
||||||
|
authority is `${applicationId}.fileprovider`, so it follows the suffix
|
||||||
|
automatically.
|
||||||
|
- The launcher labels come from the `appLabel` / `activityLabel`
|
||||||
|
manifestPlaceholders (`AndroidManifest.xml` uses `${appLabel}`), *not* from
|
||||||
|
`resValue`, which would collide with Tauri's generated `strings.xml`.
|
||||||
|
|
||||||
|
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||||
|
(defaults to debug).
|
||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
|
|
||||||
Player-related Kotlin files:
|
Player-related Kotlin files:
|
||||||
|
|||||||
@@ -22,11 +22,25 @@ val keystoreProperties = Properties().apply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
|
||||||
|
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
|
||||||
|
// debug applicationId slot, signed with the local debug keystore — so you can
|
||||||
|
// test what minification actually produces (R8 stripping JNI-loaded classes has
|
||||||
|
// broken release APKs here before) without the real signing key and without
|
||||||
|
// uninstalling your working install. Unset in CI, so distributable release
|
||||||
|
// builds are untouched.
|
||||||
|
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
namespace = "com.dtourolle.jellytau"
|
namespace = "com.dtourolle.jellytau"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
// Launcher/app names come from placeholders so the debug build can
|
||||||
|
// rename itself without touching the generated strings.xml (a
|
||||||
|
// resValue() override there would collide with Tauri's own entries).
|
||||||
|
manifestPlaceholders["appLabel"] = "@string/app_name"
|
||||||
|
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
|
||||||
applicationId = "com.dtourolle.jellytau"
|
applicationId = "com.dtourolle.jellytau"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
@@ -45,6 +59,21 @@ android {
|
|||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
// Distinct applicationId so the debug build installs SIDE BY SIDE
|
||||||
|
// with a release/store install instead of demanding an uninstall
|
||||||
|
// (different signing keys on the same package = INSTALL_FAILED_
|
||||||
|
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
|
||||||
|
// and its own offline cache — the two are fully independent apps.
|
||||||
|
//
|
||||||
|
// This changes only the *application* id. The Kotlin/JNI classes
|
||||||
|
// stay in the `namespace` package (com.dtourolle.jellytau), so the
|
||||||
|
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||||
|
// <service> entry and the R8 keep rules are all unaffected. The
|
||||||
|
// FileProvider authority is already ${applicationId}-relative.
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||||
|
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
isJniDebuggable = true
|
isJniDebuggable = true
|
||||||
@@ -56,7 +85,18 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
if (sideBySideRelease) {
|
||||||
|
// Same slot, name and version scheme as the debug build type,
|
||||||
|
// plus "-release" so you can tell from Settings > Apps which of
|
||||||
|
// the two is currently sitting there. Signed with the debug
|
||||||
|
// keystore: it shares a signature with the debug build, so the
|
||||||
|
// two replace each other cleanly instead of colliding.
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug-release"
|
||||||
|
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||||
|
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
} else if (keystoreProperties.getProperty("storeFile") != null) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
(An earlier version of this file was a partial <application> fragment on the
|
(An earlier version of this file was a partial <application> fragment on the
|
||||||
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
||||||
declared never reached any built APK. It is folded in properly below.)
|
declared never reached any built APK. It is folded in properly below.)
|
||||||
|
|
||||||
|
${appLabel} / ${activityLabel} are manifestPlaceholders set in
|
||||||
|
app/build.gradle.kts: they resolve to @string/app_name and
|
||||||
|
@string/main_activity_title for release, and to "JellyTau Debug" for the
|
||||||
|
debug build type (which also carries applicationIdSuffix ".debug" so it
|
||||||
|
installs alongside a release build).
|
||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -26,7 +32,7 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="${appLabel}"
|
||||||
android:theme="@style/Theme.jellytau"
|
android:theme="@style/Theme.jellytau"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
@@ -34,7 +40,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:label="@string/main_activity_title"
|
android:label="${activityLabel}"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.webkit.WebView
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var configAttempts = 0
|
private var configAttempts = 0
|
||||||
@@ -52,6 +53,42 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private var bridgesInstalledOn: WebView? = null
|
private var bridgesInstalledOn: WebView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wry hands us the WebView here, and this is the only point at which the
|
||||||
|
* bridges can be installed *deterministically*.
|
||||||
|
*
|
||||||
|
* WebView binds an injected object into JS at **page-load time**: an
|
||||||
|
* addJavascriptInterface call that lands after the page has loaded does not
|
||||||
|
* appear to that page at all. The bridges used to be installed from
|
||||||
|
* [configureWebViewForMedia], which finds the WebView by walking the view
|
||||||
|
* tree 500 ms after onCreate — a race against Tauri's own page load, and one
|
||||||
|
* that is *permanent* when lost, because the identity guard then declines to
|
||||||
|
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
|
||||||
|
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
|
||||||
|
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
|
||||||
|
* silently: every one of them is called through an optional chain, so a
|
||||||
|
* missing bridge is a no-op rather than an error. That is a candidate
|
||||||
|
* explanation for DR-172's central piece of evidence — native video shipped
|
||||||
|
* with `WebView transparent = false` logged and `= true` never appearing,
|
||||||
|
* i.e. the enable call never reaching Kotlin.
|
||||||
|
*
|
||||||
|
* `WryActivity.setWebView()` calls this immediately before wry issues the
|
||||||
|
* first `loadUrl`, so a bridge installed here is bound by the time any page
|
||||||
|
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
|
||||||
|
* of our own onCreate — so only work that needs nothing but the WebView
|
||||||
|
* belongs here. Insets are deliberately left to
|
||||||
|
* [configureWebViewForMedia], which runs later and on every resume.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-183
|
||||||
|
*/
|
||||||
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
|
super.onWebViewCreate(webView)
|
||||||
|
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||||
|
mediaWebView = webView
|
||||||
|
installJavascriptBridges(webView)
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -154,6 +191,37 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation (and any other config change this Activity handles itself).
|
||||||
|
*
|
||||||
|
* Two things have to happen here rather than later, and both are about the
|
||||||
|
* *previous* video frame surviving the transition:
|
||||||
|
*
|
||||||
|
* - The video view is hidden until a new frame arrives. The equivalent call
|
||||||
|
* in `fitSurfaceToScreen` runs from the content view's layout listener,
|
||||||
|
* which is after the rotation — by then the stale frame has been on screen
|
||||||
|
* for the whole transition.
|
||||||
|
* - The window's rotation animation is a **cross-fade of a screenshot** of
|
||||||
|
* the old orientation, and that screenshot contains the old video frame at
|
||||||
|
* the old size. No amount of TextureView bookkeeping can touch it, which is
|
||||||
|
* why hiding on frame-arrival alone did not stop the flash. `JUMPCUT` drops
|
||||||
|
* the cross-fade, so there is no old frame to fade through; it is set only
|
||||||
|
* while native compositing is active (see setTransparent) so the rest of
|
||||||
|
* the app keeps the normal animation.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-066 | DR-194
|
||||||
|
*/
|
||||||
|
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
try {
|
||||||
|
if (com.dtourolle.jellytau.player.JellyTauPlayer.isInitialized()) {
|
||||||
|
com.dtourolle.jellytau.player.JellyTauPlayer.getInstance().hideUntilFreshFrame()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("MainActivity", "hideUntilFreshFrame on config change failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(
|
override fun onPictureInPictureModeChanged(
|
||||||
isInPictureInPictureMode: Boolean,
|
isInPictureInPictureMode: Boolean,
|
||||||
newConfig: android.content.res.Configuration
|
newConfig: android.content.res.Configuration
|
||||||
@@ -165,7 +233,9 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
private fun configureWebViewForMedia() {
|
private fun configureWebViewForMedia() {
|
||||||
try {
|
try {
|
||||||
val webView = findWebView(window.decorView)
|
// onWebViewCreate normally got here first; the tree walk is the fallback
|
||||||
|
// for a WebView we were never handed.
|
||||||
|
val webView = mediaWebView ?: findWebView(window.decorView)
|
||||||
|
|
||||||
if (webView == null) {
|
if (webView == null) {
|
||||||
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
||||||
@@ -183,33 +253,47 @@ class MainActivity : TauriActivity() {
|
|||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
|
|
||||||
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
|
||||||
//
|
|
||||||
// configureWebViewForMedia() runs from onCreate's delayed post AND from
|
|
||||||
// every onResume (plus each WebView re-find), so this used to re-inject
|
|
||||||
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
|
|
||||||
// injected objects at page-load time; re-injecting over a live page
|
|
||||||
// leaves JS holding a stale proxy. The object stays truthy while its
|
|
||||||
// methods vanish, which surfaced as a flood of
|
|
||||||
// "WebView: Unknown object" chromium errors and, in JS,
|
|
||||||
// "TypeError: setEnabled is not a function".
|
|
||||||
//
|
|
||||||
// The visible bug: the background-audio toggle turned blue but never
|
|
||||||
// reached native, so backgroundAudioEnabled stayed false, onStop never
|
|
||||||
// dispatched 'jellytau-background', and a locked screen killed audio
|
|
||||||
// instantly (UR-040). Audio focus and PiP broke the same way.
|
|
||||||
//
|
|
||||||
// The settings/WebChromeClient work below is idempotent and must keep
|
|
||||||
// running on resume; only the bridge injection is one-shot.
|
|
||||||
|
|
||||||
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
|
||||||
// idempotent and MUST re-run: a page load discards the inline style the
|
// idempotent and MUST re-run: a page load discards the inline style the
|
||||||
// last push set, so the WebView would otherwise be left with no insets.
|
// last push set, so the WebView would otherwise be left with no insets.
|
||||||
WindowInsetsBridge.attachWebView(webView)
|
WindowInsetsBridge.attachWebView(webView)
|
||||||
|
|
||||||
|
// Normally already done by onWebViewCreate; this is the fallback path.
|
||||||
|
installJavascriptBridges(webView)
|
||||||
|
configureWebViewSettings(webView)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
|
||||||
|
*
|
||||||
|
* This runs from [onWebViewCreate] — the only point early enough to be bound
|
||||||
|
* before the first page load — and from [configureWebViewForMedia] as a
|
||||||
|
* fallback. The latter runs from onCreate's delayed post AND from every
|
||||||
|
* onResume (plus each WebView re-find), so without the identity guard this
|
||||||
|
* re-injected every bridge repeatedly — 5 times in a 45s session. WebView
|
||||||
|
* binds injected objects at page-load time; re-injecting over a live page
|
||||||
|
* leaves JS holding a stale proxy. The object stays truthy while its methods
|
||||||
|
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
|
||||||
|
* errors and, in JS, "TypeError: setEnabled is not a function".
|
||||||
|
*
|
||||||
|
* The visible bug: the background-audio toggle turned blue but never reached
|
||||||
|
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
|
||||||
|
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
|
||||||
|
* Audio focus and PiP broke the same way.
|
||||||
|
*
|
||||||
|
* Settings/WebChromeClient work is idempotent and must keep running on
|
||||||
|
* resume, so it lives in [configureWebViewSettings], not here.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
|
||||||
|
*/
|
||||||
|
private fun installJavascriptBridges(webView: WebView) {
|
||||||
|
try {
|
||||||
if (webView === bridgesInstalledOn) {
|
if (webView === bridgesInstalledOn) {
|
||||||
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
|
||||||
configureWebViewSettings(webView)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bridgesInstalledOn = webView
|
bridgesInstalledOn = webView
|
||||||
@@ -328,6 +412,34 @@ class MainActivity : TauriActivity() {
|
|||||||
window.setBackgroundDrawable(
|
window.setBackgroundDrawable(
|
||||||
android.graphics.drawable.ColorDrawable(color)
|
android.graphics.drawable.ColorDrawable(color)
|
||||||
)
|
)
|
||||||
|
// Drop the rotation cross-fade while a native video surface is
|
||||||
|
// composited behind the page. The animation fades a *screenshot* of
|
||||||
|
// the old orientation, which still holds the previous video frame at
|
||||||
|
// the old size — that is the "previous frame flashing in the black
|
||||||
|
// bars", and it lives in the window animation rather than in
|
||||||
|
// anything the TextureView owns. (DR-194)
|
||||||
|
val attrs = window.attributes
|
||||||
|
attrs.rotationAnimation = if (transparent) {
|
||||||
|
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
|
||||||
|
} else {
|
||||||
|
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE
|
||||||
|
}
|
||||||
|
window.attributes = attrs
|
||||||
|
// `rotationAnimation` is honoured only for a **fullscreen** window —
|
||||||
|
// the platform says so out loud, logging
|
||||||
|
// "VRI[MainActivity]: setLayoutParams: not fullscreen" when the
|
||||||
|
// attribute is set on ours, and then animating normally regardless.
|
||||||
|
// Without this the JUMPCUT above is accepted and ignored, and the
|
||||||
|
// cross-fade keeps showing the old orientation's screenshot, stale
|
||||||
|
// video frame and all. FLAG_FULLSCREEN is deprecated for *hiding
|
||||||
|
// system bars* (immersive mode does that, on player entry), but it
|
||||||
|
// is still what marks the window fullscreen for this decision.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
if (transparent) {
|
||||||
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
} else {
|
||||||
|
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
}
|
||||||
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,10 +483,8 @@ class MainActivity : TauriActivity() {
|
|||||||
dispatchWebEvent("jellytau-network-changed")
|
dispatchWebEvent("jellytau-network-changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
configureWebViewSettings(webView)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.dtourolle.jellytau
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.view.SurfaceView
|
import android.view.TextureView
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||||
@@ -14,15 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
|||||||
*/
|
*/
|
||||||
object VideoOverlayManager {
|
object VideoOverlayManager {
|
||||||
|
|
||||||
private var attachedSurfaceView: SurfaceView? = null
|
private var attachedSurfaceView: TextureView? = null
|
||||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||||
private var listenerContentView: ViewGroup? = null
|
private var listenerContentView: ViewGroup? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach the video SurfaceView to the Activity's content view.
|
* Attach the video view to the Activity's content view.
|
||||||
*
|
*
|
||||||
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
* Added at index 0 (bottom of the z-order) so it renders behind the Tauri
|
||||||
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
* WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
|
||||||
|
* is a TextureView, so "behind" is ordinary view z-order within one window
|
||||||
|
* rather than a separate surface punched through it — which is what makes
|
||||||
|
* the overlay above it repaint reliably.
|
||||||
*
|
*
|
||||||
* @param activity The Activity to attach the surface to
|
* @param activity The Activity to attach the surface to
|
||||||
*/
|
*/
|
||||||
@@ -77,16 +80,29 @@ object VideoOverlayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach the video SurfaceView from the Activity's view hierarchy.
|
* Detach the video SurfaceView from the view hierarchy.
|
||||||
*
|
*
|
||||||
* @param activity The Activity to detach the surface from
|
* Must be called on the main thread.
|
||||||
|
*
|
||||||
|
* This had **no callers at all**, which made [attachVideoSurface] one-way:
|
||||||
|
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
|
||||||
|
* without removing the view, so every native video left its SurfaceView
|
||||||
|
* parented to the content view for the life of the process and the next one
|
||||||
|
* added another beneath it. The stack was invisible while the WebView was
|
||||||
|
* opaque, and [isVideoSurfaceAttached] — which gates
|
||||||
|
* `PictureInPictureManager.canEnterPip` — stayed true forever afterwards.
|
||||||
|
*
|
||||||
|
* Removes from the view's *own* parent rather than looking the content view
|
||||||
|
* up from an Activity, so it cannot leave a view behind when the Activity
|
||||||
|
* has been recreated under it.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-041 | DR-184
|
||||||
*/
|
*/
|
||||||
fun detachVideoSurface(activity: Activity) {
|
fun detachVideoSurface() {
|
||||||
try {
|
try {
|
||||||
removeLayoutListener()
|
removeLayoutListener()
|
||||||
attachedSurfaceView?.let { surfaceView ->
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||||
contentView.removeView(surfaceView)
|
|
||||||
attachedSurfaceView = null
|
attachedSurfaceView = null
|
||||||
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -39,6 +38,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** AudioEffect priority. Positive = higher priority than the default. */
|
/** AudioEffect priority. Positive = higher priority than the default. */
|
||||||
private const val EFFECT_PRIORITY = 1000
|
private const val EFFECT_PRIORITY = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a fresh frame after a resize before revealing the
|
||||||
|
* view anyway. Playback may be paused, in which case no frame is coming.
|
||||||
|
*/
|
||||||
|
private const val FRESH_FRAME_TIMEOUT_MS = 400L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
|
||||||
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
|
||||||
@@ -225,9 +230,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Media type enum */
|
/** Media type enum */
|
||||||
enum class MediaType { AUDIO, VIDEO }
|
enum class MediaType { AUDIO, VIDEO }
|
||||||
|
|
||||||
/** SurfaceView for video playback */
|
/** TextureView for video playback — see getOrCreateSurfaceView() for why. */
|
||||||
private var surfaceView: SurfaceView? = null
|
private var videoView: TextureView? = null
|
||||||
private var surfaceHolder: SurfaceHolder? = null
|
|
||||||
|
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
|
||||||
|
private var videoSurface: android.view.Surface? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the view is hidden waiting for a new frame after a resize.
|
||||||
|
* See fitSurfaceToScreen (DR-194).
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var awaitingFreshFrame = false
|
||||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -1078,52 +1092,114 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create the SurfaceView for video playback.
|
* Get or create the video view, and hand it to ExoPlayer.
|
||||||
* Returns the view ID that can be attached to the view hierarchy.
|
|
||||||
*
|
*
|
||||||
* Note: The surface is created but not automatically attached to the view hierarchy.
|
* This is a **TextureView**, not a SurfaceView, and that is the whole point.
|
||||||
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
*
|
||||||
|
* A SurfaceView renders on its own layer *outside* the app window and punches
|
||||||
|
* a transparent hole through the window to show it. Anything drawn above
|
||||||
|
* that hole — for us, the entire Svelte UI in a transparent WebView — is at
|
||||||
|
* the mercy of that composition path, and Android's own graphics
|
||||||
|
* documentation says plainly that "overlays do not currently work correctly
|
||||||
|
* with SurfaceView or TextureView". On device that showed up as the WebView
|
||||||
|
* overlay silently dropping its incremental damage: the clock text stopped
|
||||||
|
* advancing on screen while the DOM kept updating (slider 476 → 479 across
|
||||||
|
* three seconds behind a display showing neither), the control bar would not
|
||||||
|
* fade, and rotation lost the transport UI. Only *structural* DOM changes
|
||||||
|
* got through, which is why the play overlay — an `{#if}` block that is added
|
||||||
|
* and removed — always appeared to work while the progress bar never did.
|
||||||
|
*
|
||||||
|
* A TextureView is an ordinary view: its frames are drawn as a texture inside
|
||||||
|
* the window's normal rendering pass, so there is no second layer, no
|
||||||
|
* transparent region, and the WebView above composites like it would over any
|
||||||
|
* other view. This is the standard remedy for ExoPlayer overlay problems and
|
||||||
|
* is why media3 offers `surface_type="texture_view"` at all.
|
||||||
|
*
|
||||||
|
* The cost is real and accepted: TextureView uses more power and memory than
|
||||||
|
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
|
||||||
|
* is unaffected — only presentation changes — so the reason native video
|
||||||
|
* exists survives the trade.
|
||||||
|
*
|
||||||
|
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
|
||||||
|
* there is deliberately no listener of ours here; adding one would displace
|
||||||
|
* it and the video would never appear.
|
||||||
|
*
|
||||||
|
* Note: the view is created but not attached to the hierarchy. Call
|
||||||
|
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-192
|
||||||
*/
|
*/
|
||||||
fun getOrCreateSurfaceView(): Int {
|
fun getOrCreateSurfaceView(): Int {
|
||||||
if (surfaceView == null) {
|
if (videoView == null) {
|
||||||
surfaceView = SurfaceView(appContext).apply {
|
videoView = TextureView(appContext).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
// Render BEHIND WebView - video shows through transparent areas
|
// The view is opaque where video is drawn; the WebView above it
|
||||||
setZOrderMediaOverlay(false)
|
// is what supplies transparency, exactly as before.
|
||||||
|
isOpaque = true
|
||||||
|
|
||||||
// Set up SurfaceHolder callbacks
|
// Own the listener rather than calling `setVideoTextureView`,
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
// which installs ExoPlayer's own and leaves us blind to frame
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
// arrival. `onSurfaceTextureUpdated` is the only honest signal
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface created")
|
// that a NEW frame has landed in the texture, and that is
|
||||||
surfaceHolder = holder
|
// precisely what the letterbox artefact waits on — see
|
||||||
exoPlayer.setVideoSurfaceHolder(holder)
|
// fitSurfaceToScreen. Handing ExoPlayer the Surface directly is
|
||||||
|
// the same wiring `setVideoTextureView` does internally.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-004 | DR-194
|
||||||
|
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||||
|
override fun onSurfaceTextureAvailable(
|
||||||
|
texture: android.graphics.SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
|
videoSurface?.release()
|
||||||
|
videoSurface = android.view.Surface(texture)
|
||||||
|
exoPlayer.setVideoSurface(videoSurface)
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
|
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}")
|
texture: android.graphics.SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface destroyed")
|
texture: android.graphics.SurfaceTexture
|
||||||
exoPlayer.clearVideoSurfaceHolder(holder)
|
): Boolean {
|
||||||
surfaceHolder = null
|
exoPlayer.setVideoSurface(null)
|
||||||
|
videoSurface?.release()
|
||||||
|
videoSurface = null
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
override fun onSurfaceTextureUpdated(
|
||||||
|
texture: android.graphics.SurfaceTexture
|
||||||
|
) {
|
||||||
|
// A genuinely new frame is now in the texture, so
|
||||||
|
// whatever was retained from before the resize is gone.
|
||||||
|
if (awaitingFreshFrame) {
|
||||||
|
awaitingFreshFrame = false
|
||||||
|
videoView?.alpha = 1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
|
||||||
}
|
}
|
||||||
return surfaceView!!.hashCode()
|
return videoView!!.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the SurfaceView instance (for VideoOverlayManager).
|
* Get the video view instance (for VideoOverlayManager).
|
||||||
* Returns null if no surface has been created yet.
|
* Returns null if none has been created yet.
|
||||||
*/
|
*/
|
||||||
fun getSurfaceView(): SurfaceView? {
|
fun getSurfaceView(): TextureView? {
|
||||||
return surfaceView
|
return videoView
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1138,7 +1214,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* This should be called from MainActivity when video playback is active.
|
* This should be called from MainActivity when video playback is active.
|
||||||
*/
|
*/
|
||||||
fun attachSurfaceToActivity(activity: android.app.Activity) {
|
fun attachSurfaceToActivity(activity: android.app.Activity) {
|
||||||
if (surfaceView != null && currentMediaType == MediaType.VIDEO) {
|
if (videoView != null && currentMediaType == MediaType.VIDEO) {
|
||||||
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
|
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
|
||||||
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
|
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
|
||||||
}
|
}
|
||||||
@@ -1182,9 +1258,34 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
||||||
* video to the surface bounds, which crops the bottom on rotation.
|
* video to the surface bounds, which crops the bottom on rotation.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Hide the video view now, and keep it hidden until a genuinely new frame
|
||||||
|
* arrives (or the timeout fires).
|
||||||
|
*
|
||||||
|
* Called from `MainActivity.onConfigurationChanged`, i.e. at the *start* of a
|
||||||
|
* rotation. [fitSurfaceToScreen] is too late for this: it runs from the
|
||||||
|
* content view's layout listener, after the rotation has already happened,
|
||||||
|
* so the stale frame has been on screen for the whole transition by then.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-066 | DR-194
|
||||||
|
*/
|
||||||
|
fun hideUntilFreshFrame() {
|
||||||
|
mainHandler.post {
|
||||||
|
val view = videoView ?: return@post
|
||||||
|
awaitingFreshFrame = true
|
||||||
|
view.alpha = 0f
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (awaitingFreshFrame) {
|
||||||
|
awaitingFreshFrame = false
|
||||||
|
videoView?.alpha = 1f
|
||||||
|
}
|
||||||
|
}, FRESH_FRAME_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fitSurfaceToScreen() {
|
fun fitSurfaceToScreen() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
val view = surfaceView ?: return@post
|
val view = videoView ?: return@post
|
||||||
val parent = view.parent as? ViewGroup
|
val parent = view.parent as? ViewGroup
|
||||||
// Available area: prefer the parent's measured size, fall back to the screen.
|
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||||
val availW = parent?.width?.takeIf { it > 0 }
|
val availW = parent?.width?.takeIf { it > 0 }
|
||||||
@@ -1216,6 +1317,44 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
if (lp is FrameLayout.LayoutParams) {
|
if (lp is FrameLayout.LayoutParams) {
|
||||||
lp.gravity = android.view.Gravity.CENTER
|
lp.gravity = android.view.Gravity.CENTER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide the view across a resize, and reveal it when a genuinely NEW
|
||||||
|
// video frame lands in the texture.
|
||||||
|
//
|
||||||
|
// A TextureView retains its last frame. Between a rotation and this
|
||||||
|
// re-fit landing, that retained frame is stretched across the OLD
|
||||||
|
// rect — larger than the new one along at least one axis — so the
|
||||||
|
// previous frame flashes in what should be the letterbox bars.
|
||||||
|
//
|
||||||
|
// Waiting a fixed number of animation frames does NOT fix it, which
|
||||||
|
// the first attempt at this proved on device: an animation frame is
|
||||||
|
// not a video frame, and at 24fps the next decoded frame can be
|
||||||
|
// several vsyncs away. The tell was that pausing and playing cleared
|
||||||
|
// the artefact by hand — that forces a fresh frame, which is the
|
||||||
|
// real precondition. So the reveal is driven by
|
||||||
|
// `onSurfaceTextureUpdated` instead.
|
||||||
|
//
|
||||||
|
// The timeout is not belt-and-braces, it is required: if playback is
|
||||||
|
// paused when the resize happens, no new frame is coming and the
|
||||||
|
// video would stay invisible forever. Revealing a stale frame after
|
||||||
|
// a beat is strictly better than a permanently black player.
|
||||||
|
//
|
||||||
|
// Scoped to an actual size change so steady-state playback never
|
||||||
|
// touches alpha.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-066 | DR-194
|
||||||
|
val sizeChanged = lp.width != targetW || lp.height != targetH
|
||||||
|
if (sizeChanged) {
|
||||||
|
awaitingFreshFrame = true
|
||||||
|
view.alpha = 0f
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
if (awaitingFreshFrame) {
|
||||||
|
awaitingFreshFrame = false
|
||||||
|
videoView?.alpha = 1f
|
||||||
|
}
|
||||||
|
}, FRESH_FRAME_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
lp.width = targetW
|
lp.width = targetW
|
||||||
lp.height = targetH
|
lp.height = targetH
|
||||||
view.layoutParams = lp
|
view.layoutParams = lp
|
||||||
@@ -1228,14 +1367,24 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the video surface when switching to audio playback.
|
* Clear the video surface when switching to audio playback, or on stop.
|
||||||
|
*
|
||||||
|
* Detaching is not optional bookkeeping: dropping the reference without
|
||||||
|
* removing the view left the SurfaceView parented to the content view for
|
||||||
|
* the life of the process, and the next video stacked another one under it.
|
||||||
|
* See VideoOverlayManager.detachVideoSurface.
|
||||||
|
*
|
||||||
|
* Always called on the main thread (every caller runs inside a
|
||||||
|
* `mainHandler.post`), which is what touching the view hierarchy requires.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-041 | DR-184
|
||||||
*/
|
*/
|
||||||
private fun clearVideoSurface() {
|
private fun clearVideoSurface() {
|
||||||
surfaceView?.let {
|
videoView?.let {
|
||||||
exoPlayer.clearVideoSurface()
|
exoPlayer.clearVideoSurface()
|
||||||
surfaceView = null
|
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||||
surfaceHolder = null
|
videoView = null
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
|
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ impl AuthManager {
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a username before it goes to the server.
|
||||||
|
///
|
||||||
|
/// Only surrounding whitespace is stripped — interior spaces are legal in
|
||||||
|
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
|
||||||
|
/// autocorrect makes the server report an unknown user, which surfaces as a
|
||||||
|
/// 401 that looks exactly like a wrong password.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-042 | DR-054
|
||||||
|
pub fn normalize_username(username: &str) -> String {
|
||||||
|
username.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to server and get server info
|
/// Connect to server and get server info
|
||||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
let normalized_url = Self::normalize_url(server_url)?;
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
@@ -185,6 +197,7 @@ impl AuthManager {
|
|||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
let url = Self::normalize_url(server_url)?;
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
let username = Self::normalize_username(username);
|
||||||
|
|
||||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
|
|
||||||
@@ -443,6 +456,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Usernames must be trimmed before they reach the server: the Android soft
|
||||||
|
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
|
||||||
|
/// reports an unknown user — a 401 indistinguishable from a wrong password.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_username_trims_whitespace() {
|
||||||
|
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
|
||||||
|
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interior spaces are legal in Jellyfin usernames and must survive.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_username_preserves_interior_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_username(" duncan tourolle "),
|
||||||
|
"duncan tourolle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test URL normalization - real world case
|
/// Test URL normalization - real world case
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_real_world_case() {
|
fn test_normalize_url_real_world_case() {
|
||||||
|
|||||||
@@ -520,15 +520,27 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
|||||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||||
|
///
|
||||||
|
/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
|
||||||
|
/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
|
||||||
|
/// it just created, so clicking download on one album cannot also start every
|
||||||
|
/// unrelated row that has been sitting pending.
|
||||||
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
target_dir: &str,
|
target_dir: &str,
|
||||||
|
only_ids: Option<&[i64]>,
|
||||||
resolve: F,
|
resolve: F,
|
||||||
) -> Result<ResumeQueuedResult, String>
|
) -> Result<ResumeQueuedResult, String>
|
||||||
where
|
where
|
||||||
F: Fn(String, String, String) -> Fut,
|
F: Fn(String, String, String) -> Fut,
|
||||||
Fut: std::future::Future<Output = Option<String>>,
|
Fut: std::future::Future<Output = Option<String>>,
|
||||||
{
|
{
|
||||||
|
if only_ids.is_some_and(|ids| ids.is_empty()) {
|
||||||
|
return Ok(ResumeQueuedResult {
|
||||||
|
resolved: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
// A row's own media_type wins; otherwise the *item's* type decides. Rows
|
||||||
// queued from a media card never carry one (`download_item` does not record
|
// queued from a media card never carry one (`download_item` does not record
|
||||||
// it), and defaulting that NULL to 'audio' resolved movies against
|
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||||
@@ -541,6 +553,16 @@ where
|
|||||||
.map(|t| format!("'{t}'"))
|
.map(|t| format!("'{t}'"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
let id_filter = match only_ids {
|
||||||
|
Some(ids) => format!(
|
||||||
|
" AND d.id IN ({})",
|
||||||
|
ids.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
let rows_query = Query::new(&format!(
|
let rows_query = Query::new(&format!(
|
||||||
"SELECT d.id, d.item_id,
|
"SELECT d.id, d.item_id,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
@@ -552,7 +574,7 @@ where
|
|||||||
COALESCE(d.quality_preset, 'original')
|
COALESCE(d.quality_preset, 'original')
|
||||||
FROM downloads d
|
FROM downloads d
|
||||||
LEFT JOIN items i ON i.id = d.item_id
|
LEFT JOIN items i ON i.id = d.item_id
|
||||||
WHERE d.status = 'pending' AND d.stream_url IS NULL"
|
WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
|
||||||
));
|
));
|
||||||
let rows: Vec<(i64, String, String, String)> = db_service
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
.query_many(rows_query, |row| {
|
.query_many(rows_query, |row| {
|
||||||
@@ -676,6 +698,7 @@ pub async fn resume_queued_downloads(
|
|||||||
let outcome = resolve_pending_download_urls(
|
let outcome = resolve_pending_download_urls(
|
||||||
&db_service,
|
&db_service,
|
||||||
&target_dir,
|
&target_dir,
|
||||||
|
None,
|
||||||
move |item_id: String, media_type: String, quality: String| {
|
move |item_id: String, media_type: String, quality: String| {
|
||||||
let repo = Arc::clone(&repo_for_resolve);
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
async move {
|
async move {
|
||||||
@@ -861,12 +884,14 @@ mod tests {
|
|||||||
// A completed row: irrelevant.
|
// A completed row: irrelevant.
|
||||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||||
|
|
||||||
let out =
|
let out = resolve_pending_download_urls(
|
||||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
&db,
|
||||||
Some(format!("http://resolved/{item_id}"))
|
"/data/downloads",
|
||||||
})
|
None,
|
||||||
.await
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||||
.unwrap();
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 1);
|
assert_eq!(out.resolved, 1);
|
||||||
assert_eq!(out.failed, 0);
|
assert_eq!(out.failed, 0);
|
||||||
@@ -882,15 +907,79 @@ mod tests {
|
|||||||
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bulk enqueue resolves only the rows it just created. Downloading one
|
||||||
|
/// album must not also start every unrelated row that has been sitting
|
||||||
|
/// pending with no URL (the smart cache leaves plenty of those).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_ids_restricts_the_sweep_to_the_given_rows() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "mine", "pending", None, Some("audio")).await;
|
||||||
|
insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
|
||||||
|
|
||||||
|
let mine: i64 = db
|
||||||
|
.query_one(
|
||||||
|
Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let out = resolve_pending_download_urls(
|
||||||
|
&db,
|
||||||
|
"/data",
|
||||||
|
Some(&[mine]),
|
||||||
|
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
assert_eq!(out.failed, 0);
|
||||||
|
|
||||||
|
let (_s, url, _t) = get_row(&db, "mine").await;
|
||||||
|
assert_eq!(url.as_deref(), Some("http://resolved/mine"));
|
||||||
|
|
||||||
|
let (status, other_url, _t) = get_row(&db, "someone-elses").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(
|
||||||
|
other_url, None,
|
||||||
|
"a scoped resolve must leave unrelated pending rows alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty id list resolves nothing — it must not fall through to "sweep
|
||||||
|
/// everything", which is what an unguarded `IN ()` would amount to.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_id_list_resolves_nothing() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "untouched", "pending", None, Some("audio")).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 0);
|
||||||
|
let (_s, url, _t) = get_row(&db, "untouched").await;
|
||||||
|
assert_eq!(url, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
insert_download(&db, "bad", "pending", None, None).await;
|
insert_download(&db, "bad", "pending", None, None).await;
|
||||||
|
|
||||||
// Resolver returns None (e.g. server lookup failed).
|
// Resolver returns None (e.g. server lookup failed).
|
||||||
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
let out =
|
||||||
.await
|
resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 0);
|
assert_eq!(out.resolved, 0);
|
||||||
assert_eq!(out.failed, 1);
|
assert_eq!(out.failed, 1);
|
||||||
@@ -920,7 +1009,7 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
seen.lock().unwrap().push((item_id.clone(), media_type));
|
seen.lock().unwrap().push((item_id.clone(), media_type));
|
||||||
@@ -953,7 +1042,7 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(String::new()));
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock().unwrap() = media_type;
|
*seen.lock().unwrap() = media_type;
|
||||||
@@ -977,7 +1066,7 @@ mod tests {
|
|||||||
|
|
||||||
let seen = Arc::new(Mutex::new(String::new()));
|
let seen = Arc::new(Mutex::new(String::new()));
|
||||||
let seen_c = Arc::clone(&seen);
|
let seen_c = Arc::clone(&seen);
|
||||||
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
|
resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
|
||||||
let seen = Arc::clone(&seen_c);
|
let seen = Arc::clone(&seen_c);
|
||||||
async move {
|
async move {
|
||||||
*seen.lock().unwrap() = media_type;
|
*seen.lock().unwrap() = media_type;
|
||||||
@@ -1037,13 +1126,17 @@ mod tests {
|
|||||||
let db = test_db();
|
let db = test_db();
|
||||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
let out =
|
let out = resolve_pending_download_urls(
|
||||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
&db,
|
||||||
|
"/data",
|
||||||
|
None,
|
||||||
|
|item_id, media_type, _q| async move {
|
||||||
assert_eq!(media_type, "video");
|
assert_eq!(media_type, "video");
|
||||||
Some(format!("http://transcode/{item_id}"))
|
Some(format!("http://transcode/{item_id}"))
|
||||||
})
|
},
|
||||||
.await
|
)
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(out.resolved, 1);
|
assert_eq!(out.resolved, 1);
|
||||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||||
|
|||||||
@@ -350,57 +350,209 @@ pub async fn download_item(
|
|||||||
Ok(download_id)
|
Ok(download_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue an entire album for download
|
/// One track of an album, as the album-download path queues it.
|
||||||
#[tauri::command]
|
///
|
||||||
#[specta::specta]
|
/// `artist_name` carries whatever the catalog holds for the track's artists (a
|
||||||
pub async fn download_album(
|
/// JSON array, as stored on `items.artists`); it is display metadata for the
|
||||||
db: State<'_, DatabaseWrapper>,
|
/// downloads list, not a lookup key.
|
||||||
album_id: String,
|
///
|
||||||
user_id: String,
|
/// TRACES: UR-018, UR-055 | DR-173
|
||||||
base_path: String,
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
) -> Result<Vec<i64>, String> {
|
pub(crate) struct AlbumTrack {
|
||||||
let db_service = {
|
pub id: String,
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
pub name: String,
|
||||||
Arc::new(database.service())
|
pub artist_name: Option<String>,
|
||||||
};
|
pub album_name: Option<String>,
|
||||||
|
pub index_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
// Get all tracks in the album with metadata
|
impl From<&crate::repository::types::MediaItem> for AlbumTrack {
|
||||||
|
fn from(item: &crate::repository::types::MediaItem) -> Self {
|
||||||
|
Self {
|
||||||
|
id: item.id.clone(),
|
||||||
|
name: item.name.clone(),
|
||||||
|
artist_name: item
|
||||||
|
.artists
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|a| serde_json::to_string(a).ok()),
|
||||||
|
album_name: item.album_name.clone(),
|
||||||
|
index_number: item.index_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album's tracks as the local catalog cache knows them.
|
||||||
|
///
|
||||||
|
/// Only a fallback for [`download_album`]: the cache links a track to its album
|
||||||
|
/// through `items.album_id`, which Jellyfin does not populate on every listing
|
||||||
|
/// endpoint, so this can legitimately return fewer tracks than the album has.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173
|
||||||
|
pub(crate) async fn cached_album_tracks(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
album_id: &str,
|
||||||
|
) -> Result<Vec<AlbumTrack>, String> {
|
||||||
let tracks_query = Query::with_params(
|
let tracks_query = Query::with_params(
|
||||||
"SELECT id, name, artists, album_name FROM items
|
"SELECT id, name, artists, album_name, index_number FROM items
|
||||||
WHERE album_id = ? AND item_type = 'Audio'
|
WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
|
||||||
ORDER BY index_number",
|
ORDER BY index_number",
|
||||||
vec![QueryParam::String(album_id)],
|
vec![
|
||||||
|
QueryParam::String(album_id.to_string()),
|
||||||
|
QueryParam::String(album_id.to_string()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service
|
db_service
|
||||||
.query_many(tracks_query, |row| {
|
.query_many(tracks_query, |row| {
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
Ok(AlbumTrack {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
artist_name: row.get(2)?,
|
||||||
|
album_name: row.get(3)?,
|
||||||
|
index_number: row.get(4)?,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
let mut download_ids = Vec::new();
|
/// Queue one download row per track and link every track to its album.
|
||||||
|
///
|
||||||
|
/// The linkage is the half that is easy to miss: offline browsing joins a track
|
||||||
|
/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
|
||||||
|
/// track whose cached row lacks it stays invisible under the album even after
|
||||||
|
/// its file is on disk. Queuing a track *is* the statement that it belongs to
|
||||||
|
/// this album, so the link is written here rather than hoped for from whichever
|
||||||
|
/// listing endpoint happened to cache the row.
|
||||||
|
///
|
||||||
|
/// Idempotent: re-queuing an album fills in what is missing and returns the same
|
||||||
|
/// row ids, in the order the tracks were given.
|
||||||
|
///
|
||||||
|
/// A file name per track, unique within the album.
|
||||||
|
///
|
||||||
|
/// A title is not a unique name inside its own album: a deluxe edition carries
|
||||||
|
/// the album version and a demo of the same song, and a two-disc set repeats
|
||||||
|
/// titles across discs. Naming files after the title alone gave those tracks one
|
||||||
|
/// path, and each download overwrote the previous one — an album that quietly
|
||||||
|
/// ends up short by however many titles it repeats. The track number
|
||||||
|
/// disambiguates the ordinary case; anything still colliding falls back to the
|
||||||
|
/// item id, which is unique by construction.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||||
|
pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
|
||||||
|
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||||
|
for track in tracks {
|
||||||
|
*counts.entry(track.name.to_lowercase()).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Queue each track with album priority (100) and metadata
|
tracks
|
||||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
.iter()
|
||||||
let file_path = format!("{}/{}.mp3", base_path, sanitize_filename(&track_name));
|
.map(|track| {
|
||||||
|
let title = sanitize_filename(&track.name);
|
||||||
|
if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
|
||||||
|
return format!("{}.mp3", title);
|
||||||
|
}
|
||||||
|
match track.index_number {
|
||||||
|
Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
|
||||||
|
None => format!("{} [{}].mp3", title, track.id),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
pub(crate) async fn queue_album_tracks(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
album_id: &str,
|
||||||
|
tracks: &[AlbumTrack],
|
||||||
|
user_id: &str,
|
||||||
|
base_path: &str,
|
||||||
|
) -> Result<Vec<i64>, String> {
|
||||||
|
let mut download_ids = Vec::with_capacity(tracks.len());
|
||||||
|
let file_names = album_file_names(tracks);
|
||||||
|
|
||||||
|
for (track, file_name) in tracks.iter().zip(file_names) {
|
||||||
|
// Cache a row for a track the catalog has never seen, borrowing the
|
||||||
|
// album's server. Nothing is inserted when the album itself is unknown,
|
||||||
|
// which also keeps the parent_id foreign key satisfiable.
|
||||||
|
let cache_query = Query::with_params(
|
||||||
|
"INSERT OR IGNORE INTO items
|
||||||
|
(id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
|
||||||
|
SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
|
||||||
|
FROM items a WHERE a.id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(track.id.clone()),
|
||||||
|
QueryParam::String(track.name.clone()),
|
||||||
|
track
|
||||||
|
.album_name
|
||||||
|
.clone()
|
||||||
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
track
|
||||||
|
.artist_name
|
||||||
|
.clone()
|
||||||
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
track
|
||||||
|
.index_number
|
||||||
|
.map(QueryParam::Int)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
QueryParam::String(album_id.to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db_service
|
||||||
|
.execute(cache_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Link an already-cached track to the album. The parent_id subquery
|
||||||
|
// resolves to NULL when the album is not cached, so the foreign key
|
||||||
|
// holds either way.
|
||||||
|
let link_query = Query::with_params(
|
||||||
|
"UPDATE items
|
||||||
|
SET album_id = ?,
|
||||||
|
parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
|
||||||
|
WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(album_id.to_string()),
|
||||||
|
QueryParam::String(album_id.to_string()),
|
||||||
|
QueryParam::String(track.id.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db_service
|
||||||
|
.execute(link_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let file_path = format!("{}/{}", base_path, file_name);
|
||||||
|
|
||||||
|
// Queue at album priority (100). A track already downloaded stays
|
||||||
|
// completed — re-queuing an album must fill the gaps, not re-fetch it.
|
||||||
let insert_query = Query::with_params(
|
let insert_query = Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name)
|
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
|
||||||
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?)
|
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
|
||||||
ON CONFLICT(item_id, user_id) DO UPDATE SET
|
ON CONFLICT(item_id, user_id) DO UPDATE SET
|
||||||
priority = 100,
|
priority = 100,
|
||||||
status = 'pending',
|
status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
|
||||||
|
media_type = 'audio',
|
||||||
item_name = COALESCE(excluded.item_name, downloads.item_name),
|
item_name = COALESCE(excluded.item_name, downloads.item_name),
|
||||||
artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
|
artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
|
||||||
album_name = COALESCE(excluded.album_name, downloads.album_name)",
|
album_name = COALESCE(excluded.album_name, downloads.album_name)",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(track_id.clone()),
|
QueryParam::String(track.id.clone()),
|
||||||
QueryParam::String(user_id.clone()),
|
QueryParam::String(user_id.to_string()),
|
||||||
QueryParam::String(file_path),
|
QueryParam::String(file_path),
|
||||||
QueryParam::String(track_name),
|
QueryParam::String(track.name.clone()),
|
||||||
artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
track
|
||||||
album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.artist_name
|
||||||
|
.clone()
|
||||||
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
track
|
||||||
|
.album_name
|
||||||
|
.clone()
|
||||||
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -413,8 +565,8 @@ pub async fn download_album(
|
|||||||
let id_query = Query::with_params(
|
let id_query = Query::with_params(
|
||||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(track_id),
|
QueryParam::String(track.id.clone()),
|
||||||
QueryParam::String(user_id.clone()),
|
QueryParam::String(user_id.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -428,6 +580,129 @@ pub async fn download_album(
|
|||||||
Ok(download_ids)
|
Ok(download_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queue an entire album for download.
|
||||||
|
///
|
||||||
|
/// Owns the whole operation: the album's track list comes from the server (the
|
||||||
|
/// only place that knows all of it), every track is queued and linked to its
|
||||||
|
/// album, each row's stream URL is resolved here, and the queue is pumped.
|
||||||
|
///
|
||||||
|
/// The frontend used to do the second half — resolve one URL per track and pair
|
||||||
|
/// it with the returned ids **by position**. That pairing had no basis: the ids
|
||||||
|
/// came back in the backend's own order over a different set of rows, so
|
||||||
|
/// whenever the two lists disagreed a row was handed another track's URL, and
|
||||||
|
/// any track past the end of the shorter list was never started at all. Nothing
|
||||||
|
/// crosses the boundary now except the album id.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn download_album(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
handle: String,
|
||||||
|
album_id: String,
|
||||||
|
user_id: String,
|
||||||
|
base_path: String,
|
||||||
|
) -> Result<Vec<i64>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle);
|
||||||
|
|
||||||
|
// Ask the server what the album contains; the cache is only a fallback for
|
||||||
|
// when it cannot answer.
|
||||||
|
let tracks: Vec<AlbumTrack> = match &repo {
|
||||||
|
Some(repo) => match repo.get_album_tracks(&album_id).await {
|
||||||
|
Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
|
||||||
|
Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[download_album] Could not list album {} from the repository ({:?}); \
|
||||||
|
falling back to the cached track list",
|
||||||
|
album_id, e
|
||||||
|
);
|
||||||
|
cached_album_tracks(&db_service, &album_id).await?
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => cached_album_tracks(&db_service, &album_id).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
if tracks.is_empty() {
|
||||||
|
warn!("[download_album] No tracks found for album {}", album_id);
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let download_ids =
|
||||||
|
queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[download_album] Queued {} track(s) for album {}",
|
||||||
|
download_ids.len(),
|
||||||
|
album_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve each queued row's stream URL here, then pump. Without a
|
||||||
|
// repository (or while offline) the rows stay pending with no URL and
|
||||||
|
// `resume_queued_downloads` picks them up on reconnect.
|
||||||
|
let Some(repo) = repo else {
|
||||||
|
return Ok(download_ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_dir = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
database
|
||||||
|
.path()
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo_for_resolve = Arc::clone(&repo);
|
||||||
|
let outcome = crate::commands::catalog::resolve_pending_download_urls(
|
||||||
|
&db_service,
|
||||||
|
&target_dir,
|
||||||
|
Some(&download_ids),
|
||||||
|
move |item_id: String, _media_type: String, _quality: String| {
|
||||||
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
|
async move {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[download_album] Failed to resolve stream URL for {}: {:?}",
|
||||||
|
item_id, e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if outcome.failed > 0 {
|
||||||
|
warn!(
|
||||||
|
"[download_album] {} track(s) could not be resolved and stay queued for the next \
|
||||||
|
reconnect",
|
||||||
|
outcome.failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
|
||||||
|
Ok(download_ids)
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue a video item (movie or episode) for download with quality preset
|
/// Queue a video item (movie or episode) for download with quality preset
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -2658,4 +2933,294 @@ mod tests {
|
|||||||
download_source: "user".to_string(),
|
download_source: "user".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Album download: track sourcing and album linkage =====
|
||||||
|
|
||||||
|
/// A database with just the tables the album-download path touches.
|
||||||
|
fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
item_type TEXT NOT NULL,
|
||||||
|
album_id TEXT,
|
||||||
|
album_name TEXT,
|
||||||
|
album_artist TEXT,
|
||||||
|
artists TEXT,
|
||||||
|
index_number INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
queued_at TEXT,
|
||||||
|
item_name TEXT,
|
||||||
|
artist_name TEXT,
|
||||||
|
album_name TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
stream_url TEXT,
|
||||||
|
target_dir TEXT,
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
INSERT INTO items (id, server_id, name, item_type)
|
||||||
|
VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
|
||||||
|
Mutex::new(conn),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
|
||||||
|
AlbumTrack {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
artist_name: Some("Woodkid".to_string()),
|
||||||
|
album_name: Some("The Golden Age".to_string()),
|
||||||
|
index_number: Some(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album-download regression: every track the album actually has must be
|
||||||
|
/// queued, and each queued track must be linked to its album.
|
||||||
|
///
|
||||||
|
/// `download_album` used to take its track list from
|
||||||
|
/// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
|
||||||
|
/// listing endpoint, so tracks cached from those endpoints sit in `items`
|
||||||
|
/// with a NULL `album_id` — invisible to that query. "Download album" then
|
||||||
|
/// silently queued only the subset that happened to carry the link, which is
|
||||||
|
/// the reported "only 4-5 songs downloaded". The same column is what offline
|
||||||
|
/// browsing joins tracks to their album on (`i.album_id = ?` in
|
||||||
|
/// `OfflineRepository::get_items`), so even a track that did download stayed
|
||||||
|
/// invisible under its album offline.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
|
||||||
|
let db = album_test_db();
|
||||||
|
|
||||||
|
// The cache holds all three tracks, but only one carries `album_id` —
|
||||||
|
// exactly the state the bug report's database is in.
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||||
|
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||||
|
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||||
|
VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
|
||||||
|
] {
|
||||||
|
db.execute(Query::new(sql)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks = vec![
|
||||||
|
album_track("t1", "Run Boy Run", 1),
|
||||||
|
album_track("t2", "The Great Escape", 2),
|
||||||
|
album_track("t3", "Boat Song", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ids.len(),
|
||||||
|
3,
|
||||||
|
"every track of the album must get a download row"
|
||||||
|
);
|
||||||
|
|
||||||
|
let queued: i64 = db
|
||||||
|
.query_one(
|
||||||
|
Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(queued, 3);
|
||||||
|
|
||||||
|
// Each track is now linked to its album, so the offline album page can
|
||||||
|
// find it once the download completes.
|
||||||
|
let linked: i64 = db
|
||||||
|
.query_one(
|
||||||
|
Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
linked, 3,
|
||||||
|
"queued tracks must be linked to their album; offline browsing joins on album_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The returned ids must line up with the tracks that were passed in. The
|
||||||
|
/// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
|
||||||
|
/// only sound if both lists agree — they did not, because the backend
|
||||||
|
/// ordered by `index_number` over a different set of rows. Resolving URLs in
|
||||||
|
/// Rust removes the pairing entirely, but the order is still the contract
|
||||||
|
/// for anything that reads the ids back.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_queue_album_tracks_returns_ids_in_track_order() {
|
||||||
|
let db = album_test_db();
|
||||||
|
let tracks = vec![
|
||||||
|
album_track("t1", "Run Boy Run", 1),
|
||||||
|
album_track("t2", "The Great Escape", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (id, track) in ids.iter().zip(tracks.iter()) {
|
||||||
|
let item_id: String = db
|
||||||
|
.query_one(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT item_id FROM downloads WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(*id)],
|
||||||
|
),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-queueing an album already partly downloaded must not duplicate rows or
|
||||||
|
/// reset a completed track — it fills in what is missing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_queue_album_tracks_is_idempotent() {
|
||||||
|
let db = album_test_db();
|
||||||
|
let tracks = vec![
|
||||||
|
album_track("t1", "Run Boy Run", 1),
|
||||||
|
album_track("t2", "The Great Escape", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first, second, "the same tracks must map to the same rows");
|
||||||
|
|
||||||
|
let rows: i64 = db
|
||||||
|
.query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two tracks of one album can share a title — a deluxe edition carrying the
|
||||||
|
/// album version and a demo of the same song, or the same song on two discs.
|
||||||
|
/// Naming the file after the title alone gave them one path, so the second
|
||||||
|
/// download overwrote the first and the album ended up short however many
|
||||||
|
/// duplicates it had.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||||
|
#[test]
|
||||||
|
fn test_album_file_names_are_unique_within_the_album() {
|
||||||
|
let tracks = vec![
|
||||||
|
album_track("t1", "Crucified Again", 5),
|
||||||
|
album_track("t2", "Crucified Again", 5),
|
||||||
|
album_track("t3", "Get Right", 7),
|
||||||
|
];
|
||||||
|
|
||||||
|
let names = album_file_names(&tracks);
|
||||||
|
|
||||||
|
assert_eq!(names.len(), 3);
|
||||||
|
let unique: std::collections::HashSet<_> = names.iter().collect();
|
||||||
|
assert_eq!(
|
||||||
|
unique.len(),
|
||||||
|
3,
|
||||||
|
"every track of an album needs its own file: {:?}",
|
||||||
|
names
|
||||||
|
);
|
||||||
|
assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
|
||||||
|
assert!(
|
||||||
|
names[2].contains("Get Right"),
|
||||||
|
"an unambiguous title keeps its name: {}",
|
||||||
|
names[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path separators in a track title must not escape the album directory.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
|
||||||
|
#[test]
|
||||||
|
fn test_album_file_names_sanitize_the_title() {
|
||||||
|
let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
|
||||||
|
assert!(!names[0].contains('/'), "{}", names[0]);
|
||||||
|
assert!(!names[0].contains(':'), "{}", names[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The offline fallback reads the catalog directly, not through the
|
||||||
|
/// availability-gated offline listing: queueing an album while the server is
|
||||||
|
/// unreachable is a supported flow (the rows resolve on reconnect), and
|
||||||
|
/// gating it on what is already downloaded would queue only the tracks the
|
||||||
|
/// device already has.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
|
||||||
|
let db = album_test_db();
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
|
||||||
|
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
|
||||||
|
// Linked by parent_id only — how a track cached from a folder
|
||||||
|
// listing lands in the catalog.
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
|
||||||
|
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
|
||||||
|
// A different album's track must not be swept in.
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, album_id) \
|
||||||
|
VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
|
||||||
|
] {
|
||||||
|
db.execute(Query::new(sql)).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks = cached_album_tracks(&db, "album1").await.unwrap();
|
||||||
|
let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["t1", "t2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the cache has never seen still get queued: the row is created and
|
||||||
|
/// an `items` row is written for it, so the download is both startable and
|
||||||
|
/// visible offline afterwards.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
|
||||||
|
let db = album_test_db();
|
||||||
|
let tracks = vec![album_track("never-cached", "Iron", 1)];
|
||||||
|
|
||||||
|
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ids.len(), 1);
|
||||||
|
|
||||||
|
let (item_type, album_id): (String, Option<String>) = db
|
||||||
|
.query_one(
|
||||||
|
Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(item_type, "Audio");
|
||||||
|
assert_eq!(album_id.as_deref(), Some("album1"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,6 +454,49 @@ pub(super) fn background_audio_source(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a background-audio handoff must start playback, given where its audio
|
||||||
|
/// actually begins.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
pub(super) struct BackgroundAudioPlan {
|
||||||
|
/// The position the stream's own zero corresponds to, recorded as the
|
||||||
|
/// handoff base so later readings can be shifted back to the episode's
|
||||||
|
/// timeline.
|
||||||
|
pub base_seconds: f64,
|
||||||
|
/// Where to seek after loading, if the source does not already start there.
|
||||||
|
pub seek_to: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide the base and the seek for a handoff at `position_seconds`.
|
||||||
|
///
|
||||||
|
/// The two sources start in different places. An audio-only **stream** is built
|
||||||
|
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
|
||||||
|
/// zero: the base is the handoff position, and seeking would skip *past* the
|
||||||
|
/// content by that much again. A downloaded **file** has no such parameter and
|
||||||
|
/// begins at the episode's own zero, so it needs the opposite — no base, and a
|
||||||
|
/// real seek. Treating a file like a stream is why backgrounding a downloaded
|
||||||
|
/// episode restarted it from 0:00 while the lockscreen showed the right time.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
pub(super) fn background_audio_plan(
|
||||||
|
is_local_file: bool,
|
||||||
|
position_seconds: f64,
|
||||||
|
) -> BackgroundAudioPlan {
|
||||||
|
let position = position_seconds.max(0.0);
|
||||||
|
|
||||||
|
if is_local_file {
|
||||||
|
BackgroundAudioPlan {
|
||||||
|
base_seconds: 0.0,
|
||||||
|
seek_to: (position > 0.0).then_some(position),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BackgroundAudioPlan {
|
||||||
|
base_seconds: position,
|
||||||
|
seek_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the on-disk file backing a completed download, if there is one.
|
/// Resolve the on-disk file backing a completed download, if there is one.
|
||||||
///
|
///
|
||||||
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
||||||
@@ -710,6 +753,9 @@ pub async fn player_enter_background_audio(
|
|||||||
item.id
|
item.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// A downloaded file starts at the episode's zero; a stream starts at the
|
||||||
|
// handoff point. Only one of them has a base, and only the other needs a seek.
|
||||||
|
let plan = background_audio_plan(local_path.is_some(), position_seconds);
|
||||||
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
let source = background_audio_source(local_path, item.stream_url, &item.id);
|
||||||
|
|
||||||
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
||||||
@@ -753,21 +799,28 @@ pub async fn player_enter_background_audio(
|
|||||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||||
// absolute, so shift the reported position back to absolute for the scrubber.
|
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
// Remember where the video was: the audio stream's zero == this position
|
// Remember where the video was: for a stream the audio's zero == this
|
||||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
// position (the URL was built with StartTimeTicks=position_seconds), so on
|
||||||
// this base to the native player's relative position to get the absolute one.
|
// exit we add this base to the native player's relative position to get the
|
||||||
// The controller owns it so a backend-driven advance to the next episode
|
// absolute one. The controller owns it so a backend-driven advance to the
|
||||||
// clears it along with the stream it described.
|
// next episode clears it along with the stream it described.
|
||||||
controller.enter_background_audio(position_seconds);
|
controller.enter_background_audio(plan.base_seconds);
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff
|
// Seek ONLY a local file. The audio-only URL already starts at the handoff
|
||||||
// position via StartTimeTicks; the stream's timeline begins at 0 == that
|
// position via StartTimeTicks — its timeline begins at 0 == that point — so
|
||||||
// point, so an extra seek(position_seconds) would jump PAST the content.
|
// seeking a stream would jump PAST the content by the handoff position again.
|
||||||
|
if let Some(seek_to) = plan.seek_to {
|
||||||
|
info!(
|
||||||
|
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
|
||||||
|
seek_to
|
||||||
|
);
|
||||||
|
controller.seek(seek_to).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
if let Some(emitter) = controller.event_emitter() {
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
@@ -801,7 +854,14 @@ pub async fn player_exit_background_audio(
|
|||||||
// moment it matters most. Capturing into a `let` before stop() is also the
|
// moment it matters most. Capturing into a `let` before stop() is also the
|
||||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||||
// (DR-159)
|
// (DR-159)
|
||||||
let absolute = controller.position();
|
//
|
||||||
|
// `absolute_position` rather than `position`, because a tick that has not
|
||||||
|
// landed *yet* is the same hazard from the other side: returning to the
|
||||||
|
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||||
|
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||||
|
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||||
|
// physically incapable of being behind its own starting point. (DR-178)
|
||||||
|
let absolute = controller.absolute_position();
|
||||||
|
|
||||||
// Now safe to tear the handoff down, native side first.
|
// Now safe to tear the handoff down, native side first.
|
||||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
@@ -1332,7 +1392,6 @@ pub async fn player_seek_video(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
Some(position),
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1343,6 +1402,13 @@ pub async fn player_seek_video(
|
|||||||
position
|
position
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// `seek_offset` carries the position to RESUME AT, not a base to add
|
||||||
|
// to the element's clock. The reloaded stream starts at the item's
|
||||||
|
// zero — a position on an HLS playlist makes the server 400 every
|
||||||
|
// segment behind it (DR-181) — so the adapter reaches the position by
|
||||||
|
// seeking the element and leaves the transcode offset at zero. The
|
||||||
|
// field keeps its name only because renaming it means regenerating
|
||||||
|
// the specta bindings; `reloadSource` documents the contract.
|
||||||
Ok(VideoSeekResponse::ReloadStream {
|
Ok(VideoSeekResponse::ReloadStream {
|
||||||
new_url,
|
new_url,
|
||||||
seek_offset: position,
|
seek_offset: position,
|
||||||
@@ -1356,7 +1422,6 @@ pub async fn player_seek_video(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
Some(position),
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1394,6 +1459,11 @@ pub async fn player_seek_video(
|
|||||||
} else {
|
} else {
|
||||||
return Err("No current item after URL update".to_string());
|
return Err("No current item after URL update".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The re-opened stream begins at zero — the position cannot ride
|
||||||
|
// along in the URL without 400ing every segment (DR-181) — so the
|
||||||
|
// seek that the reload was asked for happens here.
|
||||||
|
controller.seek(position).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -1444,12 +1514,13 @@ pub async fn player_switch_audio_track(
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get new stream URL with selected audio track
|
// Get new stream URL with selected audio track. It starts at zero — an
|
||||||
|
// HLS playlist cannot carry a position (DR-181) — and `position` below
|
||||||
|
// tells the frontend where to seek the reloaded element back to.
|
||||||
let new_url = repository
|
let new_url = repository
|
||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
current_position,
|
|
||||||
Some(stream_index),
|
Some(stream_index),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1540,7 +1611,6 @@ pub async fn player_set_stream_quality(
|
|||||||
.get_video_stream_url(
|
.get_video_stream_url(
|
||||||
&jellyfin_item_id,
|
&jellyfin_item_id,
|
||||||
media_source_id.as_deref(),
|
media_source_id.as_deref(),
|
||||||
current_position,
|
|
||||||
audio_stream_index,
|
audio_stream_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1552,8 +1622,9 @@ pub async fn player_set_stream_quality(
|
|||||||
|
|
||||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||||
// The URL already carries `StartTimeTicks`, so the reloaded stream begins at
|
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
|
||||||
// the current position rather than at zero.
|
// position without 400ing every segment — DR-181), so it is seeked back to
|
||||||
|
// where the picture was.
|
||||||
{
|
{
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
@@ -1571,6 +1642,9 @@ pub async fn player_set_stream_quality(
|
|||||||
controller
|
controller
|
||||||
.load_and_play(updated_item)
|
.load_and_play(updated_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if position > 0.0 {
|
||||||
|
controller.seek(position).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StreamQualityResponse::Native { position })
|
Ok(StreamQualityResponse::Native { position })
|
||||||
@@ -1846,7 +1920,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
|||||||
|
|
||||||
PlayerStatus {
|
PlayerStatus {
|
||||||
state: controller.state(),
|
state: controller.state(),
|
||||||
position: controller.position(),
|
// The position on the item's timeline, whichever of the three paths is
|
||||||
|
// rendering it — the native backend answers for only one of them, and
|
||||||
|
// reads 0 for webview video and for a handoff that has not ticked yet.
|
||||||
|
// TRACES: UR-005 | DR-178
|
||||||
|
position: controller.absolute_position(),
|
||||||
duration: controller.duration(),
|
duration: controller.duration(),
|
||||||
volume: controller.volume(),
|
volume: controller.volume(),
|
||||||
muted: controller.muted(),
|
muted: controller.muted(),
|
||||||
@@ -2838,6 +2916,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two sources start in different places, so the handoff cannot treat
|
||||||
|
/// them alike.
|
||||||
|
///
|
||||||
|
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
|
||||||
|
/// the handoff point that stream's zero: the base is the handoff position and
|
||||||
|
/// seeking would jump past the content. A *downloaded file* has no such
|
||||||
|
/// parameter — it starts at the episode's own zero — so basing it at the
|
||||||
|
/// handoff position claims 18 minutes of audio that is about to play from the
|
||||||
|
/// beginning. That is the downloaded-episode version of "it restarts when the
|
||||||
|
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
|
||||||
|
use super::background_audio_plan;
|
||||||
|
|
||||||
|
let local = background_audio_plan(true, 1104.0);
|
||||||
|
assert_eq!(local.base_seconds, 0.0);
|
||||||
|
assert_eq!(local.seek_to, Some(1104.0));
|
||||||
|
|
||||||
|
let streamed = background_audio_plan(false, 1104.0);
|
||||||
|
assert_eq!(streamed.base_seconds, 1104.0);
|
||||||
|
assert_eq!(
|
||||||
|
streamed.seek_to, None,
|
||||||
|
"the URL already starts at the handoff point; seeking again skips past it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handing off at the very start has nothing to seek to and nothing to base:
|
||||||
|
/// both sources are already where they need to be.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
||||||
|
#[test]
|
||||||
|
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
|
||||||
|
use super::background_audio_plan;
|
||||||
|
|
||||||
|
for local in [true, false] {
|
||||||
|
let plan = background_audio_plan(local, 0.0);
|
||||||
|
assert_eq!(plan.base_seconds, 0.0);
|
||||||
|
assert_eq!(plan.seek_to, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
/// A downloaded item must resolve to its file, and a `downloads` row whose
|
||||||
/// file has gone must resolve to `None` so the caller falls back to
|
/// file has gone must resolve to `None` so the caller falls back to
|
||||||
/// streaming instead of handing the player a path that cannot be opened.
|
/// streaming instead of handing the player a path that cannot be opened.
|
||||||
|
|||||||
@@ -571,7 +571,13 @@ pub async fn repository_get_playback_info(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get video stream URL with optional seeking support
|
/// Get a video stream URL.
|
||||||
|
///
|
||||||
|
/// There is no start-position parameter on purpose: the URL is an HLS playlist
|
||||||
|
/// covering the whole item, and a position on it makes the server reject every
|
||||||
|
/// segment with `400` (DR-181). Callers resume by seeking after load.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-181 | UT-182
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_get_video_stream_url(
|
pub async fn repository_get_video_stream_url(
|
||||||
@@ -579,17 +585,11 @@ pub async fn repository_get_video_stream_url(
|
|||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
start_time_seconds: Option<f64>,
|
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
repo.as_ref()
|
repo.as_ref()
|
||||||
.get_video_stream_url(
|
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
|
||||||
&item_id,
|
|
||||||
media_source_id.as_deref(),
|
|
||||||
start_time_seconds,
|
|
||||||
audio_stream_index,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|||||||
+781
-109
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,18 @@ impl ObservedTime {
|
|||||||
live.filter(|p| *p >= 0.0).unwrap_or(self.position)
|
live.filter(|p| *p >= 0.0).unwrap_or(self.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last observed position, with no live reading to prefer — the case
|
||||||
|
/// where the *reporter* is the only source there is (webview-rendered media,
|
||||||
|
/// which the native backend cannot see at all).
|
||||||
|
pub fn last_position(&self) -> f64 {
|
||||||
|
self.position
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last observed duration, if one was ever established.
|
||||||
|
pub fn last_duration(&self) -> Option<f64> {
|
||||||
|
self.duration
|
||||||
|
}
|
||||||
|
|
||||||
/// The live reading if there is one, else the last observed value.
|
/// The live reading if there is one, else the last observed value.
|
||||||
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
||||||
live.filter(|d| *d > 0.0).or(self.duration)
|
live.filter(|d| *d > 0.0).or(self.duration)
|
||||||
|
|||||||
@@ -61,6 +61,131 @@ const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
|||||||
/// it rather than give up.
|
/// it rather than give up.
|
||||||
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
||||||
|
|
||||||
|
/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
|
||||||
|
///
|
||||||
|
/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
|
||||||
|
/// the source's default/forced flags and picks a track itself. See
|
||||||
|
/// [`playback_subtitle_stream_index`] for why that is never what we want.
|
||||||
|
pub const NO_SUBTITLE_STREAM: i32 = -1;
|
||||||
|
|
||||||
|
/// Subtitle formats we can render ourselves, delivered as an external sidecar
|
||||||
|
/// track rather than painted into the video.
|
||||||
|
///
|
||||||
|
/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
|
||||||
|
/// deliberately absent: they are bitmaps, so the only way a server can show them
|
||||||
|
/// on a client that cannot composite them is to burn them into the picture.
|
||||||
|
const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
|
||||||
|
|
||||||
|
/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
|
||||||
|
///
|
||||||
|
/// All `External`: the app fetches subtitle tracks itself and renders them over
|
||||||
|
/// the video (UR-020), so it never needs the server to composite them.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
|
||||||
|
EXTERNAL_SUBTITLE_FORMATS
|
||||||
|
.iter()
|
||||||
|
.map(|format| (*format, "External"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether asking the server to serve this subtitle codec forces it to burn the
|
||||||
|
/// subtitle into the picture.
|
||||||
|
///
|
||||||
|
/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
|
||||||
|
/// the video stream, so a source we would otherwise have passed through untouched
|
||||||
|
/// gets fully re-encoded frame by frame.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
pub fn subtitle_forces_burn_in(codec: &str) -> bool {
|
||||||
|
!EXTERNAL_SUBTITLE_FORMATS
|
||||||
|
.iter()
|
||||||
|
.any(|format| format.eq_ignore_ascii_case(codec.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `SubtitleStreamIndex` to negotiate with: always "none".
|
||||||
|
///
|
||||||
|
/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
|
||||||
|
/// subtitle track. Sending no index let the server honour that default, and since
|
||||||
|
/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
|
||||||
|
/// That turned an audio-only transcode (the HEVC video was directly supported)
|
||||||
|
/// into a full HEVC→h264 re-encode, which the server could not sustain in real
|
||||||
|
/// time: the buffer never grew beyond one segment and playback stalled every few
|
||||||
|
/// seconds, taking seeking down with it.
|
||||||
|
///
|
||||||
|
/// Asking for no subtitle stream costs nothing, because the app never wanted the
|
||||||
|
/// server's composited version — it fetches the text tracks separately and
|
||||||
|
/// renders them itself (UR-020).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
pub fn playback_subtitle_stream_index() -> i32 {
|
||||||
|
NO_SUBTITLE_STREAM
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query keys through which a stream URL can carry a subtitle decision.
|
||||||
|
///
|
||||||
|
/// Jellyfin binds query keys case-insensitively, so the match has to be too —
|
||||||
|
/// the server itself mixes casing (`SubtitleStreamIndex` but
|
||||||
|
/// `alwaysBurnInSubtitleWhenTranscoding`).
|
||||||
|
const SUBTITLE_QUERY_KEYS: &[&str] = &[
|
||||||
|
"subtitlestreamindex",
|
||||||
|
"subtitlemethod",
|
||||||
|
"subtitlecodec",
|
||||||
|
"alwaysburninsubtitlewhentranscoding",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
|
||||||
|
///
|
||||||
|
/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
|
||||||
|
/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
|
||||||
|
/// server from its own subtitle verdict, and we play it verbatim — so a server
|
||||||
|
/// that picked a track anyway (a live channel opened without an index, a source
|
||||||
|
/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
|
||||||
|
/// burn-in the negotiation just declined comes back through the URL. Burn-in is
|
||||||
|
/// a *video* cost: it rules out remuxing and forces a full re-encode.
|
||||||
|
///
|
||||||
|
/// Stripping the keys is not enough on its own — an absent index is not "none",
|
||||||
|
/// it is "you choose" — so the sentinel is always appended.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
pub fn without_server_chosen_subtitle(url: &str) -> String {
|
||||||
|
let (path, query) = match url.split_once('?') {
|
||||||
|
Some((path, query)) => (path, query),
|
||||||
|
None => (url, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut kept: Vec<&str> = query
|
||||||
|
.split('&')
|
||||||
|
.filter(|param| !param.is_empty())
|
||||||
|
.filter(|param| {
|
||||||
|
let key = param.split_once('=').map_or(*param, |(key, _)| key);
|
||||||
|
!SUBTITLE_QUERY_KEYS
|
||||||
|
.iter()
|
||||||
|
.any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
|
||||||
|
kept.push(&sentinel);
|
||||||
|
|
||||||
|
format!("{}?{}", path, kept.join("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a subtitle in this format can reach the app as a sidecar it draws
|
||||||
|
/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
|
||||||
|
/// side, and the one a subtitle picker needs.
|
||||||
|
///
|
||||||
|
/// Since the app asks for burn-in nowhere (see
|
||||||
|
/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
|
||||||
|
/// is one it can never display. An unnamed format is treated as undeliverable
|
||||||
|
/// rather than guessed at: offering a track and drawing nothing is worse than
|
||||||
|
/// not offering it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
||||||
|
codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
|
||||||
|
}
|
||||||
|
|
||||||
/// Narrow a detected audio-codec list to what the renderer that will actually
|
/// Narrow a detected audio-codec list to what the renderer that will actually
|
||||||
/// play the **video** can decode.
|
/// play the **video** can decode.
|
||||||
///
|
///
|
||||||
@@ -162,6 +287,118 @@ pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The reported bug, at the level it was decided: a source whose default
|
||||||
|
/// subtitle track is PGSSUB must not drag the video into a re-encode.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
|
||||||
|
assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
|
||||||
|
// Not `None`/omitted: that is what let the server pick the PGS track.
|
||||||
|
assert_eq!(playback_subtitle_stream_index(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transcode URL the *server* built carries the server's own subtitle
|
||||||
|
/// verdict. Adopting it verbatim re-introduces the burn-in
|
||||||
|
/// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
|
||||||
|
/// asks for no subtitle, and then we play a URL that asks for one anyway.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn a_server_built_transcode_url_has_its_burn_in_stripped() {
|
||||||
|
// Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
|
||||||
|
// `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
|
||||||
|
let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
|
||||||
|
&VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
|
||||||
|
&PlaySessionId=xyz";
|
||||||
|
|
||||||
|
let url = without_server_chosen_subtitle(served);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
url.contains("SubtitleStreamIndex=-1"),
|
||||||
|
"the adopted URL must ask for no subtitle: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("SubtitleStreamIndex=2"),
|
||||||
|
"the server's chosen track must not survive: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("SubtitleMethod"),
|
||||||
|
"burn-in must not be requested: {url}"
|
||||||
|
);
|
||||||
|
// Everything else identifies the job and must survive untouched.
|
||||||
|
for kept in [
|
||||||
|
"DeviceId=jt",
|
||||||
|
"MediaSourceId=src1",
|
||||||
|
"VideoCodec=h264",
|
||||||
|
"PlaySessionId=xyz",
|
||||||
|
] {
|
||||||
|
assert!(url.contains(kept), "{kept} must survive: {url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server may also be told to burn in unconditionally
|
||||||
|
/// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
|
||||||
|
/// rather than expressed as a method — and its keys are not PascalCase.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
||||||
|
let url = without_server_chosen_subtitle(
|
||||||
|
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
||||||
|
&subtitlestreamindex=3&SubtitleCodec=ass",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
|
||||||
|
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
||||||
|
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
||||||
|
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
||||||
|
assert!(url.contains("api_key=k"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A URL the server built without any subtitle in it still has to *say* so:
|
||||||
|
/// omitting the index is what makes the server apply the source's default.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
||||||
|
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A bare URL is rare but must not come out malformed.
|
||||||
|
let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
|
||||||
|
assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn text_subtitles_are_advertised_as_external_sidecars() {
|
||||||
|
let profiles = subtitle_profiles();
|
||||||
|
for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
|
||||||
|
let entry = profiles.iter().find(|(f, _)| *f == format);
|
||||||
|
assert!(
|
||||||
|
entry.is_some(),
|
||||||
|
"{format} must be advertised or the server burns it into the picture"
|
||||||
|
);
|
||||||
|
assert_eq!(entry.unwrap().1, "External");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn text_subtitles_never_force_burn_in_but_image_ones_do() {
|
||||||
|
// Text: deliverable as a sidecar, so the video can still be remuxed.
|
||||||
|
assert!(!subtitle_forces_burn_in("subrip"));
|
||||||
|
assert!(!subtitle_forces_burn_in("ASS"));
|
||||||
|
assert!(!subtitle_forces_burn_in("ssa"));
|
||||||
|
// Image formats are bitmaps — the server can only composite them.
|
||||||
|
assert!(subtitle_forces_burn_in("PGSSUB"));
|
||||||
|
assert!(subtitle_forces_burn_in("dvdsub"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_undecodable_default_track_forces_a_transcode() {
|
fn an_undecodable_default_track_forces_a_transcode() {
|
||||||
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||||
|
|||||||
@@ -79,22 +79,20 @@ impl HybridRepository {
|
|||||||
self.online.get_jray_actors(item_id, t).await
|
self.online.get_jray_actors(item_id, t).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get video stream URL with optional seeking support.
|
/// Get video stream URL. This method is online-only since offline playback
|
||||||
/// This method is online-only since offline playback uses local file paths.
|
/// uses local file paths.
|
||||||
|
///
|
||||||
|
/// Takes no start position: the URL is an HLS playlist spanning the whole
|
||||||
|
/// item, and a position on it would 400 every segment — see
|
||||||
|
/// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
|
||||||
pub async fn get_video_stream_url(
|
pub async fn get_video_stream_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
start_time_seconds: Option<f64>,
|
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
) -> Result<String, RepoError> {
|
) -> Result<String, RepoError> {
|
||||||
self.online
|
self.online
|
||||||
.get_video_stream_url(
|
.get_video_stream_url(item_id, media_source_id, audio_stream_index)
|
||||||
item_id,
|
|
||||||
media_source_id,
|
|
||||||
start_time_seconds,
|
|
||||||
audio_stream_index,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +117,40 @@ impl HybridRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every track of an album, asked of the **server** rather than the cache.
|
||||||
|
///
|
||||||
|
/// Deliberately not `get_items`, which is cache-first: it answers from SQLite
|
||||||
|
/// the moment the cache has any content. That is right for browsing and wrong
|
||||||
|
/// for deciding what to download, because a partial or unlinked cache then
|
||||||
|
/// decides how much of the album gets queued while the user is told the whole
|
||||||
|
/// album is downloading. Downloading is the one operation that must know the
|
||||||
|
/// album's *complete* contents.
|
||||||
|
///
|
||||||
|
/// Errors when the server cannot answer (offline); the caller falls back to
|
||||||
|
/// the local catalog and the rows are queued either way, resolving on
|
||||||
|
/// reconnect. Server results are written back to the cache, so browsing
|
||||||
|
/// benefits from the round trip too.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-018, UR-055 | DR-173
|
||||||
|
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
let options = Some(GetItemsOptions {
|
||||||
|
include_item_types: Some(vec!["Audio".to_string()]),
|
||||||
|
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
|
||||||
|
limit: Some(1000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = self.online.get_items(album_id, options).await?;
|
||||||
|
|
||||||
|
if !result.items.is_empty() {
|
||||||
|
if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
|
||||||
|
warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result.items)
|
||||||
|
}
|
||||||
|
|
||||||
/// Search only the local SQLite cache (downloaded content).
|
/// Search only the local SQLite cache (downloaded content).
|
||||||
///
|
///
|
||||||
/// Fast (100ms timeout) — used to render instant results before the server
|
/// Fast (100ms timeout) — used to render instant results before the server
|
||||||
|
|||||||
@@ -1014,14 +1014,13 @@ impl OfflineRepository {
|
|||||||
|
|
||||||
self.db_service
|
self.db_service
|
||||||
.query_many(query, |row| {
|
.query_many(query, |row| {
|
||||||
Ok(Library {
|
Ok(Library::new(
|
||||||
id: row.get(0)?,
|
row.get(0)?,
|
||||||
name: row.get(1)?,
|
row.get(1)?,
|
||||||
collection_type: row
|
row.get::<_, Option<String>>(2)?
|
||||||
.get::<_, Option<String>>(2)?
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
image_tag: row.get(3)?,
|
row.get(3)?,
|
||||||
})
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepoError::Database { message: e })
|
.map_err(|e| RepoError::Database { message: e })
|
||||||
@@ -1214,14 +1213,13 @@ impl MediaRepository for OfflineRepository {
|
|||||||
|
|
||||||
self.db_service
|
self.db_service
|
||||||
.query_many(query, |row| {
|
.query_many(query, |row| {
|
||||||
Ok(Library {
|
Ok(Library::new(
|
||||||
id: row.get(0)?,
|
row.get(0)?,
|
||||||
name: row.get(1)?,
|
row.get(1)?,
|
||||||
collection_type: row
|
row.get::<_, Option<String>>(2)?
|
||||||
.get::<_, Option<String>>(2)?
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
image_tag: row.get(3)?,
|
row.get(3)?,
|
||||||
})
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepoError::Database { message: e })
|
.map_err(|e| RepoError::Database { message: e })
|
||||||
@@ -3466,18 +3464,13 @@ mod tests {
|
|||||||
|
|
||||||
// Simulate the online path persisting the server's library list.
|
// Simulate the online path persisting the server's library list.
|
||||||
let server_libs = vec![
|
let server_libs = vec![
|
||||||
Library {
|
Library::new("music".into(), "Music".into(), "music".into(), None),
|
||||||
id: "music".into(),
|
Library::new(
|
||||||
name: "Music".into(),
|
"movies".into(),
|
||||||
collection_type: "music".into(),
|
"Movies".into(),
|
||||||
image_tag: None,
|
"movies".into(),
|
||||||
},
|
Some("tag".into()),
|
||||||
Library {
|
),
|
||||||
id: "movies".into(),
|
|
||||||
name: "Movies".into(),
|
|
||||||
collection_type: "movies".into(),
|
|
||||||
image_tag: Some("tag".into()),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
|
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
|
||||||
assert_eq!(saved, 2);
|
assert_eq!(saved, 2);
|
||||||
|
|||||||
@@ -44,6 +44,53 @@ pub fn streaming_quality() -> StreamingQuality {
|
|||||||
*STREAMING_QUALITY.read_safe()
|
*STREAMING_QUALITY.read_safe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every request this app makes identifies the same device, so the device id
|
||||||
|
/// alone cannot tell two transcodes of the same item apart — see
|
||||||
|
/// [`begin_video_play_session`].
|
||||||
|
const DEVICE_ID: &str = "jellytau-tauri";
|
||||||
|
|
||||||
|
/// The `PlaySessionId` of the video transcode most recently opened, so the next
|
||||||
|
/// open can stop it.
|
||||||
|
///
|
||||||
|
/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
|
||||||
|
/// *this device* currently has running on the server, and must survive the
|
||||||
|
/// repository being rebuilt on re-login.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-162
|
||||||
|
static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Claim a transcode identity for a stream about to be opened, returning the new
|
||||||
|
/// `PlaySessionId` and the one it replaces (if any).
|
||||||
|
///
|
||||||
|
/// Jellyfin keys a transcode job by device *and* play session. Without a session
|
||||||
|
/// id every open of the same item on this device looked like the same job, so
|
||||||
|
/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
|
||||||
|
/// switch — left the old ffmpeg running and the server intermittently rejected
|
||||||
|
/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
|
||||||
|
/// fought over one transcode path. The caller stops the returned previous
|
||||||
|
/// session before the new stream's segments are fetched.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-177 | UT-173
|
||||||
|
pub fn begin_video_play_session() -> (String, Option<String>) {
|
||||||
|
let new_session = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mut current = VIDEO_PLAY_SESSION.write_safe();
|
||||||
|
let previous = current.replace(new_session.clone());
|
||||||
|
(new_session, previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take ownership of a transcode this process did not build a URL for, returning
|
||||||
|
/// the session it replaces.
|
||||||
|
///
|
||||||
|
/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
|
||||||
|
/// started the job and named the session; that id is the only handle on it we
|
||||||
|
/// will ever have. Without adopting it, the first re-open of that stream has no
|
||||||
|
/// previous session to stop and collides with the very job that was playing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-177 | UT-173
|
||||||
|
pub fn adopt_video_play_session(session_id: String) -> Option<String> {
|
||||||
|
VIDEO_PLAY_SESSION.write_safe().replace(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
|
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
|
||||||
///
|
///
|
||||||
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
|
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
|
||||||
@@ -405,33 +452,81 @@ impl OnlineRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a video stream URL for playback at an arbitrary position (resume,
|
/// Ask the server to tear down a transcode this device started.
|
||||||
/// transcoded seeking, audio-track switching).
|
///
|
||||||
|
/// Best-effort and deliberately un-retried: it runs on the path that opens a
|
||||||
|
/// replacement stream, so a slow or failed stop must not delay playback. The
|
||||||
|
/// worst case if it does fail is the job Jellyfin would have reaped on its
|
||||||
|
/// own idle timer anyway — the new stream still has its own session id, so it
|
||||||
|
/// no longer collides with the old one.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-177
|
||||||
|
async fn stop_transcode(&self, play_session_id: &str) {
|
||||||
|
let url = format!(
|
||||||
|
"{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
|
||||||
|
self.server_url, DEVICE_ID, play_session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = self
|
||||||
|
.http_client
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
|
.send();
|
||||||
|
|
||||||
|
match request.await {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
debug!("[Transcode] Stopped previous encoding {}", play_session_id);
|
||||||
|
}
|
||||||
|
Ok(response) => {
|
||||||
|
debug!(
|
||||||
|
"[Transcode] Server declined to stop encoding {}: HTTP {}",
|
||||||
|
play_session_id,
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(
|
||||||
|
"[Transcode] Could not stop encoding {}: {}",
|
||||||
|
play_session_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a video stream URL (initial play, resume, transcoded seeking,
|
||||||
|
/// audio-track switching).
|
||||||
///
|
///
|
||||||
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
|
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
|
||||||
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
|
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
|
||||||
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
|
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
|
||||||
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
|
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
|
||||||
/// forces the server to transcode the whole file before playback can begin —
|
/// forces the server to transcode the whole file before playback can begin —
|
||||||
/// which manifests as playback never starting. `StartTimeTicks` makes the
|
/// which manifests as playback never starting.
|
||||||
/// server begin the transcode at the requested position.
|
///
|
||||||
|
/// **There is deliberately no start-position parameter.** A playlist covers
|
||||||
|
/// the whole item and asking for segment N *is* the seek, so a position would
|
||||||
|
/// be redundant — and actively fatal: Jellyfin builds every segment URI by
|
||||||
|
/// echoing this playlist's query string into it, while its segment handler
|
||||||
|
/// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
|
||||||
|
/// resume position here therefore 400s every segment of the stream, which
|
||||||
|
/// presents as a resumed episode that simply never plays while the same
|
||||||
|
/// episode from the beginning is fine. Resume by seeking the player once it
|
||||||
|
/// has loaded. (The progressive `/Audio/universal` builder below has no
|
||||||
|
/// segments and keeps its `StartTimeTicks`.)
|
||||||
///
|
///
|
||||||
/// The stream is built against the current [`streaming_quality`] ceiling:
|
/// The stream is built against the current [`streaming_quality`] ceiling:
|
||||||
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
|
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
|
||||||
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
|
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
|
||||||
/// allowance, which is a transcode ceiling rather than a user-facing limit.
|
/// allowance, which is a transcode ceiling rather than a user-facing limit.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-004, UR-074 | DR-140, DR-162 | UT-130, UT-156
|
/// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
|
||||||
pub async fn get_video_stream_url(
|
pub async fn get_video_stream_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
start_time_seconds: Option<f64>,
|
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
) -> Result<String, RepoError> {
|
) -> Result<String, RepoError> {
|
||||||
// Convert seconds to ticks (10,000,000 ticks per second)
|
|
||||||
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
|
|
||||||
|
|
||||||
let quality = streaming_quality();
|
let quality = streaming_quality();
|
||||||
// `Original` is uncapped as a *user* setting, but a transcode still needs
|
// `Original` is uncapped as a *user* setting, but a transcode still needs
|
||||||
// a ceiling to encode against — keep the values this endpoint has always
|
// a ceiling to encode against — keep the values this endpoint has always
|
||||||
@@ -439,11 +534,21 @@ impl OnlineRepository {
|
|||||||
let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
|
let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
|
||||||
let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
|
let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
|
||||||
|
|
||||||
|
// Claim a distinct transcode identity and retire the one it replaces, so
|
||||||
|
// the server is never running two jobs for this device at once. Doing it
|
||||||
|
// here covers every path that re-opens a stream (quality switch,
|
||||||
|
// transcoded seek, audio-track switch) rather than each remembering to.
|
||||||
|
let (play_session_id, superseded) = begin_video_play_session();
|
||||||
|
if let Some(previous) = superseded {
|
||||||
|
self.stop_transcode(&previous).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
|
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
|
||||||
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
("api_key", self.access_token.clone()),
|
("api_key", self.access_token.clone()),
|
||||||
("DeviceId", "jellytau-tauri".to_string()),
|
("DeviceId", DEVICE_ID.to_string()),
|
||||||
|
("PlaySessionId", play_session_id),
|
||||||
("VideoCodec", "h264".to_string()),
|
("VideoCodec", "h264".to_string()),
|
||||||
("AudioCodec", "aac".to_string()),
|
("AudioCodec", "aac".to_string()),
|
||||||
("MaxStreamingBitrate", max_bitrate.to_string()),
|
("MaxStreamingBitrate", max_bitrate.to_string()),
|
||||||
@@ -456,6 +561,18 @@ impl OnlineRepository {
|
|||||||
("SegmentContainer", "ts".to_string()),
|
("SegmentContainer", "ts".to_string()),
|
||||||
("TranscodingContainer", "ts".to_string()),
|
("TranscodingContainer", "ts".to_string()),
|
||||||
("TranscodingProtocol", "hls".to_string()),
|
("TranscodingProtocol", "hls".to_string()),
|
||||||
|
// Say "no subtitle" rather than leaving the choice open. An omitted
|
||||||
|
// index is not neutral: the server then picks the source's own
|
||||||
|
// default/forced track, and an image-based one can only be delivered
|
||||||
|
// by burning it into the picture (DR-176). The negotiation already
|
||||||
|
// sends this sentinel, but most streams are opened by rebuilding
|
||||||
|
// *this* URL — a quality switch, a transcoded seek, an audio-track
|
||||||
|
// switch — so it has to hold here too, independently of whatever
|
||||||
|
// session state the server still holds.
|
||||||
|
(
|
||||||
|
"SubtitleStreamIndex",
|
||||||
|
super::device_profile::playback_subtitle_stream_index().to_string(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Scale the picture down to what the budget can carry. Omitted for the
|
// Scale the picture down to what the budget can carry. Omitted for the
|
||||||
@@ -478,10 +595,6 @@ impl OnlineRepository {
|
|||||||
params.push(("MediaSourceId", source_id.to_string()));
|
params.push(("MediaSourceId", source_id.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ticks) = start_time_ticks {
|
|
||||||
params.push(("StartTimeTicks", ticks.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build query string (values are already safe, no encoding needed)
|
// Build query string (values are already safe, no encoding needed)
|
||||||
let query = params
|
let query = params
|
||||||
.iter()
|
.iter()
|
||||||
@@ -528,7 +641,7 @@ impl OnlineRepository {
|
|||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
("UserId", self.user_id.clone()),
|
("UserId", self.user_id.clone()),
|
||||||
("api_key", self.access_token.clone()),
|
("api_key", self.access_token.clone()),
|
||||||
("DeviceId", "jellytau-tauri".to_string()),
|
("DeviceId", DEVICE_ID.to_string()),
|
||||||
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
||||||
("Container", "mp3".to_string()),
|
("Container", "mp3".to_string()),
|
||||||
("AudioCodec", "mp3".to_string()),
|
("AudioCodec", "mp3".to_string()),
|
||||||
@@ -889,15 +1002,28 @@ impl JellyfinItem {
|
|||||||
media_streams: self.media_streams.map(|streams| {
|
media_streams: self.media_streams.map(|streams| {
|
||||||
streams
|
streams
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| crate::repository::types::MediaStream {
|
.map(|s| {
|
||||||
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
|
let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
|
||||||
stream_type: s.stream_type,
|
// Only a subtitle can be a sidecar; asked of anything
|
||||||
codec: s.codec,
|
// else the question has no answer. TRACES: UR-020 |
|
||||||
language: s.language,
|
// DR-176 | UT-168
|
||||||
display_title: s.display_title,
|
let supports_external_delivery =
|
||||||
index: s.index,
|
(kind == crate::domain::StreamKind::Subtitle).then(|| {
|
||||||
is_default: s.is_default,
|
super::device_profile::subtitle_supports_external_delivery(
|
||||||
is_forced: s.is_forced,
|
s.codec.as_deref(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
crate::repository::types::MediaStream {
|
||||||
|
kind,
|
||||||
|
stream_type: s.stream_type,
|
||||||
|
codec: s.codec,
|
||||||
|
language: s.language,
|
||||||
|
display_title: s.display_title,
|
||||||
|
index: s.index,
|
||||||
|
is_default: s.is_default,
|
||||||
|
is_forced: s.is_forced,
|
||||||
|
supports_external_delivery,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}),
|
}),
|
||||||
@@ -946,11 +1072,13 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(response
|
Ok(response
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|lib| Library {
|
.map(|lib| {
|
||||||
id: lib.id,
|
Library::new(
|
||||||
name: lib.name,
|
lib.id,
|
||||||
collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
|
lib.name,
|
||||||
image_tag: lib.image_tags.and_then(|tags| tags.primary()),
|
lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
lib.image_tags.and_then(|tags| tags.primary()),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -1528,23 +1656,27 @@ impl MediaRepository for OnlineRepository {
|
|||||||
max_audio_channels: max_audio_channels.clone(),
|
max_audio_channels: max_audio_channels.clone(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
subtitle_profiles: vec![
|
subtitle_profiles: super::device_profile::subtitle_profiles()
|
||||||
SubtitleProfile {
|
.into_iter()
|
||||||
format: "srt".to_string(),
|
.map(|(format, method)| SubtitleProfile {
|
||||||
method: "External".to_string(),
|
format: format.to_string(),
|
||||||
},
|
method: method.to_string(),
|
||||||
SubtitleProfile {
|
})
|
||||||
format: "vtt".to_string(),
|
.collect(),
|
||||||
method: "External".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST to PlaybackInfo with device profile containing detected codecs
|
// POST to PlaybackInfo with device profile containing detected codecs
|
||||||
let request_body = PlaybackInfoRequest {
|
let request_body = PlaybackInfoRequest {
|
||||||
user_id: self.user_id.clone(),
|
user_id: self.user_id.clone(),
|
||||||
audio_stream_index: None, // Let the server pick the source default
|
audio_stream_index: None, // Let the server pick the source default
|
||||||
subtitle_stream_index: None,
|
// Never let the server choose a subtitle track for us. Omitting this
|
||||||
|
// makes it honour the source's default/forced flag, and an image-based
|
||||||
|
// default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
|
||||||
|
// — burn-in, which forces a full video re-encode of a stream that would
|
||||||
|
// otherwise be remuxed. The app renders subtitles itself (UR-020).
|
||||||
|
//
|
||||||
|
// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
|
||||||
start_time_ticks: 0,
|
start_time_ticks: 0,
|
||||||
is_playback: true,
|
is_playback: true,
|
||||||
auto_open_live_stream: true,
|
auto_open_live_stream: true,
|
||||||
@@ -1571,6 +1703,23 @@ impl MediaRepository for OnlineRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name the tracks we are declining to have the server composite. Burn-in
|
||||||
|
// rules out remuxing the video, so a single image-based track can turn a
|
||||||
|
// free passthrough into a full re-encode; when that used to happen there
|
||||||
|
// was nothing in the log connecting the stall to the subtitle.
|
||||||
|
for stream in &source.media_streams {
|
||||||
|
if stream.stream_type == "Subtitle" {
|
||||||
|
if let Some(codec) = stream.codec.as_deref() {
|
||||||
|
if super::device_profile::subtitle_forces_burn_in(codec) {
|
||||||
|
info!(
|
||||||
|
" Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
|
||||||
|
stream.index, codec
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
|
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
|
||||||
// but ignores its audio codec, so it offers an E-AC-3 track for direct
|
// but ignores its audio codec, so it offers an E-AC-3 track for direct
|
||||||
// play even though DR-148 advertises only AAC — and the webview renders
|
// play even though DR-148 advertises only AAC — and the webview renders
|
||||||
@@ -1586,13 +1735,28 @@ impl MediaRepository for OnlineRepository {
|
|||||||
|
|
||||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||||
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
||||||
format!("{}{}", self.server_url, transcoding_url)
|
// The server started this job and named the session — adopt it, or a
|
||||||
|
// later quality switch / seek on this stream has no previous job to
|
||||||
|
// stop and ends up contending with the one currently playing.
|
||||||
|
if let Some(previous) = adopt_video_play_session(response.play_session_id.clone()) {
|
||||||
|
self.stop_transcode(&previous).await;
|
||||||
|
}
|
||||||
|
// The server built this URL from its *own* subtitle verdict, so it can
|
||||||
|
// hand back the burn-in the request above just declined. Strip it: the
|
||||||
|
// negotiated answer only holds for the stream we actually open.
|
||||||
|
//
|
||||||
|
// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
self.server_url,
|
||||||
|
super::device_profile::without_server_chosen_subtitle(transcoding_url)
|
||||||
|
)
|
||||||
} else if audio_forces_transcode {
|
} else if audio_forces_transcode {
|
||||||
warn!(
|
warn!(
|
||||||
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
||||||
audio_streams.first().and_then(|(codec, _)| *codec)
|
audio_streams.first().and_then(|(codec, _)| *codec)
|
||||||
);
|
);
|
||||||
self.get_video_stream_url(item_id, Some(&source.id), None, None)
|
self.get_video_stream_url(item_id, Some(&source.id), None)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
||||||
@@ -1689,6 +1853,13 @@ impl MediaRepository for OnlineRepository {
|
|||||||
auto_open_live_stream: bool,
|
auto_open_live_stream: bool,
|
||||||
is_playback: bool,
|
is_playback: bool,
|
||||||
max_streaming_bitrate: u64,
|
max_streaming_bitrate: u64,
|
||||||
|
/// "No subtitle", for the same reason as everywhere else: omitting it
|
||||||
|
/// lets the server apply the channel's default track, and broadcast
|
||||||
|
/// subtitles are DVB bitmaps — deliverable only by burning them in,
|
||||||
|
/// which forces a full re-encode of a stream that is already tight.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
subtitle_stream_index: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1716,6 +1887,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// too — a channel opened at the source bitrate would walk straight
|
// too — a channel opened at the source bitrate would walk straight
|
||||||
// past a limit set for the connection. TRACES: UR-074 | DR-162
|
// past a limit set for the connection. TRACES: UR-074 | DR-162
|
||||||
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
|
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
|
||||||
|
subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
||||||
@@ -1731,14 +1903,21 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// The transcoding URL is server-relative; make it absolute. If the server
|
// The transcoding URL is server-relative; make it absolute. If the server
|
||||||
// did not provide one (rare for live), fall back to the HLS master endpoint.
|
// did not provide one (rare for live), fall back to the HLS master endpoint.
|
||||||
let stream_url = match source.transcoding_url {
|
let stream_url = match source.transcoding_url {
|
||||||
Some(url) => format!("{}{}", self.server_url, url),
|
// As in `get_playback_info`: the server chose the subtitle in this
|
||||||
|
// URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
Some(url) => format!(
|
||||||
|
"{}{}",
|
||||||
|
self.server_url,
|
||||||
|
super::device_profile::without_server_chosen_subtitle(&url)
|
||||||
|
),
|
||||||
None => format!(
|
None => format!(
|
||||||
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
|
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
|
||||||
self.server_url,
|
self.server_url,
|
||||||
item_id,
|
item_id,
|
||||||
self.access_token,
|
self.access_token,
|
||||||
source.id,
|
source.id,
|
||||||
source.live_stream_id.clone().unwrap_or_default(),
|
source.live_stream_id.clone().unwrap_or_default(),
|
||||||
|
super::device_profile::playback_subtitle_stream_index(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2527,7 +2706,7 @@ mod tests {
|
|||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
let url = repo
|
let url = repo
|
||||||
.get_video_stream_url("vid-1", None, None, None)
|
.get_video_stream_url("vid-1", None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2549,7 +2728,7 @@ mod tests {
|
|||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
let url = repo
|
let url = repo
|
||||||
.get_video_stream_url("vid-1", None, None, None)
|
.get_video_stream_url("vid-1", None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2587,16 +2766,25 @@ mod tests {
|
|||||||
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
|
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transcoded video must be an HLS master playlist, not a progressive
|
||||||
|
/// `stream.mp4`: a progressive transcode of an HEVC source makes the server
|
||||||
|
/// convert the whole file before serving a byte, which presents as playback
|
||||||
|
/// that never starts. The chosen source and audio track ride along with it.
|
||||||
|
///
|
||||||
|
/// This is the surviving half of the old
|
||||||
|
/// `test_get_video_stream_url_returns_hls_with_position`, whose other half
|
||||||
|
/// asserted the `StartTimeTicks` that DR-181 removed — the position now
|
||||||
|
/// belongs to a seek after load, never to this URL, so the assertion for it
|
||||||
|
/// is gone rather than inverted (its inverse is UT-182's own test).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-140, DR-181 | UT-130
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_video_stream_url_returns_hls_with_position() {
|
async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
|
||||||
// Transcoded video resume/seek must produce an HLS master playlist with
|
|
||||||
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
|
|
||||||
// for HEVC sources). See get_video_stream_url docs.
|
|
||||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
let url = repo
|
let url = repo
|
||||||
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
|
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2607,18 +2795,57 @@ mod tests {
|
|||||||
assert!(url.contains("VideoCodec=h264"));
|
assert!(url.contains("VideoCodec=h264"));
|
||||||
assert!(url.contains("MediaSourceId=source-1"));
|
assert!(url.contains("MediaSourceId=source-1"));
|
||||||
assert!(url.contains("AudioStreamIndex=1"));
|
assert!(url.contains("AudioStreamIndex=1"));
|
||||||
// 193.0 seconds * 10_000_000 ticks/sec
|
|
||||||
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
|
|
||||||
assert!(!url.contains("stream.mp4"));
|
assert!(!url.contains("stream.mp4"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resuming a transcoded video played nothing at all: every segment came back
|
||||||
|
/// `400`, hls.js exhausted its retries and gave up. Starting the same episode
|
||||||
|
/// from the beginning was fine.
|
||||||
|
///
|
||||||
|
/// Jellyfin builds each segment URI by echoing the *master playlist's* query
|
||||||
|
/// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
|
||||||
|
/// its segment handler opens with
|
||||||
|
///
|
||||||
|
/// ```csharp
|
||||||
|
/// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
|
||||||
|
/// throw new ArgumentException("StartTimeTicks is not allowed.");
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// so a resume position put on the playlist is copied onto every
|
||||||
|
/// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
|
||||||
|
/// from the beginning survived.
|
||||||
|
///
|
||||||
|
/// HLS does not need the parameter: the playlist spans the whole item, and
|
||||||
|
/// asking for segment N *is* the seek — the server transcodes from there. So
|
||||||
|
/// the position never belongs in this URL; the player seeks after load. The
|
||||||
|
/// sibling progressive `/Audio/universal` builder is a different endpoint with
|
||||||
|
/// no segments, and keeps its `StartTimeTicks`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-074 | DR-181 | UT-182
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_video_stream_url_never_carries_start_time_ticks() {
|
||||||
|
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
let url = repo
|
||||||
|
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!url.contains("StartTimeTicks"),
|
||||||
|
"an HLS playlist must never carry StartTimeTicks — the server copies it \
|
||||||
|
onto every segment URI and then rejects each one with 400: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_video_stream_url_omits_position_when_absent() {
|
async fn test_get_video_stream_url_omits_position_when_absent() {
|
||||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
|
|
||||||
let url = repo
|
let url = repo
|
||||||
.get_video_stream_url("vid-1", None, None, None)
|
.get_video_stream_url("vid-1", None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -2635,6 +2862,140 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jellyfin keys a transcode job by device *and* play session. Every stream
|
||||||
|
/// this app opened used the same `DeviceId` and no `PlaySessionId`, so
|
||||||
|
/// re-opening the same item — what a mid-playback quality switch, a
|
||||||
|
/// transcoded seek and an audio-track switch all do — handed the server a
|
||||||
|
/// second job it could not tell apart from the one still running. Observed
|
||||||
|
/// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
|
||||||
|
/// intermittently while the two jobs fight over the same transcode path, and
|
||||||
|
/// playback stalls.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-177 | UT-173
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_video_stream_url_carries_a_play_session_id() {
|
||||||
|
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
let url = repo
|
||||||
|
.get_video_stream_url("vid-1", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
url.contains("PlaySessionId="),
|
||||||
|
"every transcode must be openable as its own job: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Naming no subtitle stream is not the same as asking for none. The server
|
||||||
|
/// fills the gap with the source's own default/forced track, and an
|
||||||
|
/// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
|
||||||
|
/// the picture — the burn-in of DR-176, arriving through the URL rather than
|
||||||
|
/// through the negotiation.
|
||||||
|
///
|
||||||
|
/// The negotiation already sends the sentinel, but it is not what opens most
|
||||||
|
/// streams: a quality switch, a transcoded seek and an audio-track switch all
|
||||||
|
/// build this URL again, on their own. Saying it here too makes "no subtitle"
|
||||||
|
/// a property of the request instead of something inherited from whatever
|
||||||
|
/// session state the server happens to still hold.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_video_stream_url_asks_for_no_subtitle_stream() {
|
||||||
|
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||||
|
let repo = create_test_repository();
|
||||||
|
|
||||||
|
let url = repo
|
||||||
|
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
url.contains("SubtitleStreamIndex=-1"),
|
||||||
|
"the stream URL must ask for no subtitle, not leave the choice open: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picker must not offer a subtitle the app cannot draw. Image-based
|
||||||
|
/// tracks are bitmaps: the only way to show one is to have the server
|
||||||
|
/// composite it, which is exactly what DR-176 stopped asking for. Selecting
|
||||||
|
/// one was therefore a control that could not do anything — so the verdict
|
||||||
|
/// travels with the stream, decided here where the codec vocabulary lives.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn test_media_streams_carry_whether_the_app_can_render_them() {
|
||||||
|
let item: JellyfinItem = serde_json::from_value(serde_json::json!({
|
||||||
|
"Id": "ep-1",
|
||||||
|
"Name": "Partings",
|
||||||
|
"Type": "Episode",
|
||||||
|
"MediaStreams": [
|
||||||
|
{ "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
|
||||||
|
{ "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
|
||||||
|
{ "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
|
||||||
|
{ "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
|
||||||
|
{ "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.expect("fixture must deserialize");
|
||||||
|
|
||||||
|
let streams = item.to_media_item("server-1".to_string()).media_streams;
|
||||||
|
let streams = streams.expect("the item carries streams");
|
||||||
|
let deliverable = |index: i32| {
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.index == index)
|
||||||
|
.unwrap_or_else(|| panic!("stream {index} missing"))
|
||||||
|
.supports_external_delivery
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bitmap track the server would have had to burn in.
|
||||||
|
assert_eq!(deliverable(2), Some(false));
|
||||||
|
// Text: fetched as WebVTT and drawn by the app itself.
|
||||||
|
assert_eq!(deliverable(3), Some(true));
|
||||||
|
// A subtitle whose format the server did not name could be anything;
|
||||||
|
// offering it risks a dead control, so it is not offered.
|
||||||
|
assert_eq!(deliverable(4), Some(false));
|
||||||
|
// Meaningless for anything that is not a subtitle — and said as `None`
|
||||||
|
// rather than as a `false` a reader could mistake for a verdict.
|
||||||
|
assert_eq!(deliverable(0), None);
|
||||||
|
assert_eq!(deliverable(1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session id is what makes two opens *distinguishable*, so a fresh one
|
||||||
|
/// per open is the whole point — and the open must report the id it replaced
|
||||||
|
/// so the caller can stop that job instead of leaving it running.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-074 | DR-177 | UT-173
|
||||||
|
#[test]
|
||||||
|
fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
|
||||||
|
let _lock = QUALITY_LOCK.lock_safe();
|
||||||
|
|
||||||
|
let (first, _) = begin_video_play_session();
|
||||||
|
let (second, replaced) = begin_video_play_session();
|
||||||
|
|
||||||
|
assert_ne!(first, second, "each open needs its own job identity");
|
||||||
|
assert_eq!(
|
||||||
|
replaced,
|
||||||
|
Some(first),
|
||||||
|
"the open must hand back the job it superseded so it can be stopped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
|
||||||
|
// has to become the current session too — otherwise the first switch on
|
||||||
|
// that stream stops nothing and collides with what is playing.
|
||||||
|
let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
|
||||||
|
assert_eq!(replaced_by_adoption, Some(second));
|
||||||
|
|
||||||
|
let (_, after_adoption) = begin_video_play_session();
|
||||||
|
assert_eq!(
|
||||||
|
after_adoption,
|
||||||
|
Some("server-named-session".to_string()),
|
||||||
|
"the adopted job must be the one the next open stops"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
|
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
|
||||||
// TRACES: UR-040 | JA-032 | UT-059
|
// TRACES: UR-040 | JA-032 | UT-059
|
||||||
|
|||||||
@@ -36,6 +36,38 @@ pub struct Library {
|
|||||||
pub collection_type: String,
|
pub collection_type: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub image_tag: Option<String>,
|
pub image_tag: Option<String>,
|
||||||
|
/// The favourites scope this library's contents fall under, or `None` for a
|
||||||
|
/// library kind favourites does not carve up (Live TV, channels, books…).
|
||||||
|
///
|
||||||
|
/// Derived here rather than in the UI: which collection type maps to which
|
||||||
|
/// scope is Jellyfin vocabulary, and the frontend must not hold a
|
||||||
|
/// collection-type → category table any more than an item-type one. See
|
||||||
|
/// `SearchScope::for_collection_type`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-075 | DR-175
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub favorites_scope: Option<SearchScope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Library {
|
||||||
|
/// Build a library, deriving everything that follows from its collection
|
||||||
|
/// type. Prefer this over the struct literal so a new derived field cannot
|
||||||
|
/// be forgotten at one of the construction sites.
|
||||||
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
collection_type: String,
|
||||||
|
image_tag: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let favorites_scope = SearchScope::for_collection_type(&collection_type);
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
collection_type,
|
||||||
|
image_tag,
|
||||||
|
favorites_scope,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User-specific data for an item (playback state, favorites, etc.)
|
/// User-specific data for an item (playback state, favorites, etc.)
|
||||||
@@ -221,6 +253,18 @@ pub struct MediaStream {
|
|||||||
pub index: i32,
|
pub index: i32,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
pub is_forced: bool,
|
pub is_forced: bool,
|
||||||
|
/// Whether this stream can reach the app as a sidecar it renders itself.
|
||||||
|
///
|
||||||
|
/// `None` for anything that is not a subtitle — the question does not apply,
|
||||||
|
/// and `false` there would read like a verdict. For a subtitle it is the
|
||||||
|
/// difference between a track the app can draw and one only the server could
|
||||||
|
/// have shown, by burning it into the picture (DR-176) — which this app never
|
||||||
|
/// asks it to do. The vocabulary of *which formats those are* stays in Rust;
|
||||||
|
/// the frontend only reads the answer.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
#[serde(default)]
|
||||||
|
pub supports_external_delivery: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Media source information
|
/// Media source information
|
||||||
@@ -345,6 +389,27 @@ impl SearchScope {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The scope a library of this Jellyfin `CollectionType` belongs to, or
|
||||||
|
/// `None` when its contents are not something favourites are browsed by.
|
||||||
|
///
|
||||||
|
/// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
|
||||||
|
/// changes when Jellyfin renames a collection type, not when the library
|
||||||
|
/// page is redesigned — so it lives here rather than in the UI that renders
|
||||||
|
/// a per-library favourites tile.
|
||||||
|
///
|
||||||
|
/// `All` is never returned: it is the *absence* of a category, offered
|
||||||
|
/// alongside the libraries rather than derived from one.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-075 | DR-175 | UT-161
|
||||||
|
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
|
||||||
|
match collection_type {
|
||||||
|
"movies" => Some(SearchScope::Movies),
|
||||||
|
"tvshows" => Some(SearchScope::Tv),
|
||||||
|
"music" => Some(SearchScope::Music),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for search queries
|
/// Options for search queries
|
||||||
@@ -653,6 +718,54 @@ mod search_scope_tests {
|
|||||||
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
|
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
|
||||||
assert!(matches!(all.scope, Some(SearchScope::All)));
|
assert!(matches!(all.scope, Some(SearchScope::All)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: DR-175 | UT-161
|
||||||
|
#[test]
|
||||||
|
fn test_collection_type_maps_to_its_favorites_scope() {
|
||||||
|
assert_eq!(
|
||||||
|
SearchScope::for_collection_type("movies"),
|
||||||
|
Some(SearchScope::Movies)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SearchScope::for_collection_type("tvshows"),
|
||||||
|
Some(SearchScope::Tv)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SearchScope::for_collection_type("music"),
|
||||||
|
Some(SearchScope::Music)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library kind favourites are not browsed by gets no tile at all, rather
|
||||||
|
/// than one that opens an unfiltered list. `All` is never derived from a
|
||||||
|
/// library — it is the cross-library entry offered beside them.
|
||||||
|
///
|
||||||
|
/// TRACES: DR-175 | UT-161
|
||||||
|
#[test]
|
||||||
|
fn test_uncategorised_collection_types_have_no_favorites_scope() {
|
||||||
|
for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
|
||||||
|
assert_eq!(
|
||||||
|
SearchScope::for_collection_type(collection_type),
|
||||||
|
None,
|
||||||
|
"{collection_type} should not carry a favourites scope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: DR-175 | UT-161
|
||||||
|
#[test]
|
||||||
|
fn test_library_carries_its_favorites_scope_to_the_frontend() {
|
||||||
|
let music = Library::new("1".into(), "Music".into(), "music".into(), None);
|
||||||
|
assert_eq!(music.favorites_scope, Some(SearchScope::Music));
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&music).unwrap();
|
||||||
|
assert_eq!(json["favoritesScope"], "music");
|
||||||
|
|
||||||
|
// A library with no scope omits the field rather than sending null.
|
||||||
|
let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
|
||||||
|
let json = serde_json::to_value(&livetv).unwrap();
|
||||||
|
assert!(json.get("favoritesScope").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.5.3",
|
"version": "0.6.0",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+52
-8
@@ -840,10 +840,23 @@ async downloadItemAndStart(request: DownloadItemAndStartRequest) : Promise<numbe
|
|||||||
return await TAURI_INVOKE("download_item_and_start", { request });
|
return await TAURI_INVOKE("download_item_and_start", { request });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Queue an entire album for download
|
* Queue an entire album for download.
|
||||||
|
*
|
||||||
|
* Owns the whole operation: the album's track list comes from the server (the
|
||||||
|
* only place that knows all of it), every track is queued and linked to its
|
||||||
|
* album, each row's stream URL is resolved here, and the queue is pumped.
|
||||||
|
*
|
||||||
|
* The frontend used to do the second half — resolve one URL per track and pair
|
||||||
|
* it with the returned ids **by position**. That pairing had no basis: the ids
|
||||||
|
* came back in the backend's own order over a different set of rows, so
|
||||||
|
* whenever the two lists disagreed a row was handed another track's URL, and
|
||||||
|
* any track past the end of the shorter list was never started at all. Nothing
|
||||||
|
* crosses the boundary now except the album id.
|
||||||
|
*
|
||||||
|
* TRACES: UR-018, UR-055 | DR-173 | UT-170
|
||||||
*/
|
*/
|
||||||
async downloadAlbum(albumId: string, userId: string, basePath: string) : Promise<number[]> {
|
async downloadAlbum(handle: string, albumId: string, userId: string, basePath: string) : Promise<number[]> {
|
||||||
return await TAURI_INVOKE("download_album", { albumId, userId, basePath });
|
return await TAURI_INVOKE("download_album", { handle, albumId, userId, basePath });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Queue a video item (movie or episode) for download with quality preset
|
* Queue a video item (movie or episode) for download with quality preset
|
||||||
@@ -1501,10 +1514,16 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
|
|||||||
return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId });
|
return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get video stream URL with optional seeking support
|
* Get a video stream URL.
|
||||||
|
*
|
||||||
|
* There is no start-position parameter on purpose: the URL is an HLS playlist
|
||||||
|
* covering the whole item, and a position on it makes the server reject every
|
||||||
|
* segment with `400` (DR-181). Callers resume by seeking after load.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004 | DR-181 | UT-182
|
||||||
*/
|
*/
|
||||||
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
|
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
|
||||||
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
|
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Get audio stream URL for a track
|
* Get audio stream URL for a track
|
||||||
@@ -2072,7 +2091,19 @@ export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jell
|
|||||||
/**
|
/**
|
||||||
* Library (media collection)
|
* Library (media collection)
|
||||||
*/
|
*/
|
||||||
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
|
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null;
|
||||||
|
/**
|
||||||
|
* The favourites scope this library's contents fall under, or `None` for a
|
||||||
|
* library kind favourites does not carve up (Live TV, channels, books…).
|
||||||
|
*
|
||||||
|
* Derived here rather than in the UI: which collection type maps to which
|
||||||
|
* scope is Jellyfin vocabulary, and the frontend must not hold a
|
||||||
|
* collection-type → category table any more than an item-type one. See
|
||||||
|
* `SearchScope::for_collection_type`.
|
||||||
|
*
|
||||||
|
* TRACES: UR-075 | DR-175
|
||||||
|
*/
|
||||||
|
favoritesScope?: SearchScope | null }
|
||||||
/**
|
/**
|
||||||
* Live stream information returned from opening a Live TV / channel stream.
|
* Live stream information returned from opening a Live TV / channel stream.
|
||||||
*
|
*
|
||||||
@@ -2210,7 +2241,20 @@ type: string;
|
|||||||
/**
|
/**
|
||||||
* Provider-neutral stream classification — replaces `stream_type`.
|
* Provider-neutral stream classification — replaces `stream_type`.
|
||||||
*/
|
*/
|
||||||
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
|
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean;
|
||||||
|
/**
|
||||||
|
* Whether this stream can reach the app as a sidecar it renders itself.
|
||||||
|
*
|
||||||
|
* `None` for anything that is not a subtitle — the question does not apply,
|
||||||
|
* and `false` there would read like a verdict. For a subtitle it is the
|
||||||
|
* difference between a track the app can draw and one only the server could
|
||||||
|
* have shown, by burning it into the picture (DR-176) — which this app never
|
||||||
|
* asks it to do. The vocabulary of *which formats those are* stays in Rust;
|
||||||
|
* the frontend only reads the answer.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
*/
|
||||||
|
supportsExternalDelivery?: boolean | null }
|
||||||
export type MediaType = "audio" | "video"
|
export type MediaType = "audio" | "video"
|
||||||
/**
|
/**
|
||||||
* Lightweight media item for merged playback state
|
* Lightweight media item for merged playback state
|
||||||
|
|||||||
@@ -409,23 +409,26 @@ describe("RepositoryClient", () => {
|
|||||||
handle: "test-handle-123",
|
handle: "test-handle-123",
|
||||||
itemId: "item123",
|
itemId: "item123",
|
||||||
mediaSourceId: null,
|
mediaSourceId: null,
|
||||||
startTimeSeconds: null,
|
|
||||||
audioStreamIndex: null,
|
audioStreamIndex: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no start-position argument: a position on the HLS playlist makes
|
||||||
|
* the server reject every segment behind it with 400, so resume and seek are
|
||||||
|
* performed by seeking the player after load (DR-181).
|
||||||
|
*/
|
||||||
it("should get video stream URL with options", async () => {
|
it("should get video stream URL with options", async () => {
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?start=300&api_key=token";
|
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||||
|
|
||||||
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0);
|
const url = await client.getVideoStreamUrl("item123", "source456", 0);
|
||||||
|
|
||||||
expect(url).toBe(mockUrl);
|
expect(url).toBe(mockUrl);
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
||||||
handle: "test-handle-123",
|
handle: "test-handle-123",
|
||||||
itemId: "item123",
|
itemId: "item123",
|
||||||
mediaSourceId: "source456",
|
mediaSourceId: "source456",
|
||||||
startTimeSeconds: 300,
|
|
||||||
audioStreamIndex: 0,
|
audioStreamIndex: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -213,17 +213,25 @@ export class RepositoryClient {
|
|||||||
return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
|
return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A video stream URL, which always begins at the **start of the item**.
|
||||||
|
*
|
||||||
|
* There is deliberately no position parameter: the URL is an HLS playlist, and
|
||||||
|
* a start position on it makes Jellyfin reject every segment behind it with
|
||||||
|
* `400` (DR-181). Resume and transcoded seeking are performed by seeking the
|
||||||
|
* player once the stream has loaded.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004 | DR-181 | UT-182
|
||||||
|
*/
|
||||||
async getVideoStreamUrl(
|
async getVideoStreamUrl(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
mediaSourceId?: string,
|
mediaSourceId?: string,
|
||||||
startTimeSeconds?: number,
|
|
||||||
audioStreamIndex?: number
|
audioStreamIndex?: number
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return commands.repositoryGetVideoStreamUrl(
|
return commands.repositoryGetVideoStreamUrl(
|
||||||
this.ensureHandle(),
|
this.ensureHandle(),
|
||||||
itemId,
|
itemId,
|
||||||
mediaSourceId ?? null,
|
mediaSourceId ?? null,
|
||||||
startTimeSeconds ?? null,
|
|
||||||
audioStreamIndex ?? null
|
audioStreamIndex ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
/**
|
||||||
|
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
|
||||||
|
* layout that sizes boxes from artwork (the mosaic) use the shape the image
|
||||||
|
* actually has rather than the one its item type suggests.
|
||||||
|
* TRACES: UR-075 | DR-174
|
||||||
|
*/
|
||||||
|
onNaturalSize?: (width: number, height: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,6 +28,7 @@
|
|||||||
maxHeight,
|
maxHeight,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
alt = "",
|
alt = "",
|
||||||
|
onNaturalSize,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let imageUrl = $state<string | null>(null);
|
let imageUrl = $state<string | null>(null);
|
||||||
@@ -86,5 +94,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<img src={imageUrl} {alt} class={className} />
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
{alt}
|
||||||
|
class={className}
|
||||||
|
onload={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||||
|
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { commands } from "$lib/api/bindings";
|
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -83,28 +82,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Download the album: queue all tracks, then start each one
|
// Download the album. One call: the backend lists the album's tracks
|
||||||
|
// from the server, queues every one of them, resolves each stream URL
|
||||||
|
// and pumps the queue. This page's `tracks` are what the user is
|
||||||
|
// looking at, not the download list — pairing them against the returned
|
||||||
|
// ids by position is what used to leave most of an album unqueued.
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const basePath = `albums/${albumId}`;
|
const basePath = `albums/${albumId}`;
|
||||||
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
|
await downloads.downloadAlbum(repo.getHandle(), albumId, userId, basePath);
|
||||||
|
|
||||||
// Get target directory for downloads
|
|
||||||
const targetDir = await commands.storageGetPath();
|
|
||||||
|
|
||||||
// Enqueue each track with its resolved stream URL. The backend queue
|
|
||||||
// pump starts up to max_concurrent at a time and advances through the
|
|
||||||
// rest automatically as slots free up — so we never hit (and silently
|
|
||||||
// drop) the concurrency limit the way startDownload did.
|
|
||||||
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
|
|
||||||
try {
|
|
||||||
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
|
|
||||||
if (streamUrl) {
|
|
||||||
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh to get updated statuses
|
// Refresh to get updated statuses
|
||||||
await downloads.refresh(userId);
|
await downloads.refresh(userId);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<!--
|
||||||
|
Justified mosaic of tiles: rows of a shared height, each tile as wide as its
|
||||||
|
own aspect ratio says it should be.
|
||||||
|
|
||||||
|
The geometry is `mosaic.ts` (pure, unit-tested); this component supplies the
|
||||||
|
two things only the DOM knows — how wide the container is, and what shape the
|
||||||
|
artwork turned out to be — and renders whatever the caller's `tile` snippet
|
||||||
|
draws.
|
||||||
|
|
||||||
|
Measured ratios are committed in one batch rather than per image: artwork
|
||||||
|
arrives over a few hundred milliseconds, and re-packing on each arrival would
|
||||||
|
shuffle the grid under the viewer's cursor several times over.
|
||||||
|
|
||||||
|
TRACES: UR-075 | DR-174
|
||||||
|
-->
|
||||||
|
<script lang="ts" generics="T extends { key: string; ratio: number }">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import {
|
||||||
|
layoutMosaic,
|
||||||
|
layoutMosaicStrip,
|
||||||
|
mosaicTargetHeight,
|
||||||
|
type MosaicTile,
|
||||||
|
} from "./mosaic";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: T[];
|
||||||
|
/** Row height. Defaults to one suited to the container's width. */
|
||||||
|
targetHeight?: number;
|
||||||
|
gap?: number;
|
||||||
|
/**
|
||||||
|
* "rows" wraps into justified rows and fills the container.
|
||||||
|
* "strip" keeps one row at a fixed height and scrolls sideways — the same
|
||||||
|
* no-distortion rule applied to a shelf.
|
||||||
|
*/
|
||||||
|
layout?: "rows" | "strip";
|
||||||
|
tile: Snippet<[MosaicTile<T> & { reportRatio: (ratio: number) => void }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, targetHeight, gap = 8, layout = "rows", tile }: Props = $props();
|
||||||
|
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
let measured = $state<Record<string, number>>({});
|
||||||
|
|
||||||
|
let pending: Record<string, number> = {};
|
||||||
|
let commitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const COMMIT_DELAY_MS = 120;
|
||||||
|
/** Below this, a measured ratio isn't worth a re-pack. */
|
||||||
|
const RATIO_EPSILON = 0.02;
|
||||||
|
|
||||||
|
function reportRatio(key: string, ratio: number) {
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
||||||
|
const known = measured[key] ?? items.find((i) => i.key === key)?.ratio;
|
||||||
|
if (known !== undefined && Math.abs(known - ratio) / known < RATIO_EPSILON) return;
|
||||||
|
|
||||||
|
pending[key] = ratio;
|
||||||
|
if (commitTimer !== null) return;
|
||||||
|
commitTimer = setTimeout(() => {
|
||||||
|
commitTimer = null;
|
||||||
|
measured = { ...measured, ...pending };
|
||||||
|
pending = {};
|
||||||
|
}, COMMIT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (commitTimer !== null) clearTimeout(commitTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
|
||||||
|
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
|
||||||
|
const rows = $derived(
|
||||||
|
layout === "strip"
|
||||||
|
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
|
||||||
|
: layoutMosaic(sized, { containerWidth, targetHeight: height, gap }),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if layout === "strip"}
|
||||||
|
<!-- A strip is measured by the viewport it scrolls in, not by its content. -->
|
||||||
|
<div bind:clientWidth={containerWidth} class="overflow-x-auto pb-2">
|
||||||
|
<div class="flex w-max items-start" style="gap: {gap}px;">
|
||||||
|
{#each rows[0].tiles as placed (placed.key)}
|
||||||
|
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div bind:clientWidth={containerWidth} class="flex flex-col" style="gap: {gap}px;">
|
||||||
|
{#each rows as row, i (i)}
|
||||||
|
<div class="flex" style="gap: {gap}px;">
|
||||||
|
{#each row.tiles as placed (placed.key)}
|
||||||
|
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!--
|
||||||
|
One tile of a mosaic: artwork at an exact pixel box, with its label written
|
||||||
|
over the bottom of the image rather than beneath it.
|
||||||
|
|
||||||
|
The label lives on the artwork on purpose — a caption below would add height
|
||||||
|
outside the box the layout computed, and a row whose captions wrap to two
|
||||||
|
lines would no longer line up with its neighbours. Keeping everything inside
|
||||||
|
the box is what lets `layoutMosaic` own the geometry completely.
|
||||||
|
|
||||||
|
TRACES: UR-075 | DR-174
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Item whose Primary image is the artwork. Omit for an icon-only tile. */
|
||||||
|
itemId?: string;
|
||||||
|
imageTag?: string | null;
|
||||||
|
/** Drawn instead of artwork — favourites tiles have no image of their own. */
|
||||||
|
icon?: Snippet;
|
||||||
|
/** Tints an icon-only tile so it reads as a destination, not a broken image. */
|
||||||
|
accent?: boolean;
|
||||||
|
onclick?: () => void;
|
||||||
|
/**
|
||||||
|
* Reports the artwork's true aspect ratio once decoded, so the grid can
|
||||||
|
* re-pack against the shape the image actually has.
|
||||||
|
*/
|
||||||
|
onRatio?: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
itemId,
|
||||||
|
imageTag,
|
||||||
|
icon,
|
||||||
|
accent = false,
|
||||||
|
onclick,
|
||||||
|
onRatio,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Request an image comfortably larger than the box so a wide tile is not
|
||||||
|
// upscaled, without refetching every time the container resizes (CachedImage
|
||||||
|
// keys its fetch on the item, not on this number).
|
||||||
|
const REQUEST_WIDTH = 480;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{onclick}
|
||||||
|
aria-label={label}
|
||||||
|
class="group/tile relative overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md
|
||||||
|
transition-transform duration-200 hover:z-10 hover:scale-[1.03] hover:shadow-2xl
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-jellyfin)]"
|
||||||
|
style="width: {width}px; height: {height}px;"
|
||||||
|
>
|
||||||
|
{#if icon}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center
|
||||||
|
{accent
|
||||||
|
? 'bg-gradient-to-br from-[var(--color-jellyfin)]/40 to-[var(--color-jellyfin)]/5'
|
||||||
|
: 'bg-[var(--color-surface)]'}"
|
||||||
|
>
|
||||||
|
{@render icon()}
|
||||||
|
</div>
|
||||||
|
{:else if itemId}
|
||||||
|
<CachedImage
|
||||||
|
{itemId}
|
||||||
|
imageType="Primary"
|
||||||
|
tag={imageTag}
|
||||||
|
maxWidth={REQUEST_WIDTH}
|
||||||
|
alt={label}
|
||||||
|
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover/tile:scale-105"
|
||||||
|
onNaturalSize={(w, h) => onRatio?.(w / h)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
|
||||||
|
artwork. -->
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
|
||||||
|
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Library } from "$lib/api/types";
|
||||||
|
import { buildLibraryMosaic, assumedLibraryRatio } from "./libraryMosaic";
|
||||||
|
|
||||||
|
function lib(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
collectionType: string,
|
||||||
|
favoritesScope?: Library["favoritesScope"],
|
||||||
|
): Library {
|
||||||
|
return { id, name, collectionType, favoritesScope } as Library;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVIES = lib("1", "Movies", "movies", "movies");
|
||||||
|
const SHOWS = lib("2", "Shows", "tvshows", "tv");
|
||||||
|
const MUSIC = lib("3", "Music", "music", "music");
|
||||||
|
const LIVETV = lib("4", "Live TV", "livetv");
|
||||||
|
|
||||||
|
describe("buildLibraryMosaic", () => {
|
||||||
|
it("leads with the cross-library favourites entry", () => {
|
||||||
|
const entries = buildLibraryMosaic([MOVIES]);
|
||||||
|
expect(entries[0]).toMatchObject({
|
||||||
|
kind: "favorites",
|
||||||
|
scope: "all",
|
||||||
|
label: "Favourites",
|
||||||
|
href: "/library/favorites",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts each library's own favourites tile right after it", () => {
|
||||||
|
const entries = buildLibraryMosaic([MOVIES, MUSIC]);
|
||||||
|
|
||||||
|
expect(entries.map((e) => e.label)).toEqual([
|
||||||
|
"Favourites",
|
||||||
|
"Movies",
|
||||||
|
"Favourite Movies",
|
||||||
|
"Music",
|
||||||
|
"Favourite Music",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links a category tile to that category's favourites tab", () => {
|
||||||
|
const entries = buildLibraryMosaic([SHOWS]);
|
||||||
|
const tile = entries.find((e) => e.label === "Favourite Shows");
|
||||||
|
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a category's favourites once, however many libraries share it", () => {
|
||||||
|
const entries = buildLibraryMosaic([MOVIES, lib("5", "Kids Films", "movies", "movies")]);
|
||||||
|
|
||||||
|
expect(entries.filter((e) => e.kind === "favorites" && e.scope === "movies")).toHaveLength(1);
|
||||||
|
expect(entries.map((e) => e.label)).toEqual([
|
||||||
|
"Favourites",
|
||||||
|
"Movies",
|
||||||
|
"Favourite Movies",
|
||||||
|
"Kids Films",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives no favourites tile to a library kind favourites do not carve up", () => {
|
||||||
|
const entries = buildLibraryMosaic([LIVETV]);
|
||||||
|
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Live TV"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a scope the favourites page does not offer as a tab", () => {
|
||||||
|
const odd = lib("6", "Books", "books", "books" as Library["favoritesScope"]);
|
||||||
|
const entries = buildLibraryMosaic([odd]);
|
||||||
|
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Books"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every library, and keys tiles uniquely", () => {
|
||||||
|
const entries = buildLibraryMosaic([MOVIES, SHOWS, MUSIC, LIVETV]);
|
||||||
|
|
||||||
|
expect(entries.filter((e) => e.kind === "library")).toHaveLength(4);
|
||||||
|
expect(new Set(entries.map((e) => e.key)).size).toBe(entries.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing but the favourites entry when there are no libraries", () => {
|
||||||
|
expect(buildLibraryMosaic([]).map((e) => e.key)).toEqual(["favorites:all"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a category tile the shape of the library it follows", () => {
|
||||||
|
const entries = buildLibraryMosaic([MUSIC, MOVIES]);
|
||||||
|
const musicFavorites = entries.find((e) => e.label === "Favourite Music")!;
|
||||||
|
const movieFavorites = entries.find((e) => e.label === "Favourite Movies")!;
|
||||||
|
|
||||||
|
expect(musicFavorites.ratio).toBe(assumedLibraryRatio(MUSIC));
|
||||||
|
expect(movieFavorites.ratio).toBe(assumedLibraryRatio(MOVIES));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assumedLibraryRatio", () => {
|
||||||
|
it("assumes a square cover for music and a wide backdrop otherwise", () => {
|
||||||
|
expect(assumedLibraryRatio(MUSIC)).toBe(1);
|
||||||
|
expect(assumedLibraryRatio(MOVIES)).toBeCloseTo(16 / 9);
|
||||||
|
expect(assumedLibraryRatio(LIVETV)).toBeCloseTo(16 / 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// What the library overview mosaic is made of, and in what order.
|
||||||
|
//
|
||||||
|
// Pure: takes the libraries, returns the tiles to draw. No DOM, no stores — so
|
||||||
|
// the ordering and the de-duplication rules below are unit-testable rather than
|
||||||
|
// buried in markup.
|
||||||
|
//
|
||||||
|
// Note what is NOT decided here: which favourites category a library belongs to.
|
||||||
|
// That is Jellyfin vocabulary and arrives on the library itself as
|
||||||
|
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
|
||||||
|
// decides what to *call* it and where to put it.
|
||||||
|
//
|
||||||
|
// TRACES: UR-075, UR-067 | DR-174, DR-175 | UT-167
|
||||||
|
|
||||||
|
import type { Library } from "$lib/api/types";
|
||||||
|
import {
|
||||||
|
FAVORITE_SCOPE_LABELS,
|
||||||
|
asFavoritesScope,
|
||||||
|
favoritesRouteUrl,
|
||||||
|
type FavoritesScope,
|
||||||
|
} from "$lib/utils/favoritesView";
|
||||||
|
|
||||||
|
/** Artwork shapes, as the source images generally arrive. A measured image
|
||||||
|
* overrides these (see MosaicGrid); they are the shape assumed until then. */
|
||||||
|
const SQUARE = 1;
|
||||||
|
const WIDE = 16 / 9;
|
||||||
|
|
||||||
|
export type LibraryMosaicEntry = {
|
||||||
|
/** Stable identity for the layout and for `{#each}` keying. */
|
||||||
|
key: string;
|
||||||
|
/** Assumed width / height until the artwork reports its own. */
|
||||||
|
ratio: number;
|
||||||
|
label: string;
|
||||||
|
} & (
|
||||||
|
| { kind: "library"; library: Library }
|
||||||
|
| { kind: "favorites"; scope: FavoritesScope; href: string }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A music library's artwork is a square cover; everything else is a wide
|
||||||
|
* backdrop. Presentation, not taxonomy: this is the shape of a picture, and it
|
||||||
|
* is a starting guess that the decoded image is allowed to overrule.
|
||||||
|
*/
|
||||||
|
export function assumedLibraryRatio(lib: Library): number {
|
||||||
|
return lib.collectionType === "music" ? SQUARE : WIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mosaic's tiles, in order: the cross-library favourites entry first, then
|
||||||
|
* each library followed by its own favourites tile.
|
||||||
|
*
|
||||||
|
* A category's favourites tile appears **once**, after the first library of that
|
||||||
|
* category — two movie libraries ("Films", "Kids") share one favourites list, so
|
||||||
|
* a tile each would be two tiles going to the same place.
|
||||||
|
*/
|
||||||
|
export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
|
||||||
|
const entries: LibraryMosaicEntry[] = [
|
||||||
|
{
|
||||||
|
key: "favorites:all",
|
||||||
|
kind: "favorites",
|
||||||
|
scope: "all",
|
||||||
|
href: favoritesRouteUrl("all"),
|
||||||
|
ratio: WIDE,
|
||||||
|
label: "Favourites",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const seenScopes = new Set<FavoritesScope>(["all"]);
|
||||||
|
|
||||||
|
for (const lib of libraries) {
|
||||||
|
const ratio = assumedLibraryRatio(lib);
|
||||||
|
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
|
||||||
|
|
||||||
|
const scope = asFavoritesScope(lib.favoritesScope);
|
||||||
|
if (!scope || seenScopes.has(scope)) continue;
|
||||||
|
seenScopes.add(scope);
|
||||||
|
entries.push({
|
||||||
|
key: `favorites:${scope}`,
|
||||||
|
kind: "favorites",
|
||||||
|
scope,
|
||||||
|
href: favoritesRouteUrl(scope),
|
||||||
|
ratio,
|
||||||
|
label: `Favourite ${FAVORITE_SCOPE_LABELS[scope]}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
layoutMosaic,
|
||||||
|
layoutMosaicStrip,
|
||||||
|
mosaicTargetHeight,
|
||||||
|
type MosaicInput,
|
||||||
|
type MosaicRow,
|
||||||
|
} from "./mosaic";
|
||||||
|
|
||||||
|
const VIDEO = 16 / 9;
|
||||||
|
const SQUARE = 1;
|
||||||
|
const POSTER = 2 / 3;
|
||||||
|
|
||||||
|
function tiles(...ratios: number[]): MosaicInput[] {
|
||||||
|
return ratios.map((ratio, i) => ({ key: `t${i}`, ratio }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowWidth(row: MosaicRow, gap: number): number {
|
||||||
|
return row.tiles.reduce((sum, t) => sum + t.width, 0) + gap * (row.tiles.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tile bar the one that absorbs the rounding remainder keeps its ratio. */
|
||||||
|
function offRatioTiles(row: MosaicRow, tolerancePx = 1): number {
|
||||||
|
return row.tiles.filter((t) => Math.abs(t.width - t.ratio * t.height) > tolerancePx).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("layoutMosaic", () => {
|
||||||
|
const opts = { containerWidth: 1000, targetHeight: 160, gap: 8 };
|
||||||
|
|
||||||
|
it("fills the container width exactly on every row but the last", () => {
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
|
||||||
|
|
||||||
|
expect(rows.length).toBeGreaterThan(1);
|
||||||
|
for (const row of rows.slice(0, -1)) {
|
||||||
|
expect(rowWidth(row, opts.gap)).toBe(opts.containerWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overflows the container, last row included", () => {
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every tile in a row the same height", () => {
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const tile of row.tiles) {
|
||||||
|
expect(tile.height).toBe(row.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours each tile's aspect ratio — widths vary, nothing is squashed", () => {
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// At most the single remainder-absorbing tile may be off, and only by the
|
||||||
|
// few pixels the row was short of the container width.
|
||||||
|
expect(offRatioTiles(row)).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 16:9 tile is meaningfully wider than a 2:3 tile at the same height.
|
||||||
|
const all = rows.flatMap((r) => r.tiles);
|
||||||
|
const video = all.find((t) => t.key === "t0")!;
|
||||||
|
const poster = all.find((t) => t.key === "t2")!;
|
||||||
|
expect(video.width).toBeGreaterThan(poster.width * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps rows at or below the target height", () => {
|
||||||
|
const rows = layoutMosaic(tiles(...Array(12).fill(VIDEO)), opts);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.height).toBeLessThanOrEqual(opts.targetHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stretch a short last row across the whole container", () => {
|
||||||
|
// Two 16:9 tiles cannot fill 1000px at 160px tall (they want ~569px), so the
|
||||||
|
// last row must stay at the target height rather than blow up to fill.
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, VIDEO), opts);
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].height).toBe(opts.targetHeight);
|
||||||
|
expect(rowWidth(rows[0], opts.gap)).toBeLessThan(opts.containerWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks a last row that would otherwise overflow", () => {
|
||||||
|
// Five 16:9 tiles at 160px tall want ~1454px; the row has to come down.
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO, VIDEO, VIDEO), {
|
||||||
|
...opts,
|
||||||
|
targetHeight: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an extreme ratio instead of letting it own a row", () => {
|
||||||
|
const rows = layoutMosaic(tiles(20, SQUARE, SQUARE), { ...opts, maxRatio: 2.5 });
|
||||||
|
const panorama = rows.flatMap((r) => r.tiles).find((t) => t.key === "t0")!;
|
||||||
|
expect(panorama.width / panorama.height).toBeLessThanOrEqual(2.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing or nonsensical ratio as square rather than collapsing", () => {
|
||||||
|
const rows = layoutMosaic(
|
||||||
|
[
|
||||||
|
{ key: "nan", ratio: Number.NaN },
|
||||||
|
{ key: "zero", ratio: 0 },
|
||||||
|
{ key: "neg", ratio: -2 },
|
||||||
|
],
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
for (const tile of rows.flatMap((r) => r.tiles)) {
|
||||||
|
expect(tile.width).toBeCloseTo(tile.height, -1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing before the container has been measured", () => {
|
||||||
|
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, containerWidth: 0 })).toEqual([]);
|
||||||
|
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, targetHeight: 0 })).toEqual([]);
|
||||||
|
expect(layoutMosaic([], opts)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places every tile exactly once, in order", () => {
|
||||||
|
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER, SQUARE);
|
||||||
|
const placed = layoutMosaic(input, opts).flatMap((r) => r.tiles.map((t) => t.key));
|
||||||
|
expect(placed).toEqual(input.map((t) => t.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-packs when the container narrows", () => {
|
||||||
|
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO);
|
||||||
|
const wide = layoutMosaic(input, { ...opts, containerWidth: 1400 });
|
||||||
|
const narrow = layoutMosaic(input, { ...opts, containerWidth: 420 });
|
||||||
|
expect(narrow.length).toBeGreaterThan(wide.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mosaicTargetHeight", () => {
|
||||||
|
it("still fits two 16:9 tiles across a phone", () => {
|
||||||
|
const width = 360;
|
||||||
|
const height = mosaicTargetHeight(width);
|
||||||
|
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO), {
|
||||||
|
containerWidth: width,
|
||||||
|
targetHeight: height,
|
||||||
|
gap: 8,
|
||||||
|
});
|
||||||
|
expect(rows[0].tiles.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows with the container but stays within bounds", () => {
|
||||||
|
const widths = [0, 320, 600, 900, 1400, 3000];
|
||||||
|
const heights = widths.map(mosaicTargetHeight);
|
||||||
|
|
||||||
|
for (const h of heights) {
|
||||||
|
expect(h).toBeGreaterThanOrEqual(96);
|
||||||
|
expect(h).toBeLessThanOrEqual(190);
|
||||||
|
}
|
||||||
|
for (let i = 1; i < heights.length; i++) {
|
||||||
|
expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layoutMosaicStrip", () => {
|
||||||
|
it("gives one height and ratio-derived widths", () => {
|
||||||
|
const strip = layoutMosaicStrip(tiles(VIDEO, SQUARE, POSTER), 140);
|
||||||
|
|
||||||
|
expect(strip.map((t) => t.height)).toEqual([140, 140, 140]);
|
||||||
|
expect(strip[0].width).toBe(Math.round(VIDEO * 140));
|
||||||
|
expect(strip[1].width).toBe(140);
|
||||||
|
expect(strip[2].width).toBe(Math.round(POSTER * 140));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a height it cannot draw", () => {
|
||||||
|
expect(layoutMosaicStrip(tiles(VIDEO), 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Justified ("mosaic") tile layout — pure geometry, no DOM.
|
||||||
|
//
|
||||||
|
// The library overview and the home "Your Libraries" strip both show artwork of
|
||||||
|
// mixed shapes: square music covers next to 16:9 library backdrops next to 2:3
|
||||||
|
// posters. A CSS grid forces one box shape on all of them, so every tile that
|
||||||
|
// isn't that shape is cropped or letterboxed. This packs tiles into rows of a
|
||||||
|
// *shared height* and lets each keep its own width, so each tile is displayed at
|
||||||
|
// its own aspect ratio and nothing is distorted.
|
||||||
|
//
|
||||||
|
// Presentation only — nothing here knows what a library or a media item is.
|
||||||
|
//
|
||||||
|
// TRACES: UR-075 | DR-174 | UT-158, UT-159, UT-160
|
||||||
|
|
||||||
|
/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
|
||||||
|
export interface MosaicInput {
|
||||||
|
key: string;
|
||||||
|
/** width / height. 1 = square, 16/9 ≈ 1.78, 2/3 ≈ 0.67. */
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tile with its resolved pixel box. Generic so callers can hang whatever they
|
||||||
|
* need to render (the library, the label, a route) off the same object.
|
||||||
|
*/
|
||||||
|
export type MosaicTile<T extends MosaicInput = MosaicInput> = T & {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row of tiles, all sharing `height`. */
|
||||||
|
export interface MosaicRow<T extends MosaicInput = MosaicInput> {
|
||||||
|
height: number;
|
||||||
|
tiles: MosaicTile<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosaicOptions {
|
||||||
|
/** Usable width in px (already net of the container's own padding). */
|
||||||
|
containerWidth: number;
|
||||||
|
/** The height rows aim for. Rows land at or below it; see `layoutMosaic`. */
|
||||||
|
targetHeight: number;
|
||||||
|
/** Gap between tiles in a row, in px. Rows are justified around it. */
|
||||||
|
gap?: number;
|
||||||
|
/**
|
||||||
|
* Ratios outside this band are clamped. An extreme tile would otherwise take a
|
||||||
|
* whole row to itself (very wide) or shrink to a sliver (very tall); clamping
|
||||||
|
* costs a little crop on the outliers and keeps the mosaic readable.
|
||||||
|
*/
|
||||||
|
minRatio?: number;
|
||||||
|
maxRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
gap: 8,
|
||||||
|
minRatio: 0.5,
|
||||||
|
maxRatio: 2.5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ratio we can lay out: finite and positive, clamped into the band. */
|
||||||
|
function usableRatio(ratio: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(ratio) || ratio <= 0) return 1;
|
||||||
|
return clamp(ratio, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a row its pixel boxes.
|
||||||
|
*
|
||||||
|
* `justifyTo` is the width the row must fill *exactly* — rounding each tile
|
||||||
|
* independently leaves the row a pixel or two short or long, which reads as a
|
||||||
|
* ragged right edge, so the remainder is absorbed by the widest tile (where one
|
||||||
|
* pixel is least visible). A `null` justifies nothing: the last row keeps its
|
||||||
|
* natural width and is left-aligned.
|
||||||
|
*/
|
||||||
|
function buildRow<T extends MosaicInput>(
|
||||||
|
items: T[],
|
||||||
|
height: number,
|
||||||
|
gap: number,
|
||||||
|
justifyTo: number | null,
|
||||||
|
): MosaicRow<T> {
|
||||||
|
const h = Math.max(1, Math.round(height));
|
||||||
|
const tiles: MosaicTile<T>[] = items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
height: h,
|
||||||
|
width: Math.max(1, Math.round(item.ratio * h)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (justifyTo !== null && tiles.length > 0) {
|
||||||
|
const used = tiles.reduce((sum, t) => sum + t.width, 0) + gap * (tiles.length - 1);
|
||||||
|
const delta = justifyTo - used;
|
||||||
|
if (delta !== 0) {
|
||||||
|
let widest = 0;
|
||||||
|
for (let i = 1; i < tiles.length; i++) {
|
||||||
|
if (tiles[i].width > tiles[widest].width) widest = i;
|
||||||
|
}
|
||||||
|
tiles[widest].width = Math.max(1, tiles[widest].width + delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { height: h, tiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack `items` into justified rows.
|
||||||
|
*
|
||||||
|
* Tiles are added to a row until the height needed to fill `containerWidth` has
|
||||||
|
* fallen to `targetHeight` — at which point the row is closed at that height, so
|
||||||
|
* rows come out at or slightly below the target rather than above it. The final
|
||||||
|
* row is never stretched to fill the width: with one tile left over, justifying
|
||||||
|
* would blow it up to the full container width. It sits at `targetHeight`
|
||||||
|
* instead (or lower, if its natural fit is already shorter), left-aligned.
|
||||||
|
*
|
||||||
|
* Returns `[]` for a container with no width — a first paint before the element
|
||||||
|
* has been measured, which must render nothing rather than a row of 1px tiles.
|
||||||
|
*/
|
||||||
|
export function layoutMosaic<T extends MosaicInput>(
|
||||||
|
items: T[],
|
||||||
|
options: MosaicOptions,
|
||||||
|
): MosaicRow<T>[] {
|
||||||
|
const { containerWidth, targetHeight } = options;
|
||||||
|
const gap = options.gap ?? DEFAULTS.gap;
|
||||||
|
const minRatio = options.minRatio ?? DEFAULTS.minRatio;
|
||||||
|
const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;
|
||||||
|
|
||||||
|
if (containerWidth <= 0 || targetHeight <= 0 || items.length === 0) return [];
|
||||||
|
|
||||||
|
const normalized = items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
ratio: usableRatio(item.ratio, minRatio, maxRatio),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rows: MosaicRow<T>[] = [];
|
||||||
|
let current: T[] = [];
|
||||||
|
let ratioSum = 0;
|
||||||
|
|
||||||
|
for (const item of normalized) {
|
||||||
|
current.push(item);
|
||||||
|
ratioSum += item.ratio;
|
||||||
|
|
||||||
|
// Width left for artwork once this row's gaps are paid for.
|
||||||
|
const available = containerWidth - gap * (current.length - 1);
|
||||||
|
const height = available / ratioSum;
|
||||||
|
|
||||||
|
if (height <= targetHeight) {
|
||||||
|
rows.push(buildRow(current, height, gap, containerWidth));
|
||||||
|
current = [];
|
||||||
|
ratioSum = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
const available = containerWidth - gap * (current.length - 1);
|
||||||
|
const natural = available / ratioSum;
|
||||||
|
rows.push(buildRow(current, Math.min(natural, targetHeight), gap, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row height bounds — a phone must still fit two tiles, a desktop must not
|
||||||
|
* turn each library into a billboard. */
|
||||||
|
const MIN_TARGET_HEIGHT = 96;
|
||||||
|
const MAX_TARGET_HEIGHT = 190;
|
||||||
|
/** Roughly this many tiles per row, before ratios pull the count around. */
|
||||||
|
const TILES_PER_ROW = 4;
|
||||||
|
const MIN_TILE_WIDTH = 150;
|
||||||
|
const MAX_TILE_WIDTH = 300;
|
||||||
|
/** The width/height a "typical" tile is sized against. */
|
||||||
|
const NOMINAL_RATIO = 1.6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row height that suits the container it is drawn in: tall enough on a desktop
|
||||||
|
* to be worth looking at, short enough on a phone that two tiles still fit side
|
||||||
|
* by side. Callers may override it; this is what the grid picks unasked.
|
||||||
|
*/
|
||||||
|
export function mosaicTargetHeight(containerWidth: number): number {
|
||||||
|
if (containerWidth <= 0) return MIN_TARGET_HEIGHT;
|
||||||
|
const tileWidth = clamp(containerWidth / TILES_PER_ROW, MIN_TILE_WIDTH, MAX_TILE_WIDTH);
|
||||||
|
return Math.round(clamp(tileWidth / NOMINAL_RATIO, MIN_TARGET_HEIGHT, MAX_TARGET_HEIGHT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay tiles out as a single fixed-height row — the shape a horizontally
|
||||||
|
* scrolling strip wants. Same principle as `layoutMosaic`: one height, natural
|
||||||
|
* widths, no distortion.
|
||||||
|
*/
|
||||||
|
export function layoutMosaicStrip<T extends MosaicInput>(
|
||||||
|
items: T[],
|
||||||
|
height: number,
|
||||||
|
options: Pick<MosaicOptions, "minRatio" | "maxRatio"> = {},
|
||||||
|
): MosaicTile<T>[] {
|
||||||
|
const minRatio = options.minRatio ?? DEFAULTS.minRatio;
|
||||||
|
const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;
|
||||||
|
if (height <= 0) return [];
|
||||||
|
|
||||||
|
const h = Math.max(1, Math.round(height));
|
||||||
|
return items.map((item) => {
|
||||||
|
const ratio = usableRatio(item.ratio, minRatio, maxRatio);
|
||||||
|
return { ...item, ratio, height: h, width: Math.max(1, Math.round(ratio * h)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* VideoPlayer native-path reveal tests (Android / ExoPlayer)
|
||||||
|
*
|
||||||
|
* Reproduces "native video plays as audio with no picture" (DR-172).
|
||||||
|
*
|
||||||
|
* The poster/title card is an opaque `bg-black` overlay drawn while
|
||||||
|
* `isMediaReady` is false. Every signal that clears it — `canplay`,
|
||||||
|
* `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event and two
|
||||||
|
* `readyState` timeouts — comes from the HTML5 `<video>` element. On the native
|
||||||
|
* path there is no such element, so nothing ever cleared it: ExoPlayer decoded
|
||||||
|
* and fed its SurfaceView correctly the whole time, behind a black div.
|
||||||
|
*
|
||||||
|
* These tests pin the **flag-on** path: the backend reports native, the user
|
||||||
|
* opted in, and the video area must be revealed by the *backend's* own signals.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004, UR-041 | DR-182 | UT-185
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
|
|
||||||
|
// The native path is what these tests guard, so the opt-in flag is mocked ON.
|
||||||
|
// Stated explicitly rather than inherited: the default has moved twice
|
||||||
|
// (DR-161 on, DR-172 off) and a test that inherits it silently changes meaning.
|
||||||
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
experimentalNativeVideo: {
|
||||||
|
subscribe: (run: (v: boolean) => void) => {
|
||||||
|
run(true);
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
set: () => {},
|
||||||
|
current: () => true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async (channel: string, handler: any) => {
|
||||||
|
channelHandlers[channel] = handler;
|
||||||
|
return () => {
|
||||||
|
delete channelHandlers[channel];
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const playerPlayItem = vi.fn(async () => ({
|
||||||
|
// What Android reports: native ExoPlayer backend, no HTML5 element.
|
||||||
|
useHtml5Element: false,
|
||||||
|
backend: "exoplayer",
|
||||||
|
state: { kind: "playing" },
|
||||||
|
}));
|
||||||
|
const playerStop = vi.fn(async () => ({}));
|
||||||
|
const playerReportState = vi.fn(async () => null);
|
||||||
|
|
||||||
|
vi.mock("$lib/api/bindings", () => ({
|
||||||
|
commands: {
|
||||||
|
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
||||||
|
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
||||||
|
playerReportState: (...a: any[]) => playerReportState(...(a as [])),
|
||||||
|
playerReportPosition: vi.fn(async () => null),
|
||||||
|
playerReportMediaLoaded: vi.fn(async () => null),
|
||||||
|
playerSeek: vi.fn(async () => ({})),
|
||||||
|
playerPlay: vi.fn(async () => ({})),
|
||||||
|
playerPause: vi.fn(async () => ({})),
|
||||||
|
playerToggle: vi.fn(async () => ({ state: "playing" })),
|
||||||
|
playerSeekVideo: vi.fn(async (_h: string, position: number) => ({
|
||||||
|
strategy: "native",
|
||||||
|
position,
|
||||||
|
})),
|
||||||
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||||
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||||
|
playerSetSleepTimer: vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 })),
|
||||||
|
playerCancelSleepTimer: vi.fn(async () => ({
|
||||||
|
mode: { kind: "off" },
|
||||||
|
remainingSeconds: 0,
|
||||||
|
})),
|
||||||
|
playerGetStreamingQualities: vi.fn(async () => []),
|
||||||
|
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||||
|
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||||
|
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/auth", () => ({
|
||||||
|
auth: {
|
||||||
|
getUserId: () => "user-1",
|
||||||
|
getRepository: () => ({
|
||||||
|
getHandle: () => "repo-1",
|
||||||
|
getSubtitleUrl: async () => "",
|
||||||
|
jrayActorsAt: async () => [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({
|
||||||
|
goto: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The immersive bridge is native-only; assert the call rather than its effect.
|
||||||
|
const enterImmersive = vi.fn();
|
||||||
|
vi.mock("$lib/utils/immersive", () => ({
|
||||||
|
enterImmersive: (...a: any[]) => enterImmersive(...a),
|
||||||
|
exitImmersive: vi.fn(),
|
||||||
|
isImmersiveSupported: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
|
import { player } from "$lib/stores/player";
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
|
function makeEpisode(): MediaItem {
|
||||||
|
return {
|
||||||
|
id: "ep1",
|
||||||
|
name: "Episode 1",
|
||||||
|
kind: "episode",
|
||||||
|
durationMs: 24 * 60 * 1000,
|
||||||
|
} as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountNativePlayer() {
|
||||||
|
const utils = render(VideoPlayer, {
|
||||||
|
props: {
|
||||||
|
media: makeEpisode(),
|
||||||
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
|
mediaSourceId: "src-1",
|
||||||
|
needsTranscoding: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||||
|
// The native path must NOT be overridden to HTML5 and must NOT be stopped —
|
||||||
|
// if it were, these tests would be guarding the HTML5 path by accident.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(utils.container.querySelector("video")).toBeNull()
|
||||||
|
);
|
||||||
|
expect(playerStop).not.toHaveBeenCalled();
|
||||||
|
return utils;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The opaque poster/title card drawn while the media is not yet revealed. */
|
||||||
|
function poster(container: HTMLElement): HTMLElement | null {
|
||||||
|
return container.querySelector('[data-testid="video-poster"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report backend playback state the way the app actually does.
|
||||||
|
*
|
||||||
|
* NOT via `player://position-update` / `player://state-changed`: those channels
|
||||||
|
* are **never emitted by the backend**, which is exactly the trap this test
|
||||||
|
* exists to avoid. An earlier version of it fired those handlers by hand, went
|
||||||
|
* green, and guarded nothing — on the device the poster stayed up while
|
||||||
|
* ExoPlayer played behind it. `playerEvents.ts` feeds the `player` store, and
|
||||||
|
* the store is what the component must read.
|
||||||
|
*/
|
||||||
|
async function backendReports(
|
||||||
|
kind: "playing" | "paused" | "error",
|
||||||
|
position = 0,
|
||||||
|
duration = 0
|
||||||
|
) {
|
||||||
|
const media = makeEpisode();
|
||||||
|
if (kind === "playing") player.setPlaying(media, position, duration);
|
||||||
|
else if (kind === "paused") player.setPaused(media, position, duration);
|
||||||
|
else player.setError("Decoder failed", media);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VideoPlayer native path reveals the video (DR-172)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||||
|
player.setIdle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the poster up until the backend reports something", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
// Nothing has been heard from ExoPlayer yet, so the title card is correct.
|
||||||
|
expect(poster(container)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the poster when the backend reports playing", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
|
||||||
|
await backendReports("playing", 0, 1440);
|
||||||
|
|
||||||
|
// The surface is rendering behind the webview; an opaque overlay over it is
|
||||||
|
// exactly the "audio with no picture" defect.
|
||||||
|
await waitFor(() => expect(poster(container)).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the poster when the backend reports a paused position with a duration", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
|
||||||
|
// Backstop for a backend that starts paused: a position carrying a real
|
||||||
|
// duration means the media is loaded and the surface has content,
|
||||||
|
// mirroring the HTML5 readyState fallback.
|
||||||
|
await backendReports("paused", 12, 1440);
|
||||||
|
|
||||||
|
await waitFor(() => expect(poster(container)).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the play overlay when the backend resumes after a pause (DR-186)", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
|
||||||
|
await backendReports("paused", 5, 1440);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||||
|
);
|
||||||
|
|
||||||
|
await backendReports("playing", 6, 1440);
|
||||||
|
|
||||||
|
// This overlay is `bg-black/30` across the whole video area: left up, it
|
||||||
|
// both dims and covers the ExoPlayer surface while it plays. Before the
|
||||||
|
// mirror, nothing after init could take it down, because the only other
|
||||||
|
// writer was the never-emitted `player://state-changed` channel.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the play overlay again when the backend reports paused (DR-186)", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
|
||||||
|
await backendReports("playing", 5, 1440);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="play-overlay"]')).toBeNull()
|
||||||
|
);
|
||||||
|
|
||||||
|
await backendReports("paused", 6, 1440);
|
||||||
|
|
||||||
|
// The mirror has to work in both directions, or pausing leaves no affordance
|
||||||
|
// to resume.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="play-overlay"]')).not.toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the system bars on entry, not only on the fullscreen button (DR-187)", async () => {
|
||||||
|
await mountNativePlayer();
|
||||||
|
|
||||||
|
// The player owns the whole screen; on the native path the system bars would
|
||||||
|
// otherwise sit directly on top of the ExoPlayer surface.
|
||||||
|
expect(enterImmersive).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the control bar once playback starts, however late (DR-189)", async () => {
|
||||||
|
// Reproduce the device sequence: the backend is still starting when the
|
||||||
|
// player mounts, so playback begins *after* the first countdown window.
|
||||||
|
playerPlayItem.mockResolvedValueOnce({
|
||||||
|
useHtml5Element: false,
|
||||||
|
backend: "exoplayer",
|
||||||
|
state: { kind: "loading" },
|
||||||
|
} as any);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const utils = render(VideoPlayer, {
|
||||||
|
props: {
|
||||||
|
media: makeEpisode(),
|
||||||
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
|
mediaSourceId: "src-1",
|
||||||
|
needsTranscoding: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// The three seconds after entry elapse while the backend is still
|
||||||
|
// starting, so the bar correctly stays up. This is the exact window that
|
||||||
|
// defeated the first attempt: a one-shot timer armed on entry fired here,
|
||||||
|
// declined, and was never re-armed.
|
||||||
|
await vi.advanceTimersByTimeAsync(3500);
|
||||||
|
expect(utils.container.querySelector("[data-player-controls]")?.className).not.toContain("opacity-0");
|
||||||
|
|
||||||
|
// Playback starts late; the countdown has to restart on its own.
|
||||||
|
player.setPlaying(makeEpisode(), 5, 1440);
|
||||||
|
await vi.advanceTimersByTimeAsync(3500);
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(utils.container.querySelector("[data-player-controls]")?.className).toContain("opacity-0")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports webview element state on the native path (DR-195)", async () => {
|
||||||
|
// The report that mattered came from the 10-second progress interval, so
|
||||||
|
// the test has to reach it: the interval needs `onReportProgress` wired and
|
||||||
|
// `isPlaying` true, then time has to pass. Asserting on a freshly mounted
|
||||||
|
// player proves nothing — an earlier version of this test did exactly that
|
||||||
|
// and passed with the guard deleted.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const utils = render(VideoPlayer, {
|
||||||
|
props: {
|
||||||
|
media: makeEpisode(),
|
||||||
|
streamUrl: "http://server/videos/ep1/master.m3u8",
|
||||||
|
mediaSourceId: "src-1",
|
||||||
|
needsTranscoding: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onReportProgress: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(utils.container.querySelector("video")).toBeNull();
|
||||||
|
|
||||||
|
// Backend playing, so the interval's `isPlaying` guard is satisfied.
|
||||||
|
player.setPlaying(makeEpisode(), 5, 1440);
|
||||||
|
await vi.advanceTimersByTimeAsync(25_000);
|
||||||
|
|
||||||
|
// `html5_playing` is Rust's record of "a webview element is active", and
|
||||||
|
// `toggle_playback`/`play`/`pause` all route transport to that element
|
||||||
|
// whenever it is set. Reporting it with no element in existence is what
|
||||||
|
// left the pause button dead on the native path — from the surface tap,
|
||||||
|
// the control bar, and a direct `player_toggle` invocation alike — while
|
||||||
|
// seek and skip kept working, because they decide elsewhere.
|
||||||
|
expect(playerReportState).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear the poster on an errored backend", async () => {
|
||||||
|
const { container } = await mountNativePlayer();
|
||||||
|
|
||||||
|
await backendReports("error");
|
||||||
|
|
||||||
|
// Revealing here would replace the title card with a transparent hole
|
||||||
|
// showing the launcher through the app.
|
||||||
|
expect(poster(container)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,12 +23,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
// ---- Mocks (must precede component import) --------------------------------
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||||
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||||
// rendering and stops the native backend. That flag now defaults to *on*
|
// rendering and stops the native backend. That is the default again (DR-172,
|
||||||
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
// after native video shipped as audio with no picture), so this mock now agrees
|
||||||
// default no longer selects this path and the tests have to say which path they
|
// with the default rather than opposing it — kept explicit so the tests state
|
||||||
// are guarding rather than inherit it. (DR-161)
|
// which path they guard instead of inheriting whatever the default happens to be.
|
||||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
import { videoFitClass } from "./videoFit";
|
import { videoFitClass } from "./videoFit";
|
||||||
|
import { fatalNetworkErrorAction } from "./hlsRecovery";
|
||||||
import {
|
import {
|
||||||
|
subtitleStreamsOf,
|
||||||
resolveSubtitleTracks,
|
resolveSubtitleTracks,
|
||||||
reconcileSelectedSubtitle,
|
reconcileSelectedSubtitle,
|
||||||
videoCrossOriginMode,
|
videoCrossOriginMode,
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
type RenderableSubtitleTrack,
|
type RenderableSubtitleTrack,
|
||||||
} from "./subtitleTracks";
|
} from "./subtitleTracks";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import { playbackPosition, playerState } from "$lib/stores/player";
|
import { playbackPosition, playbackDuration, playerState } from "$lib/stores/player";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
import { playerController } from "$lib/player";
|
import { playerController } from "$lib/player";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +40,8 @@
|
|||||||
enableNativeVideoCompositing,
|
enableNativeVideoCompositing,
|
||||||
disableNativeVideoCompositing,
|
disableNativeVideoCompositing,
|
||||||
} from "$lib/utils/videoSurface";
|
} from "$lib/utils/videoSurface";
|
||||||
|
import { nativeSignalRevealsVideo } from "./mediaReady";
|
||||||
|
import { shouldHideControls } from "./controlsVisibility";
|
||||||
import {
|
import {
|
||||||
isPipSupported,
|
isPipSupported,
|
||||||
enterPip,
|
enterPip,
|
||||||
@@ -152,7 +156,9 @@
|
|||||||
let pipListenerCleanup: (() => void) | null = null;
|
let pipListenerCleanup: (() => void) | null = null;
|
||||||
let showSleepTimerModal = $state(false);
|
let showSleepTimerModal = $state(false);
|
||||||
let isBuffering = $state(false);
|
let isBuffering = $state(false);
|
||||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
// Bumped by every reveal so the auto-hide effect restarts its countdown even
|
||||||
|
// when no other input to that decision changed (a tap during playback).
|
||||||
|
let lastControlsInteraction = $state(0);
|
||||||
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
||||||
let isSeeking = $state(false);
|
let isSeeking = $state(false);
|
||||||
// Capture only the initial streamUrl prop; later prop changes are applied via
|
// Capture only the initial streamUrl prop; later prop changes are applied via
|
||||||
@@ -332,13 +338,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get available subtitle tracks from media streams
|
// The subtitle streams the menu offers — the same list the <track> children
|
||||||
|
// and the native play request are built from, so the menu can never name a
|
||||||
|
// track the player was never given. subtitleStreamsOf() also drops the ones
|
||||||
|
// the backend says it cannot deliver as a sidecar (image-based PGS/DVD/DVB,
|
||||||
|
// which only server burn-in could show and we never ask for — DR-176).
|
||||||
|
// TRACES: UR-020 | DR-176 | UT-168
|
||||||
const subtitleTracks = $derived(() => {
|
const subtitleTracks = $derived(() => {
|
||||||
if (!media || !media.mediaStreams) {
|
if (!media || !media.mediaStreams) {
|
||||||
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
|
const tracks = subtitleStreamsOf(media.mediaStreams);
|
||||||
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
@@ -434,6 +445,88 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-hide the control bar.
|
||||||
|
//
|
||||||
|
// An `$effect` rather than a timer armed by input, because the conditions that
|
||||||
|
// *permit* hiding arrive on their own schedule. The first attempt armed a
|
||||||
|
// one-shot timer from `revealControls()` on entry; three seconds later
|
||||||
|
// playback had not started yet, `shouldHideControls` correctly declined, and
|
||||||
|
// nothing re-armed it — so the bar sat over the video for the whole film. The
|
||||||
|
// timer has to follow the state, not the input event.
|
||||||
|
//
|
||||||
|
// Re-runs whenever any input changes: each run cancels the previous timer, so
|
||||||
|
// starting playback, closing a menu or finishing a seek re-arms it, and
|
||||||
|
// pausing or opening a menu cancels it. `lastControlsInteraction` is read so a
|
||||||
|
// tap restarts the countdown even when nothing else changed.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||||
|
$effect(() => {
|
||||||
|
void lastControlsInteraction;
|
||||||
|
if (!showControls) return;
|
||||||
|
if (
|
||||||
|
!shouldHideControls({
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
showControls = false;
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reveal the video on the native path.
|
||||||
|
//
|
||||||
|
// The poster/title card is opaque and covers the whole video area, so on this
|
||||||
|
// path it is the only thing between the viewer and the ExoPlayer surface —
|
||||||
|
// every other markMediaReady() call site is a `<video>` element event, and
|
||||||
|
// there is no `<video>` here.
|
||||||
|
//
|
||||||
|
// Driven from the same stores as the seek bar above, deliberately: the
|
||||||
|
// `player://position-update` and `player://state-changed` channels the native
|
||||||
|
// branch subscribes to are **never emitted by the backend** (see the comment
|
||||||
|
// on the effect above — the seek bar had to be moved off them for the same
|
||||||
|
// reason). Hooking the reveal to those channels looks right, passes a test
|
||||||
|
// that fires them by hand, and does nothing on a device.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-004 | DR-182 | UT-185
|
||||||
|
$effect(() => {
|
||||||
|
if (useHtml5Element || isMediaReady) return;
|
||||||
|
const state = $playerState.kind;
|
||||||
|
const position = $playbackPosition;
|
||||||
|
const duration = $playbackDuration;
|
||||||
|
if (
|
||||||
|
nativeSignalRevealsVideo({ kind: "state", state }) ||
|
||||||
|
nativeSignalRevealsVideo({ kind: "position", position, duration })
|
||||||
|
) {
|
||||||
|
markMediaReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror the backend's play/pause into the UI on the native path.
|
||||||
|
//
|
||||||
|
// `isPlaying` is assigned once from the player_play_item response and then
|
||||||
|
// only by the `player://state-changed` listener — a channel the backend never
|
||||||
|
// emits, exactly as for the reveal above. So on the native path it was
|
||||||
|
// whatever the initial response said, forever: with ExoPlayer playing, the UI
|
||||||
|
// still believed it was paused, which raised the `bg-black/30` play overlay
|
||||||
|
// over the video surface and left the transport button showing ▶. The video
|
||||||
|
// was both dimmed and covered while it played.
|
||||||
|
//
|
||||||
|
// The player is the authoritative source of playback state and the UI is a
|
||||||
|
// consumer of it (see the architecture docs), so this reads the same store
|
||||||
|
// `playerEvents.ts` feeds rather than tracking it locally. HTML5 keeps its own
|
||||||
|
// element-event wiring, which is authoritative for that path.
|
||||||
|
//
|
||||||
|
// TRACES: UR-003, UR-005 | DR-186 | UT-187
|
||||||
|
$effect(() => {
|
||||||
|
if (useHtml5Element) return;
|
||||||
|
isPlaying = $playerState.kind === "playing";
|
||||||
|
});
|
||||||
|
|
||||||
// Set up HLS.js for HLS streams
|
// Set up HLS.js for HLS streams
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
|
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
|
||||||
@@ -533,26 +626,32 @@
|
|||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
console.error('[VideoPlayer] HLS error:', data);
|
console.error('[VideoPlayer] HLS error:', data);
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
// Check if we're near the end of the video - if so, this is likely
|
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||||
// end-of-stream rather than a real error. Jellyfin transcoded HLS
|
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||||
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
|
// here identically and only the position tells them apart.
|
||||||
|
// `currentTime` is already absolute — see hlsRecovery.ts.
|
||||||
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
|
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
|
||||||
const effectiveTime = currentTime + seekOffset;
|
|
||||||
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
hlsFatalRecoveryAttempts++;
|
hlsFatalRecoveryAttempts++;
|
||||||
if (isNearEnd) {
|
switch (fatalNetworkErrorAction({
|
||||||
// Near end of stream - treat as natural end, don't restart
|
positionSeconds: currentTime,
|
||||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
knownDurationSeconds: knownDuration,
|
||||||
notifyEnded();
|
attempts: hlsFatalRecoveryAttempts,
|
||||||
} else if (hlsFatalRecoveryAttempts <= 3) {
|
})) {
|
||||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
case 'ended':
|
||||||
hls!.startLoad();
|
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||||
} else {
|
notifyEnded();
|
||||||
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
break;
|
||||||
hls!.destroy();
|
case 'retry':
|
||||||
|
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||||
|
hls!.startLoad();
|
||||||
|
break;
|
||||||
|
case 'giveUp':
|
||||||
|
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
||||||
|
hls!.destroy();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
@@ -682,6 +781,21 @@
|
|||||||
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The video player owns the whole screen, so the system bars go away with it
|
||||||
|
// — not only when the fullscreen button is pressed, which was the sole
|
||||||
|
// caller of enterImmersive(). The status and navigation bars stayed painted
|
||||||
|
// over the player on entry, and on the native path they sit directly on top
|
||||||
|
// of the ExoPlayer surface, which fills the content view.
|
||||||
|
//
|
||||||
|
// Synchronous, before any await, per the native-mode pitfall above. Paired
|
||||||
|
// with the unconditional exitImmersive() in onDestroy. (UR-066, DR-187)
|
||||||
|
enterImmersive();
|
||||||
|
|
||||||
|
// Arm the control-bar auto-hide on entry. Without this the bar only ever
|
||||||
|
// hides after the first pointer/touch event, which on a touchscreen meant
|
||||||
|
// "after the user happens to tap" — and before DR-189 wired touch up, never.
|
||||||
|
revealControls();
|
||||||
|
|
||||||
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
||||||
if (media && currentStreamUrl) {
|
if (media && currentStreamUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -902,6 +1016,7 @@
|
|||||||
progressInterval = setInterval(() => {
|
progressInterval = setInterval(() => {
|
||||||
if (isPlaying && !isSeeking && onReportProgress) {
|
if (isPlaying && !isSeeking && onReportProgress) {
|
||||||
onReportProgress(currentTime, false, reportMediaId);
|
onReportProgress(currentTime, false, reportMediaId);
|
||||||
|
mirrorElementStateToRust(false);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1450,33 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the **webview element's** play/pause and position into Rust.
|
||||||
|
*
|
||||||
|
* Only ever when the element is what renders. `html5_playing` is Rust's record
|
||||||
|
* of "a webview element is active and in this state", and `toggle_playback`,
|
||||||
|
* `play` and `pause` all route transport to that element when it is set. So
|
||||||
|
* reporting it from the native path is not a harmless extra: it hands
|
||||||
|
* transport authority to an element that does not exist, and every play/pause
|
||||||
|
* intent is then emitted into the void. That is exactly what made the pause
|
||||||
|
* button dead on the native path — from the on-screen tap, the control bar,
|
||||||
|
* and even a direct `player_toggle` invocation — while seek and skip kept
|
||||||
|
* working, because they decide elsewhere.
|
||||||
|
*
|
||||||
|
* This lived in the player route's reporting callbacks, which cannot tell the
|
||||||
|
* two rendering paths apart and so mirrored unconditionally — including from
|
||||||
|
* the 10-second progress interval, which is why the flag came back after
|
||||||
|
* DR-193 cleared it at load. It belongs here, where `useHtml5Element` is
|
||||||
|
* known.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005, UR-003 | DR-195 | UT-189
|
||||||
|
*/
|
||||||
|
function mirrorElementStateToRust(paused: boolean) {
|
||||||
|
if (!useHtml5Element) return;
|
||||||
|
html5Adapter.reportState(paused ? "paused" : "playing", reportMediaId ?? null);
|
||||||
|
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
function handlePlay() {
|
function handlePlay() {
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||||
@@ -1599,14 +1741,19 @@
|
|||||||
// Determine the target URL + how the element/offset should be positioned.
|
// Determine the target URL + how the element/offset should be positioned.
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
if (needsTranscoding && onSeek) {
|
if (needsTranscoding && onSeek) {
|
||||||
// Transcoded HLS can't seek by setting currentTime — the stream must be
|
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
||||||
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
|
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
||||||
// The reloaded segment's timeline starts at 0, so seekOffset carries the
|
// position on an HLS playlist is copied onto every segment URI and
|
||||||
// absolute base and the element seeks to 0 (handled on canplay).
|
// rejected with 400 (DR-181). So there is no base to carry — the element
|
||||||
|
// is seeked to the absolute position on canplay, exactly like a direct
|
||||||
|
// stream. This previously set seekOffset = pos, which paired with a URL
|
||||||
|
// that really did start there; leaving it would now display `pos` while
|
||||||
|
// playing the opening titles.
|
||||||
|
// TRACES: UR-040, UR-004 | DR-181
|
||||||
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
||||||
seekOffset = pos;
|
seekOffset = 0;
|
||||||
currentTime = pos;
|
currentTime = pos;
|
||||||
pendingForegroundSeek = 0;
|
pendingForegroundSeek = pos;
|
||||||
} else {
|
} else {
|
||||||
// Direct stream: reload the original URL and seek the element to pos.
|
// Direct stream: reload the original URL and seek the element to pos.
|
||||||
targetUrl = streamUrl;
|
targetUrl = streamUrl;
|
||||||
@@ -1658,18 +1805,25 @@
|
|||||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseMove() {
|
/**
|
||||||
|
* Show the control bar and arm its auto-hide.
|
||||||
|
*
|
||||||
|
* This used to be `handleMouseMove` and was wired *only* to the container's
|
||||||
|
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the
|
||||||
|
* timer was never armed and the bar stayed up for the whole film — hidden in
|
||||||
|
* plain sight while the native video surface was itself invisible. It is now
|
||||||
|
* armed on entry and on every touch interaction as well.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||||
|
*/
|
||||||
|
function revealControls() {
|
||||||
showControls = true;
|
showControls = true;
|
||||||
if (controlsTimeout) {
|
lastControlsInteraction = Date.now();
|
||||||
clearTimeout(controlsTimeout);
|
|
||||||
}
|
|
||||||
controlsTimeout = setTimeout(() => {
|
|
||||||
if (isPlaying) {
|
|
||||||
showControls = false;
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept as the mouse entry point; desktop still drives it from pointer motion.
|
||||||
|
const handleMouseMove = revealControls;
|
||||||
|
|
||||||
async function seekRelative(seconds: number) {
|
async function seekRelative(seconds: number) {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
|
|
||||||
@@ -1832,6 +1986,10 @@
|
|||||||
playerGestureActive = false;
|
playerGestureActive = false;
|
||||||
swipeGestureActive = false;
|
swipeGestureActive = false;
|
||||||
swipeType = null;
|
swipeType = null;
|
||||||
|
// Touch is the only input on the platform this player mostly runs on, and
|
||||||
|
// it is what `mousemove` never covers: show the bar and re-arm its hide.
|
||||||
|
// (DR-189)
|
||||||
|
revealControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2099,7 +2257,10 @@
|
|||||||
|
|
||||||
<!-- Title card with loading spinner (Loading state from DR-001) -->
|
<!-- Title card with loading spinner (Loading state from DR-001) -->
|
||||||
{#if !isMediaReady}
|
{#if !isMediaReady}
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-black">
|
<div
|
||||||
|
data-testid="video-poster"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black"
|
||||||
|
>
|
||||||
<!-- Poster/Title Card -->
|
<!-- Poster/Title Card -->
|
||||||
{#if media?.imageId}
|
{#if media?.imageId}
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -2178,6 +2339,7 @@
|
|||||||
See DR-098. -->
|
See DR-098. -->
|
||||||
<button
|
<button
|
||||||
data-player-surface
|
data-player-surface
|
||||||
|
data-testid="play-overlay"
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||||
onclick={handleSurfaceClick}
|
onclick={handleSurfaceClick}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
@@ -2365,8 +2527,9 @@
|
|||||||
disabled={changingQuality}
|
disabled={changingQuality}
|
||||||
aria-label="Select streaming quality"
|
aria-label="Select streaming quality"
|
||||||
>
|
>
|
||||||
|
<!-- Speedometer: bitrate ceiling -->
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
<path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render } from "@testing-library/svelte";
|
import { render } from "@testing-library/svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import VideoPlayer from "./VideoPlayer.svelte";
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||||
|
|
||||||
@@ -128,6 +129,23 @@ function renderPlayer() {
|
|||||||
describe("VideoPlayer tap surface (real component)", () => {
|
describe("VideoPlayer tap surface (real component)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
|
||||||
|
// real component against the real bindings, which bottom out in the globally
|
||||||
|
// mocked `invoke`. That mock resolves `undefined` for every command, so the
|
||||||
|
// commands whose results are *rendered* have to be answered here: the
|
||||||
|
// quality picker assigns the result straight to state and then does
|
||||||
|
// `streamingQualities.length` in the template, which throws (asynchronously,
|
||||||
|
// outside any test) on undefined and fails the run with an unhandled error.
|
||||||
|
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||||
|
switch (cmd) {
|
||||||
|
case "player_get_streaming_qualities":
|
||||||
|
return [];
|
||||||
|
case "player_get_video_settings":
|
||||||
|
return { streamingQuality: "original" };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a single tap on the video toggles play/pause exactly once", async () => {
|
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
// ---- Mocks (must precede component import) --------------------------------
|
// ---- Mocks (must precede component import) --------------------------------
|
||||||
|
|
||||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||||
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||||
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||||
// rendering and stops the native backend. That flag now defaults to *on*
|
// rendering and stops the native backend. That is the default again (DR-172,
|
||||||
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
// after native video shipped as audio with no picture), so this mock now agrees
|
||||||
// default no longer selects this path and the tests have to say which path they
|
// with the default rather than opposing it — kept explicit so the tests state
|
||||||
// are guarding rather than inherit it. (DR-161)
|
// which path they guard instead of inheriting whatever the default happens to be.
|
||||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Control-bar auto-hide rule (DR-189).
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { shouldHideControls } from "./controlsVisibility";
|
||||||
|
|
||||||
|
const playing = { isPlaying: true, isSeeking: false, menuOpen: false };
|
||||||
|
|
||||||
|
describe("shouldHideControls", () => {
|
||||||
|
it("hides the bar during uninterrupted playback", () => {
|
||||||
|
expect(shouldHideControls(playing)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bar while paused", () => {
|
||||||
|
// A user who paused by tapping the surface has no other way back.
|
||||||
|
expect(shouldHideControls({ ...playing, isPlaying: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bar while seeking", () => {
|
||||||
|
// The position readout is the point of the bar mid-seek.
|
||||||
|
expect(shouldHideControls({ ...playing, isSeeking: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bar while a menu is open", () => {
|
||||||
|
// The menus are anchored to the bar; hiding it takes the open menu with it.
|
||||||
|
expect(shouldHideControls({ ...playing, menuOpen: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* When the player's control bar may auto-hide.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-066 | DR-189 | UT-188
|
||||||
|
*
|
||||||
|
* The bar's hide timer used to be armed from exactly one place — the container's
|
||||||
|
* `onmousemove`. A touchscreen never fires `mousemove`, so on Android the timer
|
||||||
|
* was never set and the bar stayed on screen for the whole film. It went
|
||||||
|
* unnoticed while the video itself was invisible: with nothing to obscure, a
|
||||||
|
* permanent control bar looks like the UI, not like a defect.
|
||||||
|
*
|
||||||
|
* The decision is separated from the timer so it can be tested without a clock
|
||||||
|
* or a DOM: it is a rule about state, and the parts that were wrong here were
|
||||||
|
* the conditions, not the `setTimeout`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Everything that decides whether the bar may disappear right now. */
|
||||||
|
export interface ControlsHideContext {
|
||||||
|
/** Hiding controls over a paused player strands the user with no affordance. */
|
||||||
|
isPlaying: boolean;
|
||||||
|
/** A seek in flight is exactly when the position readout is worth watching. */
|
||||||
|
isSeeking: boolean;
|
||||||
|
/** True while any of the track / subtitle / quality menus is open. */
|
||||||
|
menuOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the control bar may hide now.
|
||||||
|
*
|
||||||
|
* Requires playback to be running: a paused player keeps its controls, which is
|
||||||
|
* both the convention and the only way back for a user who paused by tapping.
|
||||||
|
* A menu open over the bar pins it too — the menus are anchored to the bar, so
|
||||||
|
* hiding it would take the open menu with it, mid-interaction.
|
||||||
|
*/
|
||||||
|
export function shouldHideControls(ctx: ControlsHideContext): boolean {
|
||||||
|
if (!ctx.isPlaying) return false;
|
||||||
|
if (ctx.isSeeking) return false;
|
||||||
|
if (ctx.menuOpen) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { fatalNetworkErrorAction } from "./hlsRecovery";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fatal hls.js network error mid-film must be retried, not reported as the
|
||||||
|
* end of the stream — reporting "ended" hands control to autoplay and skips to
|
||||||
|
* the next item while the user is still watching this one.
|
||||||
|
*
|
||||||
|
* The position the player displays is *already absolute*: the RAF loop sets
|
||||||
|
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
|
||||||
|
* offset a second time doubles the apparent position, and after a quality
|
||||||
|
* switch or a transcoded seek the offset is the whole resume position — so past
|
||||||
|
* roughly the halfway mark the doubled value clears the near-end threshold and
|
||||||
|
* every transient error is misread as the end.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
||||||
|
*/
|
||||||
|
describe("fatalNetworkErrorAction", () => {
|
||||||
|
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
|
||||||
|
// 90-minute film, quality switched at the 50-minute mark: the reloaded
|
||||||
|
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
|
||||||
|
// the displayed position — already absolute — is 3000s of 5400s, 56%
|
||||||
|
// through and nowhere near the end.
|
||||||
|
const action = fatalNetworkErrorAction({
|
||||||
|
positionSeconds: 3000,
|
||||||
|
knownDurationSeconds: 5400,
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe("retry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a failure in the last tenth of the stream as the end", () => {
|
||||||
|
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
|
||||||
|
// genuine end-of-stream arrives as a fatal network error.
|
||||||
|
const action = fatalNetworkErrorAction({
|
||||||
|
positionSeconds: 5300,
|
||||||
|
knownDurationSeconds: 5400,
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe("ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying once the recovery budget is spent", () => {
|
||||||
|
const action = fatalNetworkErrorAction({
|
||||||
|
positionSeconds: 60,
|
||||||
|
knownDurationSeconds: 5400,
|
||||||
|
attempts: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe("giveUp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when the runtime is not known yet", () => {
|
||||||
|
const action = fatalNetworkErrorAction({
|
||||||
|
positionSeconds: 120,
|
||||||
|
knownDurationSeconds: 0,
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe("retry");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* What to do about a *fatal* hls.js network error.
|
||||||
|
*
|
||||||
|
* Jellyfin's transcoded HLS streams do not always terminate with an
|
||||||
|
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
|
||||||
|
* that broke: both arrive as a fatal network error. The only thing separating
|
||||||
|
* them is how far playback had got, which is why this decision is worth
|
||||||
|
* isolating from the player component — read the position wrong and a
|
||||||
|
* recoverable stall turns into a skip to the next item.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
|
||||||
|
const NEAR_END_FRACTION = 0.9;
|
||||||
|
|
||||||
|
/** How many times to ask hls.js to resume before giving up on the stream. */
|
||||||
|
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
|
||||||
|
|
||||||
|
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
|
||||||
|
|
||||||
|
export interface FatalNetworkErrorInput {
|
||||||
|
/**
|
||||||
|
* Absolute position in the media, in seconds — the value the player displays.
|
||||||
|
*
|
||||||
|
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
|
||||||
|
* transcode seek offset again. After a quality switch or a transcoded seek the
|
||||||
|
* offset *is* the resume position, so double-counting it puts an apparent
|
||||||
|
* position past the near-end threshold from roughly halfway through, and every
|
||||||
|
* transient error then ends playback.
|
||||||
|
*/
|
||||||
|
positionSeconds: number;
|
||||||
|
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
|
||||||
|
knownDurationSeconds: number;
|
||||||
|
/** Recovery attempts already made against this hls.js instance. */
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a failure at this position should be read as the stream ending. */
|
||||||
|
export function isNearEndOfStream(
|
||||||
|
positionSeconds: number,
|
||||||
|
knownDurationSeconds: number
|
||||||
|
): boolean {
|
||||||
|
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
|
||||||
|
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fatalNetworkErrorAction({
|
||||||
|
positionSeconds,
|
||||||
|
knownDurationSeconds,
|
||||||
|
attempts,
|
||||||
|
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
|
||||||
|
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
|
||||||
|
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Native-path reveal rule (DR-182).
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-182 | UT-184
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { nativeSignalRevealsVideo } from "./mediaReady";
|
||||||
|
|
||||||
|
describe("nativeSignalRevealsVideo", () => {
|
||||||
|
it("reveals on the backend's playing state", () => {
|
||||||
|
expect(nativeSignalRevealsVideo({ kind: "state", state: "playing" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["buffering", "paused", "stopped", "ended", "error", "idle", ""])(
|
||||||
|
"leaves the poster up on state %s",
|
||||||
|
(state) => {
|
||||||
|
expect(nativeSignalRevealsVideo({ kind: "state", state })).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reveals on a position tick that carries a duration", () => {
|
||||||
|
expect(
|
||||||
|
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 1440 })
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals on a position tick that has advanced, even with no duration", () => {
|
||||||
|
// Live streams report no duration; an advancing position is still proof
|
||||||
|
// that the surface has content.
|
||||||
|
expect(
|
||||||
|
nativeSignalRevealsVideo({ kind: "position", position: 3.2, duration: 0 })
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the poster up on an empty position tick", () => {
|
||||||
|
// A tick before anything is loaded proves nothing, and revealing here would
|
||||||
|
// show a transparent hole through the app.
|
||||||
|
expect(
|
||||||
|
nativeSignalRevealsVideo({ kind: "position", position: 0, duration: 0 })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a negative position as progress", () => {
|
||||||
|
expect(
|
||||||
|
nativeSignalRevealsVideo({ kind: "position", position: -1, duration: 0 })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* When the video area may be revealed on the **native** (ExoPlayer) path.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-182 | UT-184
|
||||||
|
*
|
||||||
|
* VideoPlayer draws an opaque `bg-black` poster/title card over the video area
|
||||||
|
* until `isMediaReady`. Every signal that clears it is emitted by the HTML5
|
||||||
|
* `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the
|
||||||
|
* `playing` event, and two `readyState` timeouts. The native path has no such
|
||||||
|
* element, so on Android nothing ever cleared the card: ExoPlayer decoded to a
|
||||||
|
* live SurfaceView behind a black div, which is the "audio with no picture"
|
||||||
|
* report of DR-172 and is indistinguishable from a compositing failure.
|
||||||
|
*
|
||||||
|
* The backend's own events are the equivalent signals, and this is the rule for
|
||||||
|
* reading them. It is a pure function rather than a branch inside the component
|
||||||
|
* because the component cannot be exercised without a DOM and a mounted player,
|
||||||
|
* and this decision is exactly the part that was missing and needs a guard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A player event that might mean "the surface has a picture on it". */
|
||||||
|
export type NativeRevealSignal =
|
||||||
|
| { kind: "state"; state: string }
|
||||||
|
| { kind: "position"; position: number; duration: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `signal` proves the native backend is rendering, and the poster card
|
||||||
|
* should therefore come down.
|
||||||
|
*
|
||||||
|
* Two signals qualify, mirroring the HTML5 path's primary event and its
|
||||||
|
* backstop:
|
||||||
|
*
|
||||||
|
* - **`state === "playing"`** — the direct equivalent of the `<video>`
|
||||||
|
* `playing` event. ExoPlayer reports this once it is actually drawing.
|
||||||
|
* - **a position tick carrying a real position or duration** — the equivalent
|
||||||
|
* of the `readyState` fallbacks. It covers a first state event that is
|
||||||
|
* dropped or arrives before the listener is attached; a tick means the media
|
||||||
|
* is loaded and the surface has content.
|
||||||
|
*
|
||||||
|
* Everything else — `buffering`, `paused`, `stopped`, `error` — leaves the card
|
||||||
|
* up. Revealing on `error` in particular would replace the title card with a
|
||||||
|
* transparent hole showing the launcher through the app.
|
||||||
|
*/
|
||||||
|
export function nativeSignalRevealsVideo(signal: NativeRevealSignal): boolean {
|
||||||
|
if (signal.kind === "state") {
|
||||||
|
return signal.state === "playing";
|
||||||
|
}
|
||||||
|
return signal.duration > 0 || signal.position > 0;
|
||||||
|
}
|
||||||
@@ -53,6 +53,68 @@ describe("subtitleStreamsOf", () => {
|
|||||||
expect(subtitleStreamsOf(null)).toEqual([]);
|
expect(subtitleStreamsOf(null)).toEqual([]);
|
||||||
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subtitle the app cannot draw must not reach the picker. Image-based
|
||||||
|
* tracks (PGS/DVD/DVB) are bitmaps: the only way to show one is for the server
|
||||||
|
* to composite it into the video, which this app deliberately never asks for
|
||||||
|
* (DR-176). Offering it anyway produced the reported symptom's twin — a menu
|
||||||
|
* entry that selects, ticks, and shows nothing.
|
||||||
|
*
|
||||||
|
* The verdict is the backend's (`supportsExternalDelivery`); the codec
|
||||||
|
* vocabulary behind it stays in Rust.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
*/
|
||||||
|
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
|
||||||
|
const streams: SubtitleStreamLike[] = [
|
||||||
|
{ index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
|
||||||
|
{ index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only an explicit "no" hides a track. A stream that carries no verdict at all
|
||||||
|
* predates the field (or came from somewhere that does not set it), and
|
||||||
|
* hiding those would silently empty the menu for sources that work today.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
*/
|
||||||
|
it("keeps subtitles that carry no verdict", () => {
|
||||||
|
const streams: SubtitleStreamLike[] = [
|
||||||
|
{ index: 2, kind: "subtitle", displayTitle: "English" },
|
||||||
|
{ index: 3, kind: "subtitle", displayTitle: "French", supportsExternalDelivery: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same list feeds the `<track>` children and the native play request, so
|
||||||
|
* an undeliverable track must not even have its URL fetched — that request is
|
||||||
|
* the one that 404s, and the sideloaded track it would produce is the dead
|
||||||
|
* entry all over again.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
*/
|
||||||
|
it("never resolves a URL for a subtitle it dropped", async () => {
|
||||||
|
const asked: number[] = [];
|
||||||
|
const tracks = await resolveSubtitleTracks(
|
||||||
|
[
|
||||||
|
{ index: 2, kind: "subtitle", displayTitle: "PGS", supportsExternalDelivery: false },
|
||||||
|
{ index: 3, kind: "subtitle", displayTitle: "SRT", supportsExternalDelivery: true },
|
||||||
|
],
|
||||||
|
async (index) => {
|
||||||
|
asked.push(index);
|
||||||
|
return url(index);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asked).toEqual([3]);
|
||||||
|
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subtitleTrackLabel", () => {
|
describe("subtitleTrackLabel", () => {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export interface SubtitleStreamLike {
|
|||||||
displayTitle?: string | null;
|
displayTitle?: string | null;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isForced?: boolean;
|
isForced?: boolean;
|
||||||
|
/**
|
||||||
|
* The backend's verdict on whether this track can arrive as a sidecar the app
|
||||||
|
* renders itself. `false` means only the server could have shown it, by
|
||||||
|
* burning it into the picture — which the app never asks for. Absent means no
|
||||||
|
* verdict was given, which is not the same as "no".
|
||||||
|
*/
|
||||||
|
supportsExternalDelivery?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
||||||
@@ -46,12 +53,32 @@ export interface RenderableSubtitleTrack {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subtitle streams of a media item, in stream order. */
|
/**
|
||||||
export function subtitleStreamsOf(
|
* Subtitle streams of a media item that the app can actually show, in stream
|
||||||
streams: readonly SubtitleStreamLike[] | null | undefined,
|
* order. This is the one list behind everything: the picker, the `<track>`
|
||||||
): SubtitleStreamLike[] {
|
* children, and the array sent to the native backend.
|
||||||
|
*
|
||||||
|
* Image-based subtitles (PGS/DVD/DVB) are filtered out here rather than at each
|
||||||
|
* consumer. They are bitmaps — a client can only display one if the server
|
||||||
|
* composites it into the video, and the app deliberately asks for no burn-in at
|
||||||
|
* all (DR-176), so such a track is one it can never draw. Leaving it in the
|
||||||
|
* picker produced a control that ticked and showed nothing.
|
||||||
|
*
|
||||||
|
* The judgement is the backend's: `supportsExternalDelivery` arrives already
|
||||||
|
* decided, because *which formats are bitmaps* is domain vocabulary and belongs
|
||||||
|
* in Rust. Only an explicit `false` drops a stream; a stream carrying no verdict
|
||||||
|
* is kept, so a source that never sets the field behaves exactly as before.
|
||||||
|
*
|
||||||
|
* Generic in the stream type so callers keep their own richer fields (the menu
|
||||||
|
* reads `codec` off the result).
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-176 | UT-168
|
||||||
|
*/
|
||||||
|
export function subtitleStreamsOf<T extends SubtitleStreamLike>(
|
||||||
|
streams: readonly T[] | null | undefined,
|
||||||
|
): T[] {
|
||||||
if (!streams) return [];
|
if (!streams) return [];
|
||||||
return streams.filter((s) => s.kind === "subtitle");
|
return streams.filter((s) => s.kind === "subtitle" && s.supportsExternalDelivery !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
||||||
|
|||||||
@@ -192,18 +192,86 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
|
|
||||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(120);
|
|
||||||
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
||||||
video._fire("canplay");
|
video._fire("canplay");
|
||||||
|
video._fire("seeked");
|
||||||
await p;
|
await p;
|
||||||
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
|
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reload lands the viewer at the position they asked for — by *seeking*,
|
||||||
|
* with no transcode offset left over.
|
||||||
|
*
|
||||||
|
* This used to be inverted: the offset was set to the position and nothing
|
||||||
|
* seeked, which was right only while the reloaded URL itself began there via
|
||||||
|
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
|
||||||
|
* the server copies it onto every segment URI and then rejects each one with
|
||||||
|
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
|
||||||
|
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
|
||||||
|
* titles, and the seek silently never happening.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||||
|
*/
|
||||||
|
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||||
|
video.paused = false;
|
||||||
|
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
|
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||||
|
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
|
||||||
|
|
||||||
|
// Nothing may seek before the new source is playable — the element drops it.
|
||||||
|
expect(video.currentTime).not.toBe(1200);
|
||||||
|
|
||||||
|
video._fire("canplay");
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(video.currentTime).toBe(1200);
|
||||||
|
|
||||||
|
video._fire("seeked");
|
||||||
|
await p;
|
||||||
|
expect(video.play).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A reload to the very start has nothing to seek to; it must not stall. */
|
||||||
|
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
||||||
|
video.paused = false;
|
||||||
|
const p = adapter.reloadSource("http://new/master.m3u8", 0);
|
||||||
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
|
video._fire("canplay");
|
||||||
|
await p; // resolves without any "seeked" event
|
||||||
|
expect(video.play).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reload that never becomes playable must be reported as a failure. It used
|
||||||
|
* to resolve on the timeout, so a quality switch whose new stream the server
|
||||||
|
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
|
||||||
|
* collide) looked like a success: the picker showed the new quality selected
|
||||||
|
* over a stream that never played, and the caller had nothing to revert to.
|
||||||
|
*
|
||||||
|
* TRACES: UR-074 | DR-177 | UT-175
|
||||||
|
*/
|
||||||
|
it("reloadSource() rejects when the new stream never becomes playable", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
video.paused = false;
|
||||||
|
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||||
|
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
||||||
|
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
||||||
|
await assertion;
|
||||||
|
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reloadSource() does not resume when it was paused", async () => {
|
it("reloadSource() does not resume when it was paused", async () => {
|
||||||
video.paused = true;
|
video.paused = true;
|
||||||
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
await new Promise((r) => setTimeout(r, 110));
|
||||||
video._fire("canplay");
|
video._fire("canplay");
|
||||||
|
video._fire("seeked");
|
||||||
await p;
|
await p;
|
||||||
expect(video.play).not.toHaveBeenCalled();
|
expect(video.play).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,16 +150,29 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRIMITIVE: compound reload — the invariant HTML5 sequence to swap the source
|
* PRIMITIVE: compound reload — swap the source and resume at
|
||||||
* and resume at `offset`. Contains NO strategy decision; the backend already
|
* `positionSeconds`, an **absolute** position on the item's own timeline.
|
||||||
* decided to reload and supplied the url/offset. Preserves the hard-won
|
* Contains NO strategy decision; the backend already decided to reload and
|
||||||
* dual-audio teardown and canplay wait.
|
* supplied the url/position. Preserves the hard-won dual-audio teardown and
|
||||||
|
* canplay wait.
|
||||||
|
*
|
||||||
|
* The position is reached by *seeking the element*, and the transcode offset
|
||||||
|
* is cleared to zero. It used to be the other way round — the offset was set
|
||||||
|
* to the position and nothing seeked — which was correct only while the
|
||||||
|
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
|
||||||
|
* parameter (on an HLS playlist it makes the server reject every segment with
|
||||||
|
* `400`), so a reloaded stream now always starts at the beginning of the item.
|
||||||
|
* Leaving the old arithmetic in place would have left `currentTime` reading
|
||||||
|
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
|
||||||
|
* no seek ever happening.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||||
*/
|
*/
|
||||||
async reloadSource(url: string, offset: number): Promise<void> {
|
async reloadSource(url: string, positionSeconds: number): Promise<void> {
|
||||||
const el = this.element;
|
const el = this.element;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
// Still update the stream URL so the component's HLS $effect can pick it up.
|
// Still update the stream URL so the component's HLS $effect can pick it up.
|
||||||
this.bridge.setSeekOffset(offset);
|
this.bridge.setSeekOffset(0);
|
||||||
this.bridge.setStreamUrl(url);
|
this.bridge.setStreamUrl(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,9 +184,24 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
el.load();
|
el.load();
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
this.bridge.setSeekOffset(offset);
|
// The reloaded stream begins at the item's zero, so there is no base to add.
|
||||||
|
this.bridge.setSeekOffset(0);
|
||||||
this.bridge.setStreamUrl(url);
|
this.bridge.setStreamUrl(url);
|
||||||
await this.waitForEvent(el, "canplay", 10000);
|
// A source that never becomes playable is a failed reload, not a slow one:
|
||||||
|
// the caller (quality switch, transcoded seek) has to know so it can revert
|
||||||
|
// its selection and surface the error instead of leaving the UI claiming a
|
||||||
|
// stream that is not playing.
|
||||||
|
const ready = await this.waitForEvent(el, "canplay", 10000);
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
|
||||||
|
}
|
||||||
|
// Now that the new source is playable, put it where the caller asked for.
|
||||||
|
// Seeking before `canplay` is dropped by the element, which is why this
|
||||||
|
// follows the wait rather than riding along with the URL swap.
|
||||||
|
if (positionSeconds > 0) {
|
||||||
|
el.currentTime = positionSeconds;
|
||||||
|
await this.waitForEvent(el, "seeked", 2000);
|
||||||
|
}
|
||||||
if (wasPlaying) await el.play();
|
if (wasPlaying) await el.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +249,26 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
||||||
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<void> {
|
/**
|
||||||
return new Promise<void>((resolve) => {
|
* Resolves `true` when the event fires, `false` if the budget runs out. The
|
||||||
const done = () => {
|
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
|
||||||
el.removeEventListener(event, done);
|
* missing `canplay` means the reload failed.
|
||||||
resolve();
|
*/
|
||||||
|
private waitForEvent(
|
||||||
|
el: HTMLVideoElement,
|
||||||
|
event: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const done = (fired: boolean) => {
|
||||||
|
el.removeEventListener(event, listener);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(fired);
|
||||||
};
|
};
|
||||||
el.addEventListener(event, done);
|
const listener = () => done(true);
|
||||||
setTimeout(done, timeoutMs);
|
el.addEventListener(event, listener);
|
||||||
|
timer = setTimeout(() => done(false), timeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ async function seekVideo(
|
|||||||
)) as any;
|
)) as any;
|
||||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||||
if (response.strategy === "reloadStream") {
|
if (response.strategy === "reloadStream") {
|
||||||
|
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
|
||||||
|
// the element's clock: the reloaded stream starts at the item's zero since
|
||||||
|
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
|
||||||
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
|
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
|
||||||
} else {
|
} else {
|
||||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import { commands } from "$lib/api/bindings";
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report playback start to Jellyfin (or queue if offline)
|
* Record the start of playback **locally**, with the context it started from.
|
||||||
*
|
*
|
||||||
* The Rust backend handles both local DB updates and server reporting,
|
* The server is told by Rust: loading an item into the controller reports the
|
||||||
* automatically queueing for sync if the server is unreachable.
|
* start, carrying the position the stream actually begins at (zero for an
|
||||||
|
* ordinary play, the handoff point for a background-audio stream). What this
|
||||||
|
* adds is the context — which album or series the play came from — which only
|
||||||
|
* the local DB keeps.
|
||||||
*
|
*
|
||||||
* TRACES: UR-005, UR-025 | DR-028
|
* TRACES: UR-005, UR-025 | DR-028, DR-179
|
||||||
*/
|
*/
|
||||||
export async function reportPlaybackStart(
|
export async function reportPlaybackStart(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@@ -50,12 +53,18 @@ export async function reportPlaybackStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report playback progress to Jellyfin (or queue if offline)
|
* Record playback progress **locally**.
|
||||||
*
|
*
|
||||||
* Note: Progress reports are frequent and are not queued for sync.
|
* The server's copy is not sent from here. Position ticks already flow into Rust
|
||||||
* The final position is captured by reportPlaybackStopped.
|
* through the player adapter (`player_report_position`), and the controller
|
||||||
|
* reports them onward on a 30s throttle — one place that covers webview video,
|
||||||
|
* native audio and the background-audio handoff alike, instead of a second
|
||||||
|
* frequent IPC path racing it.
|
||||||
*
|
*
|
||||||
* TRACES: UR-005 | DR-028
|
* This function is therefore the *local* half only, which is what its caller
|
||||||
|
* needs for resume points that work offline.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005 | DR-028, DR-179
|
||||||
*/
|
*/
|
||||||
export async function reportPlaybackProgress(
|
export async function reportPlaybackProgress(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
|
|||||||
@@ -158,9 +158,10 @@ describe("downloads store", () => {
|
|||||||
},
|
},
|
||||||
}); // get_downloads
|
}); // get_downloads
|
||||||
|
|
||||||
const ids = await downloads.downloadAlbum("album-1", "user-1", "/base/path");
|
const ids = await downloads.downloadAlbum("handle-1", "album-1", "user-1", "/base/path");
|
||||||
|
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("download_album", {
|
expect(mockInvoke).toHaveBeenCalledWith("download_album", {
|
||||||
|
handle: "handle-1",
|
||||||
albumId: "album-1",
|
albumId: "album-1",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
basePath: "/base/path",
|
basePath: "/base/path",
|
||||||
|
|||||||
@@ -191,12 +191,23 @@ function createDownloadsStore() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue an entire album for download
|
* Queue an entire album for download.
|
||||||
|
*
|
||||||
|
* The backend does all of it — listing the album's tracks, queueing them,
|
||||||
|
* resolving each stream URL and starting the queue. It returns the queued
|
||||||
|
* row ids for reporting only; nothing here pairs them back to tracks.
|
||||||
|
*
|
||||||
|
* TRACES: UR-018, UR-055 | DR-173
|
||||||
*/
|
*/
|
||||||
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
|
async downloadAlbum(
|
||||||
|
handle: string,
|
||||||
|
albumId: string,
|
||||||
|
userId: string,
|
||||||
|
basePath: string
|
||||||
|
): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
||||||
const downloadIds = await commands.downloadAlbum(albumId, userId, basePath);
|
const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath);
|
||||||
console.log(' Got download IDs from backend:', downloadIds);
|
console.log(' Got download IDs from backend:', downloadIds);
|
||||||
|
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
|
|||||||
@@ -30,23 +30,50 @@ const NATIVE_VIDEO_ATTR = "data-native-video";
|
|||||||
* Whether the native path is on, defaulting to **on** when the user has never
|
* Whether the native path is on, defaulting to **on** when the user has never
|
||||||
* chosen.
|
* chosen.
|
||||||
*
|
*
|
||||||
* It shipped defaulting to off while the native path was a spike. It is now the
|
* This default has moved three times, so the history is the documentation:
|
||||||
* default because picture-in-picture is built on it: PiP shrinks the *Activity*,
|
*
|
||||||
* so it needs a real video surface behind the WebView to show, and on the HTML5
|
* - **off** while the path was a spike (DR-150).
|
||||||
* path there is nothing for it to shrink into but the UI itself (DR-160).
|
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
|
||||||
|
* picture* — ExoPlayer decoded correctly into a live SurfaceView while the
|
||||||
|
* page stayed opaque over it.
|
||||||
|
* - **off** again (DR-172), which named the compositing as the suspect but did
|
||||||
|
* not find it.
|
||||||
|
* - **on** now, because the four defects behind that symptom were found and
|
||||||
|
* each is fixed and verified on a device: the app shell painted over the
|
||||||
|
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
|
||||||
|
* poster card had no way to lift on a path with no `<video>` element
|
||||||
|
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
|
||||||
|
* could never arrive (DR-183); and the SurfaceView was never detached
|
||||||
|
* (DR-184). Two further UI defects that only this path could show — the play
|
||||||
|
* overlay never clearing (DR-186) and the system bars staying over the player
|
||||||
|
* (DR-187) — are fixed with it.
|
||||||
|
*
|
||||||
|
* The picture is genuinely fixed and device-verified — `WebView transparent =
|
||||||
|
* true` and `Marking media ready` now appear in logcat with video on screen,
|
||||||
|
* the pair DR-172 went looking for and could not find. **The default is still
|
||||||
|
* off**, because turning it on surfaced a different gap: the background-audio
|
||||||
|
* handoff (UR-040) can only *return* through the HTML5 element.
|
||||||
|
* `applyPendingForegroundSeek` bails on `!videoElement`, the HLS re-init effect
|
||||||
|
* bails on `!useHtml5Element`, and `handleCanPlay` — the event that owns the
|
||||||
|
* post-handoff position and play state — is an element event that never fires
|
||||||
|
* natively. So coming back from background audio leaves playback dead.
|
||||||
|
*
|
||||||
|
* That is the same shape of mistake as DR-161: a verified sub-path shipped as a
|
||||||
|
* default over an unverified one. The evidence standard this branch set for the
|
||||||
|
* picture applies to the handoff too, so the flip waits for it (DR-190).
|
||||||
*
|
*
|
||||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||||
* it off keeps it off.
|
* it on keeps it on.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-188
|
||||||
*/
|
*/
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
if (typeof localStorage === "undefined") return true;
|
if (typeof localStorage === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||||
return stored === null ? true : stored === "true";
|
|
||||||
} catch {
|
} catch {
|
||||||
// Private-mode / disabled storage — no stored choice is readable, so this is
|
// Private-mode / disabled storage — default to the path whose handoff works.
|
||||||
// the same case as "never chosen".
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +101,9 @@ function createExperimentalNativeVideoStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User opt-out for the native Android video path. **Defaults to on** since
|
* User opt-in for the native Android video path. **Defaults to off** — see
|
||||||
* DR-161 — see `load()`. The name still says "experimental" because the flag
|
* `load()`. The name says "experimental" because the flag remains a suppressor
|
||||||
* remains a suppressor of Rust's backend choice, not a promoter of it.
|
* of Rust's backend choice, not a promoter of it.
|
||||||
*/
|
*/
|
||||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
|
|||||||
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
|
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow a scope the backend supplied (e.g. `Library.favoritesScope`) to one
|
||||||
|
* this page actually offers as a tab, or `null` if it doesn't.
|
||||||
|
*
|
||||||
|
* Unlike `resolveFavoritesScope`, an unrecognised scope is *rejected* rather
|
||||||
|
* than folded into "all": a caller asking "which category is this?" wants no
|
||||||
|
* answer, not the cross-category one.
|
||||||
|
*
|
||||||
|
* TRACES: UR-075 | DR-175
|
||||||
|
*/
|
||||||
|
export function asFavoritesScope(
|
||||||
|
scope: SearchScope | null | undefined,
|
||||||
|
): FavoritesScope | null {
|
||||||
|
if (!scope) return null;
|
||||||
|
return (FAVORITE_SCOPES as readonly string[]).includes(scope)
|
||||||
|
? (scope as FavoritesScope)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
|
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
|
||||||
export function favoritesRouteUrl(scope: FavoritesScope): string {
|
export function favoritesRouteUrl(scope: FavoritesScope): string {
|
||||||
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
|
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Every opaque layer the native-video CSS claims to clear must actually exist.
|
||||||
|
*
|
||||||
|
* TRACES: UR-003, UR-004 | DR-185 | UT-186
|
||||||
|
*
|
||||||
|
* The compositing rules in app.css clear the page's painted backgrounds so the
|
||||||
|
* ExoPlayer SurfaceView behind the WebView can be seen. One of the three
|
||||||
|
* selectors, `[data-app-shell]`, was written against an attribute that **no
|
||||||
|
* component ever set** — in any commit — so the app shell went on painting
|
||||||
|
* `--color-background` across the whole viewport, underneath a player that had
|
||||||
|
* correctly made itself transparent. The WebView therefore composited opaque
|
||||||
|
* and the surface could never show through.
|
||||||
|
*
|
||||||
|
* That failure is invisible three ways over: the CSS is valid, the selector is
|
||||||
|
* plausible, and the symptom (black screen, audio fine) is identical to a
|
||||||
|
* genuine compositing failure — which is how it survived DR-150 through DR-172.
|
||||||
|
* A rule that matches nothing is the specific defect worth a tripwire, so this
|
||||||
|
* asserts the relationship rather than the rule: every attribute the block
|
||||||
|
* targets is set somewhere in the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const srcRoot = path.resolve(here, "../..");
|
||||||
|
|
||||||
|
function read(file: string): string {
|
||||||
|
return fs.readFileSync(file, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every .svelte file under src/. */
|
||||||
|
function svelteFiles(dir: string, found: string[] = []): string[] {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) svelteFiles(full, found);
|
||||||
|
else if (entry.name.endsWith(".svelte")) found.push(full);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selector list of the `[data-native-video="active"]` rule in app.css.
|
||||||
|
* Returned verbatim, one selector per entry.
|
||||||
|
*/
|
||||||
|
function compositingSelectors(css: string): string[] {
|
||||||
|
const marker = 'html[data-native-video="active"]';
|
||||||
|
const start = css.indexOf(marker);
|
||||||
|
expect(start, "app.css no longer contains the native-video rule").toBeGreaterThan(-1);
|
||||||
|
const open = css.indexOf("{", start);
|
||||||
|
return css
|
||||||
|
.slice(start, open)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("native-video compositing layers (DR-185)", () => {
|
||||||
|
const css = read(path.join(srcRoot, "app.css"));
|
||||||
|
const selectors = compositingSelectors(css);
|
||||||
|
const markup = svelteFiles(srcRoot).map(read).join("\n");
|
||||||
|
|
||||||
|
it("clears the app shell, which paints over the whole viewport", () => {
|
||||||
|
// The shell is the layer directly between the player and the WebView; if it
|
||||||
|
// stays painted, nothing below it can be seen however transparent the
|
||||||
|
// player and the WebView widget are.
|
||||||
|
expect(selectors.some((s) => s.includes("[data-app-shell]"))).toBe(true);
|
||||||
|
expect(markup).toContain("data-app-shell");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets no attribute that nothing in the app sets", () => {
|
||||||
|
const attributes = selectors
|
||||||
|
.flatMap((selector) => [...selector.matchAll(/\[([a-zA-Z-]+)(?:[=\]])/g)])
|
||||||
|
.map((match) => match[1])
|
||||||
|
// data-native-video is set imperatively on <html> by nativeVideo.ts, not
|
||||||
|
// in markup, so it is verified against that module instead.
|
||||||
|
.filter((attr) => attr !== "data-native-video");
|
||||||
|
|
||||||
|
const unset = [...new Set(attributes)].filter((attr) => !markup.includes(attr));
|
||||||
|
expect(unset, `app.css targets attributes no component sets: ${unset.join(", ")}`)
|
||||||
|
.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sets data-native-video on <html> from the store", () => {
|
||||||
|
const store = read(path.join(srcRoot, "lib/stores/nativeVideo.ts"));
|
||||||
|
expect(store).toContain("data-native-video");
|
||||||
|
expect(store).toContain("documentElement");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,9 +97,9 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
|||||||
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
||||||
* action.
|
* action.
|
||||||
*
|
*
|
||||||
* The flag defaults to **on** now (DR-161), so Android normally shrinks the real
|
* The flag is back to defaulting **off** (DR-172, after native video shipped as
|
||||||
* ExoPlayer surface instead; this remains the path for Linux and for anyone who
|
* audio with no picture), so this is once again the path Android normally takes —
|
||||||
* turned the flag off.
|
* which is why PiP does not depend on that flag being on.
|
||||||
*
|
*
|
||||||
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
||||||
* video that is no longer there.
|
* video that is no longer there.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Native video surface compositing, Android only.
|
* Native video surface compositing, Android only.
|
||||||
*
|
*
|
||||||
* TRACES: UR-003, UR-004 | DR-150, DR-151
|
* TRACES: UR-003, UR-004 | DR-150, DR-151, DR-183
|
||||||
*
|
*
|
||||||
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
|
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
|
||||||
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
|
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
|
||||||
@@ -65,8 +65,23 @@ export function enableNativeVideoCompositing(): void {
|
|||||||
// Page layer first: if the Kotlin call succeeded but this threw, the user
|
// Page layer first: if the Kotlin call succeeded but this threw, the user
|
||||||
// would see through the app to the home screen.
|
// would see through the app to the home screen.
|
||||||
nativeVideoActive.set(true);
|
nativeVideoActive.set(true);
|
||||||
|
const androidVideoSurface = bridge();
|
||||||
|
if (!androidVideoSurface) {
|
||||||
|
// Say so loudly. Every bridge call in this file is optional-chained, so a
|
||||||
|
// missing bridge is silent — and a silently-skipped setTransparent(true) is
|
||||||
|
// indistinguishable on screen from a compositing failure: ExoPlayer renders
|
||||||
|
// correctly behind a WebView that never stopped painting its own opaque
|
||||||
|
// background. That ambiguity is what DR-172 was left holding. MainActivity's
|
||||||
|
// console bridge forwards this to logcat under the JellyTauWeb tag.
|
||||||
|
console.error(
|
||||||
|
"[videoSurface] AndroidVideoSurface bridge is MISSING - the webview will " +
|
||||||
|
"stay opaque and native video will play as audio with no picture"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
bridge()?.setTransparent(true);
|
androidVideoSurface.setTransparent(true);
|
||||||
|
console.log("[videoSurface] compositing enabled (setTransparent(true) sent)");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[videoSurface] setTransparent(true) failed:", err);
|
console.warn("[videoSurface] setTransparent(true) failed:", err);
|
||||||
nativeVideoActive.set(false);
|
nativeVideoActive.set(false);
|
||||||
|
|||||||
@@ -260,7 +260,16 @@
|
|||||||
|
|
||||||
TRACES: UR-066 | DR-112
|
TRACES: UR-066 | DR-112
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
data-app-shell marks the layer app.css clears for native video. This div
|
||||||
|
paints --color-background across the entire viewport, *under* a VideoPlayer
|
||||||
|
that makes itself transparent on the native path — so while it stays painted,
|
||||||
|
the ExoPlayer SurfaceView behind the WebView cannot be seen no matter what
|
||||||
|
else is cleared. The rule in app.css was written for this attribute; the
|
||||||
|
attribute was never added. (DR-185)
|
||||||
|
-->
|
||||||
<div
|
<div
|
||||||
|
data-app-shell
|
||||||
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
|
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
|
||||||
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
|
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
|
||||||
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
|
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
|
||||||
|
|||||||
+30
-13
@@ -9,7 +9,9 @@
|
|||||||
import { currentMedia } from "$lib/stores/player";
|
import { currentMedia } from "$lib/stores/player";
|
||||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
import MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
|
||||||
|
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
|
||||||
|
import { assumedLibraryRatio } from "$lib/components/library/libraryMosaic";
|
||||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
|
|
||||||
@@ -113,6 +115,15 @@
|
|||||||
$libraries.filter((lib) => lib.collectionType !== "playlists")
|
$libraries.filter((lib) => lib.collectionType !== "playlists")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
|
||||||
|
// artwork. It used to force 16:9 on everything so square music covers lined up
|
||||||
|
// with wide backdrops — which lined them up by cropping the covers.
|
||||||
|
// TRACES: UR-075 | DR-174
|
||||||
|
const LIBRARY_STRIP_HEIGHT = 132;
|
||||||
|
const libraryTiles = $derived(
|
||||||
|
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
|
||||||
|
);
|
||||||
|
|
||||||
function handleLibraryClick(lib: Library) {
|
function handleLibraryClick(lib: Library) {
|
||||||
// Mirror /library routing: dedicated landing pages need currentLibrary set.
|
// Mirror /library routing: dedicated landing pages need currentLibrary set.
|
||||||
library.setCurrentLibrary(lib);
|
library.setCurrentLibrary(lib);
|
||||||
@@ -166,19 +177,25 @@
|
|||||||
{#if shortcutLibraries.length > 0}
|
{#if shortcutLibraries.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
||||||
<div class="flex gap-4 overflow-x-auto px-4 pb-2 items-start">
|
<div class="px-4">
|
||||||
{#each shortcutLibraries as lib (lib.id)}
|
<MosaicGrid
|
||||||
<div class="flex-shrink-0">
|
items={libraryTiles}
|
||||||
<!-- Uniform 16:9 artwork so music (square) and video libraries
|
layout="strip"
|
||||||
line up at the same height in this mixed row. -->
|
targetHeight={LIBRARY_STRIP_HEIGHT}
|
||||||
<MediaCard
|
gap={12}
|
||||||
item={lib}
|
>
|
||||||
size="medium"
|
{#snippet tile(entry)}
|
||||||
aspect="video"
|
<MosaicTile
|
||||||
onclick={() => handleLibraryClick(lib)}
|
label={entry.library.name}
|
||||||
|
width={entry.width}
|
||||||
|
height={entry.height}
|
||||||
|
itemId={entry.library.id}
|
||||||
|
imageTag={entry.library.imageTag}
|
||||||
|
onRatio={entry.reportRatio}
|
||||||
|
onclick={() => handleLibraryClick(entry.library)}
|
||||||
/>
|
/>
|
||||||
</div>
|
{/snippet}
|
||||||
{/each}
|
</MosaicGrid>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
|
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
|
||||||
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
|
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
|
||||||
|
import MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
|
||||||
|
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
|
||||||
|
import { buildLibraryMosaic } from "$lib/components/library/libraryMosaic";
|
||||||
|
|
||||||
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
||||||
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
||||||
@@ -40,6 +42,12 @@
|
|||||||
return $libraries.filter(lib => lib.collectionType !== "playlists");
|
return $libraries.filter(lib => lib.collectionType !== "playlists");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The overview is a mosaic: rows of one height, tiles of their own widths, so
|
||||||
|
// a square music cover sits beside a wide backdrop without either being
|
||||||
|
// cropped to the other's shape. Each category also gets a favourites tile of
|
||||||
|
// its own, beside the library it belongs to. TRACES: UR-075 | DR-174, DR-175
|
||||||
|
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
|
||||||
|
|
||||||
// Track if we've done an initial load and previous server state
|
// Track if we've done an initial load and previous server state
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
let previousServerReachable = false;
|
let previousServerReachable = false;
|
||||||
@@ -242,44 +250,46 @@
|
|||||||
<p>No libraries found</p>
|
<p>No libraries found</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<!-- Favourites are destinations in their own right, not just the icon in
|
||||||
<!-- Favourites as a destination in its own right, not just the icon in
|
the header above. The cross-library entry leads the mosaic and each
|
||||||
the header above. It cuts across every library, so it leads the
|
category's own favourites sits beside its library — a labelled tile
|
||||||
grid rather than sitting inside one — and a labelled tile at the
|
at the same weight as a library is the difference between a feature
|
||||||
same weight as a library is the difference between a feature
|
people find and one they don't. ux-flows §5C.2.
|
||||||
people find and one they don't. ux-flows §5C.2.
|
TRACES: UR-067, UR-075 | DR-117, DR-174 -->
|
||||||
TRACES: UR-067 | DR-117 -->
|
<MosaicGrid items={mosaicEntries} gap={8}>
|
||||||
<button
|
{#snippet tile(entry)}
|
||||||
onclick={() => goto('/library/favorites')}
|
{#if entry.kind === "favorites"}
|
||||||
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
<MosaicTile
|
||||||
>
|
label={entry.label}
|
||||||
<div
|
width={entry.width}
|
||||||
class="relative aspect-video w-full overflow-hidden rounded-lg shadow-md
|
height={entry.height}
|
||||||
flex items-center justify-center
|
accent
|
||||||
bg-gradient-to-br from-[var(--color-jellyfin)]/30 to-[var(--color-jellyfin)]/5"
|
onclick={() => goto(entry.href)}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-10 h-10 text-[var(--color-jellyfin)]"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
{#snippet icon()}
|
||||||
</svg>
|
<svg
|
||||||
</div>
|
class="w-10 h-10 text-[var(--color-jellyfin)]"
|
||||||
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
fill="currentColor"
|
||||||
Favourites
|
viewBox="0 0 24 24"
|
||||||
</p>
|
aria-hidden="true"
|
||||||
</button>
|
>
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||||
{#each visibleLibraries as lib (lib.id)}
|
</svg>
|
||||||
<MediaCard
|
{/snippet}
|
||||||
item={lib}
|
</MosaicTile>
|
||||||
size="medium"
|
{:else}
|
||||||
onclick={() => handleLibraryClick(lib)}
|
<MosaicTile
|
||||||
/>
|
label={entry.label}
|
||||||
{/each}
|
width={entry.width}
|
||||||
</div>
|
height={entry.height}
|
||||||
|
itemId={entry.library.id}
|
||||||
|
imageTag={entry.library.imageTag}
|
||||||
|
onRatio={entry.reportRatio}
|
||||||
|
onclick={() => handleLibraryClick(entry.library)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</MosaicGrid>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
let serverName = $state("");
|
let serverName = $state("");
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
|
let showPassword = $state(false);
|
||||||
let connecting = $state(false);
|
let connecting = $state(false);
|
||||||
let loggingIn = $state(false);
|
let loggingIn = $state(false);
|
||||||
let localError = $state<string | null>(null);
|
let localError = $state<string | null>(null);
|
||||||
@@ -146,6 +147,10 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your username"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="username"
|
||||||
|
spellcheck="false"
|
||||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
|
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
|
||||||
disabled={loggingIn}
|
disabled={loggingIn}
|
||||||
/>
|
/>
|
||||||
@@ -155,14 +160,43 @@
|
|||||||
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="password"
|
<!-- `type` is dynamic, so bind:value is not allowed here (Svelte); wire it manually. -->
|
||||||
type="password"
|
<input
|
||||||
bind:value={password}
|
id="password"
|
||||||
placeholder="Enter your password"
|
type={showPassword ? "text" : "password"}
|
||||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
|
value={password}
|
||||||
disabled={loggingIn}
|
oninput={(e) => (password = e.currentTarget.value)}
|
||||||
/>
|
placeholder="Enter your password"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="current-password"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full pl-4 pr-12 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
|
||||||
|
disabled={loggingIn}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
disabled={loggingIn}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-white disabled:opacity-50 focus:outline-none focus:text-white"
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<!-- eye-off -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- eye -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if localError || $authError}
|
{#if localError || $authError}
|
||||||
|
|||||||
@@ -334,22 +334,20 @@
|
|||||||
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set initial position for video player to seek to after load
|
// Set initial position for the video player to seek to after load.
|
||||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
// Use explicit startPosition, or fall back to retrieved progress.
|
||||||
// For transcoded content, we need to request a new stream with StartTimeTicks
|
//
|
||||||
|
// Transcoded streams resume the same way direct ones do — by seeking
|
||||||
|
// after load. Asking the server for a stream that *starts* at the
|
||||||
|
// position is what DR-181 removed: on an HLS playlist that position is
|
||||||
|
// copied onto every segment URI and the server then rejects each one
|
||||||
|
// with 400, so a resumed episode played nothing at all while the same
|
||||||
|
// episode from the beginning was fine.
|
||||||
|
// TRACES: UR-004, UR-019 | DR-181
|
||||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||||
if (effectivePosition > 0) {
|
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
|
||||||
if (videoNeedsTranscoding) {
|
if (videoInitialPosition > 0) {
|
||||||
// For transcoded streams, get a new URL starting at the position
|
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
||||||
console.log("loadAndPlay: Getting transcoded stream starting at:", effectivePosition);
|
|
||||||
streamUrl = await repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, effectivePosition);
|
|
||||||
} else {
|
|
||||||
// For direct streams, we'll seek after load
|
|
||||||
videoInitialPosition = effectivePosition;
|
|
||||||
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoInitialPosition = 0;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For audio, use MPV backend
|
// For audio, use MPV backend
|
||||||
@@ -531,14 +529,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle video seeking by requesting a new stream URL starting at the given position.
|
* Rebuild the stream for a transcoded seek or an audio-track switch.
|
||||||
* Transcoded streams don't support native seeking, so we restart from a new position.
|
*
|
||||||
|
* The returned URL starts at the beginning of the item, not at
|
||||||
|
* `positionSeconds`: a start position on an HLS playlist is copied onto every
|
||||||
|
* segment URI and rejected with 400 (DR-181). The caller seeks the reloaded
|
||||||
|
* element to the position — `positionSeconds` is kept in the signature because
|
||||||
|
* VideoPlayer's seek contract passes it, and the audio-track switch needs the
|
||||||
|
* same rebuild.
|
||||||
|
*
|
||||||
|
* TRACES: UR-004, UR-005, UR-021 | DR-181
|
||||||
*/
|
*/
|
||||||
async function handleVideoSeek(positionSeconds: number, audioStreamIndex?: number): Promise<string> {
|
async function handleVideoSeek(_positionSeconds: number, audioStreamIndex?: number): Promise<string> {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const id = itemId;
|
const id = itemId;
|
||||||
if (!id) throw new Error("No item ID");
|
if (!id) throw new Error("No item ID");
|
||||||
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, positionSeconds, audioStreamIndex);
|
return repo.getVideoStreamUrl(id, mediaSourceId ?? undefined, audioStreamIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playback reporting callbacks
|
// Playback reporting callbacks
|
||||||
@@ -555,11 +561,11 @@
|
|||||||
if (id) {
|
if (id) {
|
||||||
reportPlaybackStart(id, positionSeconds, context.type, context.id);
|
reportPlaybackStart(id, positionSeconds, context.type, context.id);
|
||||||
}
|
}
|
||||||
// Mirror HTML5 <video> state into the Rust PlayerController so it is the
|
// The element's state is mirrored into Rust by VideoPlayer, which is the
|
||||||
// single source of truth for video playback (see html5Adapter.ts). The
|
// only place that knows whether a webview element is rendering at all.
|
||||||
// element lives in the webview and Rust cannot observe it directly.
|
// Doing it here mirrored unconditionally, so on the native path it told Rust
|
||||||
html5Adapter.reportState("playing", id ?? null);
|
// a `<video>` was playing when none existed and transport was then aimed at
|
||||||
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
|
// it — see mirrorElementStateToRust in VideoPlayer.svelte (DR-195).
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
|
function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
|
||||||
@@ -567,9 +573,7 @@
|
|||||||
if (id) {
|
if (id) {
|
||||||
reportPlaybackProgress(id, positionSeconds, isPaused);
|
reportPlaybackProgress(id, positionSeconds, isPaused);
|
||||||
}
|
}
|
||||||
// Feed the Rust controller the current position and play/pause state.
|
// Element state is mirrored by VideoPlayer (DR-195) — see handleReportStart.
|
||||||
html5Adapter.reportState(isPaused ? "paused" : "playing", id ?? null);
|
|
||||||
html5Adapter.reportPosition(positionSeconds, get(playbackDuration), { force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReportStop(positionSeconds: number, reportId?: string) {
|
function handleReportStop(positionSeconds: number, reportId?: string) {
|
||||||
|
|||||||
@@ -744,10 +744,11 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-400 mt-1">
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
Decode video with the device's hardware decoder instead of the
|
Decode video with the device's hardware decoder instead of the
|
||||||
built-in web player. Better performance and battery life, and
|
built-in web player, for better performance and battery life,
|
||||||
required for picture-in-picture to show the video rather than
|
and so picture-in-picture shows the video rather than the app.
|
||||||
the app. Still less tested — turn this off if video fails to
|
The picture works, but background audio does not come back from
|
||||||
appear or seeking misbehaves.
|
the lockscreen on this path yet — leave it off unless you are
|
||||||
|
helping test it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user