Compare commits
31
Commits
v0.5.1
...
c0c6c5023e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c6c5023e | ||
|
|
521acc75fd | ||
|
|
886cbcb29a | ||
|
|
2cc39cd7fd | ||
|
|
e457a9884c | ||
|
|
de1c13e72f | ||
|
|
5096c01960 | ||
|
|
2d67b0e4f5 | ||
|
|
13264e225b | ||
|
|
041969f446 | ||
|
|
1a9805f0f3 | ||
|
|
82b6982d68 | ||
|
|
3363ff7f08 | ||
|
|
9858b7cb92 | ||
|
|
f46d7bf676 | ||
|
|
74bffea650 | ||
|
|
7e1f0e0547 | ||
|
|
99ceeadb83 | ||
|
|
e015c4c9b1 | ||
|
|
7387f35c7e | ||
|
|
0861523015 | ||
|
|
ac4fccd499 | ||
|
|
a5535f2941 | ||
|
|
d49d027020 | ||
|
|
9c352fdb77 | ||
|
|
dda2ff86a3 | ||
|
|
8ad3dc5c4f | ||
|
|
9f5f57cba4 | ||
|
|
50934e2ac6 | ||
|
|
1e599627b5 | ||
|
|
fa7cb6e908 |
+751
-7
@@ -6,6 +6,295 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||
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.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
|
||||
|
||||
### ✨ Features
|
||||
@@ -88,6 +377,74 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
so music is not transcoded needlessly.
|
||||
(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
|
||||
|
||||
### 🐛 Fixes
|
||||
@@ -168,6 +525,182 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
|
||||
- **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
|
||||
|
||||
### ✨ Features
|
||||
@@ -220,10 +753,45 @@ were wrong.
|
||||
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
|
||||
by a spec rather than asserted as an upstream blocker.
|
||||
|
||||
<!--
|
||||
Note: v0.1.3–v0.1.5 have no entries here. Their changes are in the git log
|
||||
and docs/traceability.md.
|
||||
-->
|
||||
## v0.1.5
|
||||
|
||||
_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
|
||||
|
||||
@@ -260,7 +828,183 @@ were wrong.
|
||||
**Linux:** 64-bit, GLIBC 2.29+
|
||||
**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
|
||||
```
|
||||
|
||||
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
|
||||
only against the mirror if one exists; the canonical remote is
|
||||
`gitea.tourolle.paris`.
|
||||
|
||||
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
# Set NDK environment variable
|
||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
# Gradle distribution. `tauri android init` regenerates gen/android with a
|
||||
# wrapper pointing at services.gradle.org, so every Android job would otherwise
|
||||
# download ~130MB of Gradle at build time — slow, and a hard failure when the
|
||||
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
|
||||
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
|
||||
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
|
||||
# Tauri's generated wrapper requests.
|
||||
ENV GRADLE_VERSION=8.14.3 \
|
||||
GRADLE_HOME=/opt/gradle/gradle-8.14.3
|
||||
RUN mkdir -p /opt/gradle/dist && \
|
||||
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
|
||||
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
|
||||
"$GRADLE_HOME/bin/gradle" --version
|
||||
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||
|
||||
@@ -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.
|
||||
+70
-10
@@ -82,6 +82,10 @@ For a narrative overview of the system design, see
|
||||
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
|
||||
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | 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-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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -318,6 +322,28 @@ Internal architecture, components, and application logic.
|
||||
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
|
||||
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
|
||||
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` 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 same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `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 (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
|
||||
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
|
||||
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
|
||||
| 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-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-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-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-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-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-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
|
||||
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
||||
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
||||
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
||||
@@ -336,8 +362,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| 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-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||
| 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 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||
@@ -350,14 +376,14 @@ Internal architecture, components, and application logic.
|
||||
| UR-015 | - | DR-005, DR-020 |
|
||||
| UR-016 | - | - |
|
||||
| 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-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-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| 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-027 | IR-020 | DR-030 |
|
||||
| UR-028 | - | DR-031 |
|
||||
@@ -372,8 +398,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
@@ -387,7 +413,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
@@ -397,12 +423,16 @@ Internal architecture, components, and application logic.
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||
| UR-066 | IR-031 | DR-112 |
|
||||
| UR-066 | IR-031 | DR-112, DR-157 |
|
||||
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| 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 |
|
||||
| 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-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||
| UR-075 | - | DR-174, DR-175 |
|
||||
|
||||
---
|
||||
|
||||
@@ -553,12 +583,42 @@ Internal architecture, components, and application logic.
|
||||
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | 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-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-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-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | 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-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-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-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | 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-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 |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Spec: streaming bitrate cap
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
|
||||
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
|
||||
|
||||
## Summary
|
||||
|
||||
The viewer picks a bandwidth ceiling for video — from `Original` (no client
|
||||
limit) down to 720 kbps — and every video the app opens is fetched within it,
|
||||
live TV included. The choice is made once in Settings and persists across
|
||||
restarts; a single video can be moved to another ceiling from the player, which
|
||||
re-opens the stream and resumes where it was without changing the saved default.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every video URL the app built carried a fixed allowance —
|
||||
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
|
||||
negotiation asked for 20 Mbps, and the device profile advertised
|
||||
`999999999`, which invites the server to direct-play a source of any size. On a
|
||||
metered or slow connection there was no lever at all short of not watching.
|
||||
|
||||
The related UR-070 asks for something adjacent but different: a list of the
|
||||
renditions *the server can produce for this item*. That needs per-item
|
||||
`MediaSources` negotiation and is still proposed. What was missing first is
|
||||
cruder and more valuable: a device-wide budget that holds regardless of what is
|
||||
playing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
|
||||
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
|
||||
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
|
||||
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
|
||||
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
|
||||
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
|
||||
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
|
||||
|
||||
The frontend holds one string — the serde token for the chosen variant — and
|
||||
labels/details it received from Rust. It never encodes a bitrate, a resolution
|
||||
or a parameter name.
|
||||
|
||||
## Design
|
||||
|
||||
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
|
||||
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
|
||||
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
|
||||
`video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`.
|
||||
|
||||
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
|
||||
`repository/online.rs`, read by every builder. Process-wide rather than a field
|
||||
on `OnlineRepository` because it is a preference about *this device's
|
||||
connection*: it must survive a repository rebuilt on re-login, and the URL
|
||||
builders and the negotiation have to agree on it or the cap leaks. This mirrors
|
||||
`offline::INCLUDE_CATALOG_BROWSE`.
|
||||
|
||||
Enforcement points — all four are required:
|
||||
|
||||
| Point | What the cap sets |
|
||||
|-------|-------------------|
|
||||
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
|
||||
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
|
||||
| `open_live_stream` | `MaxStreamingBitrate` |
|
||||
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
|
||||
|
||||
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
|
||||
the server refuse to *direct play* a source fatter than the ceiling; without it
|
||||
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
|
||||
it.
|
||||
|
||||
IPC:
|
||||
|
||||
```rust
|
||||
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
|
||||
player_set_stream_quality(repository_handle, quality, use_html5,
|
||||
current_position, media_source_id, audio_stream_index)
|
||||
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
|
||||
```
|
||||
|
||||
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
|
||||
persisted before the field existed load as uncapped).
|
||||
`player_set_video_settings` applies it and writes it to `app_settings`;
|
||||
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
|
||||
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
|
||||
|
||||
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
|
||||
tauri-specta only camelCases the `strategy` tag. The facade
|
||||
(`playerController.setStreamQuality`) dispatches `reloadSource` for
|
||||
`reloadStream` and does nothing for `native`, because the backend has already
|
||||
reloaded itself.
|
||||
|
||||
Mid-playback the change applies to the current video **and** becomes the process
|
||||
ceiling for what follows, but it is not persisted: the in-player menu is a "this
|
||||
film, this connection" control and Settings owns the durable default.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
|
||||
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
|
||||
- Adaptive/automatic selection from measured throughput.
|
||||
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `bun run check` passes.
|
||||
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
|
||||
- [x] `bun run test` passes.
|
||||
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
|
||||
- [x] New code carries `// TRACES:` comments.
|
||||
- [x] `bindings.ts` regenerated from Rust.
|
||||
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
|
||||
|
||||
## Testing
|
||||
|
||||
Rust (`cargo test`):
|
||||
|
||||
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
|
||||
- `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`.
|
||||
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
|
||||
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
|
||||
|
||||
The ceiling is process-wide, so tests that depend on it serialise on a guard
|
||||
(`QualityFixture`) that restores `Original` on drop — including the two
|
||||
pre-existing stream-URL tests, which would otherwise see another test's cap.
|
||||
|
||||
`get_playback_info` and `open_live_stream` need a live server and are not unit
|
||||
tested; their behaviour is the enum's `max_bitrate()`, which is.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162`
|
||||
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
|
||||
- Audio-only handoff — `UR-040, UR-074 | DR-162`
|
||||
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
|
||||
- Tests — `UT-156`, `UT-157`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
|
||||
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
|
||||
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
|
||||
- A parallel Claude session may be active in this repo; `git diff` before
|
||||
repairing unexpected changes. DR-160/161 were claimed by such a session while
|
||||
this feature was in flight, which is why it is DR-162.
|
||||
+6179
-4215
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.4.8",
|
||||
"version": "0.5.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
@@ -11,11 +11,13 @@ echo ""
|
||||
|
||||
echo ""
|
||||
|
||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||
BUILD_TYPE="debug"
|
||||
# Deploy APK — forward the build type and the side-by-side flag (which decides
|
||||
# which package to launch), ignoring build-only flags like --clean and --device.
|
||||
DEPLOY_ARGS=("debug")
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
debug|release) DEPLOY_ARGS[0]="$arg" ;;
|
||||
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
|
||||
esac
|
||||
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
|
||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
||||
# 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"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
ABI="${ABI:-}"
|
||||
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
|
||||
next_is_abi=0
|
||||
for arg in "$@"; do
|
||||
if [ "$next_is_abi" = "1" ]; then
|
||||
@@ -37,10 +46,17 @@ for arg in "$@"; do
|
||||
--clean) CLEAN=1 ;;
|
||||
--abi) next_is_abi=1 ;;
|
||||
--device) ABI="device" ;;
|
||||
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
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.
|
||||
if [ "$ABI" = "device" ]; then
|
||||
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
|
||||
|
||||
# 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
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
|
||||
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build type: debug or release (default: debug)
|
||||
BUILD_TYPE="${1:-debug}"
|
||||
# Build type: debug or release (default: debug). `--debug` alongside `release`
|
||||
# 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
|
||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||
else
|
||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||
fi
|
||||
|
||||
if [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
||||
else
|
||||
APP_PACKAGE="com.dtourolle.jellytau"
|
||||
fi
|
||||
|
||||
# Check if APK exists
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
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 "✅ 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);
|
||||
|
||||
expect(defined.UR).toBe(71);
|
||||
expect(defined.UR).toBe(75);
|
||||
expect(defined.IR).toBe(32);
|
||||
expect(defined.DR).toBe(150);
|
||||
expect(defined.DR).toBe(172);
|
||||
expect(defined.JA).toBe(35);
|
||||
expect(defined.total).toBe(288);
|
||||
expect(defined.total).toBe(314);
|
||||
});
|
||||
});
|
||||
|
||||
+25
-4
@@ -1,13 +1,34 @@
|
||||
#!/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
|
||||
|
||||
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 "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Filter logcat for the app's package name
|
||||
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
||||
# Prefer PID-scoped output when the app is running — it drops the noise that a
|
||||
# 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
|
||||
|
||||
+11
-3
@@ -81,8 +81,16 @@ fi
|
||||
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
|
||||
# *downgrade* and Android refuses the update.
|
||||
#
|
||||
# code = 1000 + major*10000 + minor*100 + patch
|
||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||
# The floor has to clear the highest code actually in the field, which is not the
|
||||
# same as the highest this formula has produced. v0.5.2 shipped versionCode
|
||||
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
|
||||
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
|
||||
# every 0.5.x release built from it was an un-installable downgrade for anyone
|
||||
# already on v0.5.2, which is exactly the failure this block exists to prevent.
|
||||
# The multipliers are widened and the floor raised past 5002 accordingly.
|
||||
#
|
||||
# code = 10000 + major*1000000 + minor*1000 + patch
|
||||
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
if [ -f "$PROPS" ]; then
|
||||
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
|
||||
@@ -93,7 +101,7 @@ if [ -f "$PROPS" ]; then
|
||||
MIN=$(echo "$CORE" | cut -d. -f2)
|
||||
PAT=$(echo "$CORE" | cut -d. -f3)
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
|
||||
echo " versionCode=$CODE (from $CORE)"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
|
||||
+24
-12
@@ -106,21 +106,33 @@ describe("set-version.sh", () => {
|
||||
});
|
||||
|
||||
describe("Android versionCode", () => {
|
||||
// Codes below 1000 are already in the field; a newer release must never
|
||||
// produce a smaller number than an older one.
|
||||
it("clears the 1000 floor shipped by earlier builds", () => {
|
||||
// A newer release must never produce a smaller number than an older one, or
|
||||
// Android refuses the update. The floor tracks the highest code actually in
|
||||
// the field, which is NOT the same as the highest this formula has produced:
|
||||
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
|
||||
// the `minor*100` formula that replaced it yields only 1502 for that same
|
||||
// version — so every 0.5.x release built from it was an un-installable
|
||||
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
|
||||
it("clears the highest code shipped by earlier builds", () => {
|
||||
run("0.0.1");
|
||||
expect(versionCode()).toBeGreaterThan(1000);
|
||||
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("uses 1000 + major*10000 + minor*100 + patch", () => {
|
||||
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
|
||||
run("0.5.3");
|
||||
expect(versionCode()).toBeGreaterThan(5002);
|
||||
});
|
||||
|
||||
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
["0.0.14", 1014],
|
||||
["0.0.15", 1015],
|
||||
["0.1.0", 1100],
|
||||
["0.4.8", 1408],
|
||||
["0.5.0", 1500],
|
||||
["1.0.0", 11000],
|
||||
["0.0.14", 10014],
|
||||
["0.0.15", 10015],
|
||||
["0.1.0", 11000],
|
||||
["0.4.8", 14008],
|
||||
["0.5.0", 15000],
|
||||
["0.5.3", 15003],
|
||||
["1.0.0", 1010000],
|
||||
];
|
||||
for (const [version, code] of cases) {
|
||||
seed(tmp);
|
||||
@@ -145,7 +157,7 @@ describe("set-version.sh", () => {
|
||||
// stripped before the arithmetic.
|
||||
it("derives the code from the numeric core of a prerelease", () => {
|
||||
run("0.6.0-rc1");
|
||||
expect(versionCode()).toBe(1600);
|
||||
expect(versionCode()).toBe(16000);
|
||||
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
fi
|
||||
|
||||
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
|
||||
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
|
||||
# slow, and a hard failure when the CDN drops the connection mid-transfer
|
||||
# ("Unexpected end of file from server"), which is what broke the release APK
|
||||
# job. The builder image ships the matching distribution under /opt/gradle/dist,
|
||||
# so when it's present repoint the wrapper at that local zip and build offline.
|
||||
# Outside the image (dev machines) the properties file is left untouched and the
|
||||
# wrapper downloads as usual.
|
||||
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
|
||||
if [ -f "$WRAPPER_PROPS" ]; then
|
||||
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
|
||||
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
|
||||
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
|
||||
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
|
||||
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
|
||||
"$WRAPPER_PROPS"
|
||||
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
|
||||
elif [ -n "$WANTED_VERSION" ]; then
|
||||
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✓ Android sources synced successfully"
|
||||
|
||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.4.8"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.4.8"
|
||||
version = "0.5.5"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
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
|
||||
- **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
|
||||
|
||||
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 {
|
||||
compileSdk = 36
|
||||
namespace = "com.dtourolle.jellytau"
|
||||
defaultConfig {
|
||||
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"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
@@ -45,6 +59,21 @@ android {
|
||||
}
|
||||
buildTypes {
|
||||
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"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
@@ -56,7 +85,18 @@ android {
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
(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
|
||||
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">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -26,7 +32,7 @@
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:label="${appLabel}"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
@@ -34,7 +40,7 @@
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:label="${activityLabel}"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* Hides and restores the Android system bars for full-screen video.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* ## Why the web layer cannot do this
|
||||
*
|
||||
* `document.documentElement.requestFullscreen()` is the only fullscreen control
|
||||
* the frontend has, and inside an Android WebView it does nothing to the
|
||||
* *Activity*: it expands the fullscreen element within the web viewport and
|
||||
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` — which
|
||||
* MainActivity must call, and which SDK 36 makes non-optional — the WebView
|
||||
* already spans the whole window, so "fullscreen" was a no-op that changed
|
||||
* nothing on screen while the status bar and navigation/gesture bar stayed
|
||||
* painted over the video.
|
||||
*
|
||||
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
|
||||
* which is reachable only from native code. Hence this bridge.
|
||||
*
|
||||
* ## Behaviour
|
||||
*
|
||||
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
|
||||
* a swipe from either edge brings them back *transiently* — over the video,
|
||||
* auto-hiding again — rather than permanently resizing the window mid-playback.
|
||||
* That is the standard behaviour for immersive video and keeps the system's own
|
||||
* back/home gestures reachable.
|
||||
*
|
||||
* [exit] restores them. It must be called when leaving fullscreen **and** when
|
||||
* the player is torn down, or the bars stay hidden on the library screens behind
|
||||
* it.
|
||||
*
|
||||
* Both must run on the main thread; the callers in MainActivity post them there,
|
||||
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
|
||||
*
|
||||
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
|
||||
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
|
||||
* republishes them and the player's control layer stops reserving space it no
|
||||
* longer needs.
|
||||
*/
|
||||
object ImmersiveModeBridge {
|
||||
|
||||
private fun controller(activity: Activity): WindowInsetsControllerCompat =
|
||||
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
|
||||
|
||||
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
|
||||
fun enter(activity: Activity) {
|
||||
controller(activity).apply {
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
android.util.Log.d("ImmersiveMode", "system bars hidden")
|
||||
}
|
||||
|
||||
/** Restore the system bars. Safe to call when they are already showing. */
|
||||
fun exit(activity: Activity) {
|
||||
controller(activity).show(WindowInsetsCompat.Type.systemBars())
|
||||
android.util.Log.d("ImmersiveMode", "system bars restored")
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,19 @@ class MainActivity : TauriActivity() {
|
||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state.
|
||||
*
|
||||
* Without this PiP only ever knew about the native ExoPlayer surface,
|
||||
* which is behind an experimental flag that defaults to off — so in the
|
||||
* shipping configuration nothing ever satisfied canEnterPip and the
|
||||
* button did nothing. (DR-160)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
@@ -325,6 +338,28 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidVideoSurface")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
|
||||
|
||||
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
|
||||
// inside a WebView cannot touch the Activity window, so without this the
|
||||
// status and navigation bars stayed painted over full-screen video.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Hide the system bars for full-screen playback. */
|
||||
@JavascriptInterface
|
||||
fun enter() {
|
||||
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Restore the system bars on leaving fullscreen or the player. */
|
||||
@JavascriptInterface
|
||||
fun exit() {
|
||||
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidImmersive")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
|
||||
|
||||
// Window insets (safe areas). The push path above races the page load, so
|
||||
// the frontend pulls the current values on mount through this bridge.
|
||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||
|
||||
+141
-33
@@ -46,6 +46,62 @@ object PictureInPictureManager {
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* State of an HTML5 `<video>` playing inside the WebView, reported by the
|
||||
* frontend.
|
||||
*
|
||||
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
|
||||
* required a SurfaceView to be attached and rendering. But native video is
|
||||
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
|
||||
* shipping configuration video plays in the WebView's `<video>` element and
|
||||
* every one of those conditions is false. `enterPip` therefore always bailed
|
||||
* with "no local video playing": PiP could not work at all, however the
|
||||
* button was pressed.
|
||||
*
|
||||
* On this path the WebView *is* the video, which inverts two things: the
|
||||
* WebView must stay visible in PiP rather than be hidden, and play/pause has
|
||||
* to reach the element rather than ExoPlayer. Both are handled below.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
@Volatile
|
||||
private var html5VideoActive = false
|
||||
|
||||
@Volatile
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
@Volatile
|
||||
private var html5AspectRatio: Rational? = null
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state from the frontend.
|
||||
*
|
||||
* @param active whether a video element is currently the playback surface
|
||||
* @param width intrinsic video width, for the PiP window's aspect ratio
|
||||
* @param height intrinsic video height
|
||||
* @param playing whether it is playing right now, for the PiP play/pause action
|
||||
*/
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
html5VideoActive = active
|
||||
html5VideoPlaying = playing
|
||||
html5AspectRatio = if (active && width > 0 && height > 0) {
|
||||
clampedRatio(width.toDouble() / height.toDouble())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when PiP would be showing the native surface rather than the WebView. */
|
||||
private fun isNativeVideoPath(): Boolean = try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "native video path check failed", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||
* and the user (or device manufacturer) can disable the feature per-app.
|
||||
@@ -64,15 +120,10 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
return try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
||||
false
|
||||
}
|
||||
// Either surface will do: the native one, or the WebView's `<video>`,
|
||||
// which is what actually plays while experimentalNativeVideo is off.
|
||||
// (DR-160)
|
||||
return isNativeVideoPath() || html5VideoActive
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,32 +176,47 @@ object PictureInPictureManager {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
null
|
||||
}
|
||||
|
||||
val surface = player.getSurfaceView() ?: return null
|
||||
// The surface has already been letterboxed to the video's aspect ratio
|
||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||
val width = surface.width
|
||||
val height = surface.height
|
||||
if (width <= 0 || height <= 0) return null
|
||||
val surface = player?.getSurfaceView()
|
||||
if (surface != null && surface.width > 0 && surface.height > 0) {
|
||||
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||
}
|
||||
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
val minRatio = 1.0 / 2.39
|
||||
val maxRatio = 2.39
|
||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
||||
// No native surface: the WebView is the video, so use the intrinsic size
|
||||
// the frontend reported. (DR-160)
|
||||
return html5AspectRatio
|
||||
}
|
||||
|
||||
// Scale to integers; Rational(width, height) directly can overflow for
|
||||
// large surfaces, and the clamped value may not match the raw pixels.
|
||||
/**
|
||||
* Clamp a ratio to the range Android accepts and express it as a [Rational].
|
||||
*
|
||||
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||
* unusually tall or wide content. Scaled to integers because
|
||||
* `Rational(width, height)` can overflow for large surfaces, and the clamped
|
||||
* value may not match the raw pixels anyway.
|
||||
*/
|
||||
private fun clampedRatio(ratio: Double): Rational {
|
||||
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
|
||||
return Rational((clamped * 1000).toInt(), 1000)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
||||
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
||||
val isPlaying = if (isNativeVideoPath()) {
|
||||
try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
html5VideoPlaying
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
@@ -222,11 +288,20 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
hideWebView(activity)
|
||||
// Hiding the WebView is correct only when the video is *behind* it on
|
||||
// the native surface. On the HTML5 path the WebView is the video, so
|
||||
// hiding it would leave an empty black PiP window — the frontend
|
||||
// instead strips its own chrome when it hears the event below.
|
||||
// (DR-160)
|
||||
if (isNativeVideoPath()) {
|
||||
hideWebView(activity)
|
||||
}
|
||||
registerReceiver(activity)
|
||||
dispatchWebEvent(activity, "jellytau-pip-entered")
|
||||
} else {
|
||||
unregisterReceiver(activity)
|
||||
showWebView()
|
||||
dispatchWebEvent(activity, "jellytau-pip-exited")
|
||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||
try {
|
||||
@@ -237,6 +312,23 @@ object PictureInPictureManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a DOM event into the WebView.
|
||||
*
|
||||
* The HTML5 PiP path is a conversation with the frontend rather than
|
||||
* something native can do alone: it has to be told to strip its chrome when
|
||||
* the window shrinks, and to play/pause the element. (DR-160)
|
||||
*/
|
||||
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||
val webView = findWebView(activity.window.decorView) ?: return
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideWebView(activity: Activity) {
|
||||
val webView = findWebView(activity.window.decorView)
|
||||
if (webView == null) {
|
||||
@@ -264,14 +356,30 @@ object PictureInPictureManager {
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||
|
||||
if (isNativeVideoPath()) {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (control) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
} else {
|
||||
// The WebView owns playback here, so the command has to reach
|
||||
// the `<video>` element. Driving ExoPlayer instead would do
|
||||
// nothing at all, which is what a PiP button on the HTML5 path
|
||||
// used to do. (DR-160)
|
||||
val name = when (control) {
|
||||
CONTROL_PLAY -> "jellytau-pip-play"
|
||||
CONTROL_PAUSE -> "jellytau-pip-pause"
|
||||
else -> return
|
||||
}
|
||||
dispatchWebEvent(activity, name)
|
||||
html5VideoPlaying = control == CONTROL_PLAY
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
|
||||
+77
-19
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
|
||||
// media3 seeks by more routes than seekTo(long), and the ones below
|
||||
// reach the *real* ExoPlayer if they are not overridden — bypassing
|
||||
// Rust entirely and operating on the handoff stream's relative
|
||||
// timeline. That is the same mechanism as the truncation bug, reached
|
||||
// by a different door.
|
||||
//
|
||||
// seekToDefaultPosition is deliberately swallowed rather than
|
||||
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
|
||||
// player and then calls play(); on a handoff stream the seek lands at
|
||||
// stream zero — the point the screen was locked at — which is exactly
|
||||
// the reported jump-back. Sending "seek:0.0" instead would be worse
|
||||
// still, restarting the whole episode. Rust already owns what "play
|
||||
// after the stream ended" means (truncation recovery, or advancing to
|
||||
// the next episode), and the play() that follows reaches it, so the
|
||||
// right move here is to not move at all.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-159
|
||||
override fun seekToDefaultPosition() {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||
android.util.Log.d(
|
||||
"JellyTauPlaybackService",
|
||||
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
|
||||
)
|
||||
}
|
||||
|
||||
// `currentPosition` is ExoPlayer's own, so it is relative during a
|
||||
// handoff; the base makes the target absolute, which is what Rust
|
||||
// expects from every command on this boundary.
|
||||
override fun seekBack() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun seekForward() {
|
||||
val target =
|
||||
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
|
||||
.coerceAtLeast(0.0)
|
||||
nativeOnMediaCommand("seek:$target")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
// The handoff base (ms): during a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
|
||||
// starts at 0 *there* and every position it reports is relative to it. This
|
||||
// is the number that converts one back to a real position on the episode.
|
||||
//
|
||||
// It is deliberately read, not applied, here. This used to be a display-only
|
||||
// correction added at the two setPlaybackState calls below, which left every
|
||||
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
|
||||
// seeks — working in the relative timeline while treating it as absolute, each
|
||||
// crossing silently losing exactly `base` seconds. The conversion now happens
|
||||
// once, in JellyTauPlayer's position tick, so everything downstream of it
|
||||
// speaks the episode's timeline; applying it again here would double-count.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
@Volatile
|
||||
var handoffBaseMs: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
* Set the handoff base (seconds). Called by the native layer when entering or
|
||||
* leaving a background-audio handoff; 0 clears it for normal playback, where
|
||||
* ExoPlayer's position is already absolute.
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
fun setHandoffBase(offsetSeconds: Double) {
|
||||
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
session.setMetadata(metadataBuilder.build())
|
||||
|
||||
// Update MediaSession playback state (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Already absolute: this call comes from Rust, whose stored position is on
|
||||
// the episode's timeline. (DR-159)
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
@@ -337,15 +395,15 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||
* from the last play/pause and drifts out of sync with actual playback.
|
||||
*
|
||||
* @param position Position in milliseconds
|
||||
* @param position Absolute position in milliseconds, on the item's own
|
||||
* timeline — the caller has already applied [handoffBaseMs].
|
||||
* @param isPlaying Whether playback is currently active
|
||||
*/
|
||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
|
||||
@@ -1030,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||
while (isActive) {
|
||||
if (exoPlayer.isPlaying) {
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||
// THE boundary between the two timelines, and the only place
|
||||
// the conversion happens.
|
||||
//
|
||||
// During a background-audio handoff the stream is requested
|
||||
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
|
||||
// is that point and everything it reports is relative to it.
|
||||
// The base used to be added only where a position was *shown*
|
||||
// (the lockscreen scrubber), leaving progress reports to
|
||||
// Jellyfin, the frontend and the truncation maths all working
|
||||
// in the relative timeline while treating it as absolute —
|
||||
// each crossing losing exactly `base` seconds, which is why the
|
||||
// jump-back distance varied with where the screen was locked.
|
||||
// Shifting once, here, means every consumer downstream speaks
|
||||
// the episode's timeline and none of them needs to know a
|
||||
// handoff happened.
|
||||
//
|
||||
// The duration is shifted with it, so position and duration
|
||||
// stay on the same timeline — the stream's own length is only
|
||||
// what remains after the handoff point.
|
||||
//
|
||||
// TRACES: UR-040 | DR-159
|
||||
val service = JellyTauPlaybackService.getInstance()
|
||||
val baseMs = service?.handoffBaseMs ?: 0L
|
||||
|
||||
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
|
||||
val position = positionMs / 1000.0
|
||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||
val duration =
|
||||
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
|
||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||
nativeOnPositionUpdate(position, duration)
|
||||
|
||||
// Keep the lockscreen scrubber live. Without this the
|
||||
// MediaSession position only refreshes on play/pause, so the
|
||||
// scrubber freezes mid-track and drifts out of sync.
|
||||
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||
service?.updatePlaybackPosition(positionMs, true)
|
||||
}
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
@@ -520,15 +520,27 @@ pub(crate) async fn requeue_mistyped_video_downloads(
|
||||
/// `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.
|
||||
/// 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>(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
target_dir: &str,
|
||||
only_ids: Option<&[i64]>,
|
||||
resolve: F,
|
||||
) -> Result<ResumeQueuedResult, String>
|
||||
where
|
||||
F: Fn(String, String, String) -> Fut,
|
||||
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
|
||||
// queued from a media card never carry one (`download_item` does not record
|
||||
// it), and defaulting that NULL to 'audio' resolved movies against
|
||||
@@ -541,6 +553,16 @@ where
|
||||
.map(|t| format!("'{t}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.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!(
|
||||
"SELECT d.id, d.item_id,
|
||||
COALESCE(
|
||||
@@ -552,7 +574,7 @@ where
|
||||
COALESCE(d.quality_preset, 'original')
|
||||
FROM downloads d
|
||||
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
|
||||
.query_many(rows_query, |row| {
|
||||
@@ -631,8 +653,6 @@ pub async fn resume_queued_downloads(
|
||||
) -> Result<ResumeQueuedResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
use crate::repository::HybridRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// The pump needs a target_dir; use the same storage root the other download
|
||||
@@ -678,17 +698,19 @@ pub async fn resume_queued_downloads(
|
||||
let outcome = resolve_pending_download_urls(
|
||||
&db_service,
|
||||
&target_dir,
|
||||
None,
|
||||
move |item_id: String, media_type: String, quality: String| {
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
@@ -862,12 +884,14 @@ mod tests {
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out = resolve_pending_download_urls(
|
||||
&db,
|
||||
"/data/downloads",
|
||||
None,
|
||||
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
@@ -883,15 +907,79 @@ mod tests {
|
||||
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]
|
||||
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "bad", "pending", None, None).await;
|
||||
|
||||
// Resolver returns None (e.g. server lookup failed).
|
||||
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 0);
|
||||
assert_eq!(out.failed, 1);
|
||||
@@ -921,7 +1009,7 @@ mod tests {
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
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);
|
||||
async move {
|
||||
seen.lock().unwrap().push((item_id.clone(), media_type));
|
||||
@@ -954,7 +1042,7 @@ mod tests {
|
||||
|
||||
let seen = Arc::new(Mutex::new(String::new()));
|
||||
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);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
@@ -978,7 +1066,7 @@ mod tests {
|
||||
|
||||
let seen = Arc::new(Mutex::new(String::new()));
|
||||
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);
|
||||
async move {
|
||||
*seen.lock().unwrap() = media_type;
|
||||
@@ -1038,13 +1126,17 @@ mod tests {
|
||||
let db = test_db();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
let out = resolve_pending_download_urls(
|
||||
&db,
|
||||
"/data",
|
||||
None,
|
||||
|item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
|
||||
@@ -350,57 +350,209 @@ pub async fn download_item(
|
||||
Ok(download_id)
|
||||
}
|
||||
|
||||
/// Queue an entire album for download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn download_album(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
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())
|
||||
};
|
||||
/// One track of an album, as the album-download path queues it.
|
||||
///
|
||||
/// `artist_name` carries whatever the catalog holds for the track's artists (a
|
||||
/// JSON array, as stored on `items.artists`); it is display metadata for the
|
||||
/// downloads list, not a lookup key.
|
||||
///
|
||||
/// TRACES: UR-018, UR-055 | DR-173
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AlbumTrack {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
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(
|
||||
"SELECT id, name, artists, album_name FROM items
|
||||
WHERE album_id = ? AND item_type = 'Audio'
|
||||
"SELECT id, name, artists, album_name, index_number FROM items
|
||||
WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
|
||||
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| {
|
||||
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
|
||||
.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
|
||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
||||
let file_path = format!("{}/{}.mp3", base_path, sanitize_filename(&track_name));
|
||||
tracks
|
||||
.iter()
|
||||
.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(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name)
|
||||
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?)
|
||||
"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, ?, ?, ?, 'audio')
|
||||
ON CONFLICT(item_id, user_id) DO UPDATE SET
|
||||
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),
|
||||
artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
|
||||
album_name = COALESCE(excluded.album_name, downloads.album_name)",
|
||||
vec![
|
||||
QueryParam::String(track_id.clone()),
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(track.id.clone()),
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(file_path),
|
||||
QueryParam::String(track_name),
|
||||
artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(track.name.clone()),
|
||||
track
|
||||
.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(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![
|
||||
QueryParam::String(track_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(track.id.clone()),
|
||||
QueryParam::String(user_id.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -428,6 +580,129 @@ pub async fn download_album(
|
||||
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
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -845,7 +1120,19 @@ pub async fn get_downloads(
|
||||
Ok(DownloadsResponse { downloads, stats })
|
||||
}
|
||||
|
||||
/// Pause a download
|
||||
/// Pause a download.
|
||||
///
|
||||
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||
/// streaming task knew nothing about the row and kept running, then overwrote it
|
||||
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||
/// undid itself — the reported "pause does not work". Signalling the worker is
|
||||
/// what actually stops the bytes; it leaves the `.part` file in place so
|
||||
/// [`resume_download`] can continue from it.
|
||||
///
|
||||
/// A queued (not yet started) download has no worker to signal, and the status
|
||||
/// write alone is enough — the pump skips anything that is not `pending`.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(
|
||||
@@ -858,19 +1145,34 @@ pub async fn pause_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'",
|
||||
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let was_running = crate::download::stop::signal(download_id);
|
||||
info!(
|
||||
"[pause] Download {} paused (in flight: {})",
|
||||
download_id, was_running
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume a paused download
|
||||
/// Resume a paused download.
|
||||
///
|
||||
/// Flipping the row back to `pending` is likewise not enough on its own: the
|
||||
/// pump is not a poller, it runs when something calls it, so a resumed download
|
||||
/// sat untouched until some unrelated event happened to pump the queue. That is
|
||||
/// the other half of "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
@@ -879,11 +1181,22 @@ pub async fn resume_download(
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'",
|
||||
"UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Drop any stale stop flag before the pump can start this id again, or the
|
||||
// resumed run would read the pause that stopped it and halt immediately.
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -923,6 +1236,13 @@ pub async fn cancel_download(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Stop the worker if this download is actually running. Without this the
|
||||
// task keeps streaming into a `.part` file whose `downloads` row has just
|
||||
// been deleted — bytes with nothing pointing at them, and the file below is
|
||||
// removed while still being written to. (DR-168)
|
||||
crate::download::stop::signal(download_id);
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Unregister from download manager (in case it was active)
|
||||
{
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -934,10 +1254,12 @@ pub async fn cancel_download(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete partial file if exists
|
||||
// Delete the partial file, and any completed file, if present. Both go
|
||||
// through `partial_path` so this cannot drift from what the worker writes —
|
||||
// it did, and every cancelled download leaked its partial. (DR-169)
|
||||
if let Some(path) = file_path {
|
||||
let partial_path = format!("{}.part", path);
|
||||
let _ = std::fs::remove_file(&partial_path); // Ignore errors
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1244,8 +1566,6 @@ pub async fn enqueue_video_downloads(
|
||||
download_ids: Vec<i64>,
|
||||
target_dir: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
@@ -1270,10 +1590,12 @@ pub async fn enqueue_video_downloads(
|
||||
}
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
// Build the download URL, resolving the source's audio codec first so a
|
||||
// track this device cannot decode is re-encoded on the way down rather
|
||||
// than saved as a silent file (DR-167).
|
||||
let stream_url =
|
||||
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
|
||||
.await;
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
@@ -1530,7 +1852,11 @@ fn spawn_download_worker(
|
||||
let _ = progress_app.emit("download-event", event);
|
||||
};
|
||||
|
||||
let result = worker.download(&task, on_progress).await;
|
||||
// Registering returns a fresh flag, so a download resumed after a pause
|
||||
// does not inherit the stop that ended its previous run. (DR-168)
|
||||
let stop_flag = crate::download::stop::register(download_id);
|
||||
let result = worker.download(&task, &stop_flag, on_progress).await;
|
||||
crate::download::stop::clear(download_id);
|
||||
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
@@ -1601,6 +1927,17 @@ fn spawn_download_worker(
|
||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||
}
|
||||
}
|
||||
// A pause or cancel is not a failure. The row already says `paused`
|
||||
// (or the row is gone, for a cancel), and overwriting that with
|
||||
// `failed` is what made a pause look like an error and stranded the
|
||||
// download outside the resumable set. The `.part` file is deliberately
|
||||
// left alone — it is what the resume continues from. (DR-168)
|
||||
Err(e) if e.is_stopped() => {
|
||||
info!(
|
||||
"[pump] Download {} stopped by request; partial file kept for resume",
|
||||
download_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
@@ -1848,17 +2185,26 @@ pub async fn clear_stale_downloads(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// Get file paths for stale downloads (pending/paused/failed)
|
||||
// Ids as well as paths: a stale row may still have a worker attached (a
|
||||
// 'downloading' row that was paused mid-flight is 'paused' here), and
|
||||
// deleting the row without stopping the task leaves it writing to a file we
|
||||
// are about to remove. (DR-168)
|
||||
let file_query = Query::with_params(
|
||||
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
"SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
vec![QueryParam::String(user_id.clone())],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
let stale: Vec<(i64, String)> = db_service
|
||||
.query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for (id, _) in &stale {
|
||||
crate::download::stop::signal(*id);
|
||||
crate::download::stop::clear(*id);
|
||||
}
|
||||
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
|
||||
|
||||
// Delete all pending, paused, and failed downloads (but keep completed ones)
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
|
||||
@@ -1870,10 +2216,12 @@ pub async fn clear_stale_downloads(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete any partial files
|
||||
// Delete any partial files, via the shared helper so this cannot drift from
|
||||
// what the worker actually writes. (DR-169)
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
let target = std::path::PathBuf::from(&path);
|
||||
let _ = std::fs::remove_file(&target);
|
||||
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
@@ -2585,4 +2933,294 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +342,29 @@ pub enum AudioTrackSwitchResponse {
|
||||
},
|
||||
}
|
||||
|
||||
/// Response for a mid-playback streaming-quality change.
|
||||
///
|
||||
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||
/// has to reload anything, so no strategy branch lives in the UI.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||
pub enum StreamQualityResponse {
|
||||
/// The native backend was reloaded here; nothing left for the frontend.
|
||||
Native {
|
||||
/// Position playback resumed at.
|
||||
position: f64,
|
||||
},
|
||||
/// HTML5 must reload its element with this URL.
|
||||
ReloadStream {
|
||||
/// New stream URL, already transcoded to the requested ceiling.
|
||||
new_url: String,
|
||||
/// Position to resume from.
|
||||
position: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Helper function to create MediaItem from video request
|
||||
///
|
||||
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
||||
@@ -431,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.
|
||||
///
|
||||
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
|
||||
@@ -687,6 +753,9 @@ pub async fn player_enter_background_audio(
|
||||
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);
|
||||
|
||||
// Build an AUDIO media item pointing at the audio-only stream. We do not use
|
||||
@@ -730,21 +799,28 @@ pub async fn player_enter_background_audio(
|
||||
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||
// 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;
|
||||
// Remember where the video was: the audio stream's zero == this position
|
||||
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||
// this base to the native player's relative position to get the absolute one.
|
||||
// The controller owns it so a backend-driven advance to the next episode
|
||||
// clears it along with the stream it described.
|
||||
controller.enter_background_audio(position_seconds);
|
||||
// Remember where the video was: for a stream the audio's zero == this
|
||||
// position (the URL was built with StartTimeTicks=position_seconds), so on
|
||||
// exit we add this base to the native player's relative position to get the
|
||||
// absolute one. The controller owns it so a backend-driven advance to the
|
||||
// next episode clears it along with the stream it described.
|
||||
controller.enter_background_audio(plan.base_seconds);
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff
|
||||
// position via StartTimeTicks; the stream's timeline begins at 0 == that
|
||||
// point, so an extra seek(position_seconds) would jump PAST the content.
|
||||
// Seek ONLY a local file. The audio-only URL already starts at the handoff
|
||||
// position via StartTimeTicks — its timeline begins at 0 == that point — so
|
||||
// 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();
|
||||
if let Some(emitter) = controller.event_emitter() {
|
||||
@@ -770,22 +846,30 @@ pub async fn player_enter_background_audio(
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<f64, String> {
|
||||
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// The base offset (handoff position) + native player's relative position =
|
||||
// the absolute position to resume the video at. Zero after a backend-driven
|
||||
// episode advance, whose stream already starts at its own zero.
|
||||
let base = controller.exit_background_audio();
|
||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||
let relative = controller.position();
|
||||
|
||||
// Read the position BEFORE clearing either base. The position tick applies the
|
||||
// base natively, so a tick landing between "base cleared" and "position read"
|
||||
// would hand back a relative position — the whole bug, reintroduced at the one
|
||||
// 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.
|
||||
// (DR-159)
|
||||
//
|
||||
// `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.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
controller.exit_background_audio();
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
let absolute = base + relative;
|
||||
info!(
|
||||
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
|
||||
base, relative, absolute
|
||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||
absolute
|
||||
);
|
||||
Ok(absolute)
|
||||
}
|
||||
@@ -1207,9 +1291,12 @@ pub async fn player_seek(
|
||||
let position_ticks = (position * 10_000_000.0) as i64;
|
||||
client.session_seek(session_id, position_ticks).await?;
|
||||
} else {
|
||||
// Local playback
|
||||
// Local playback. seek_absolute, not seek: the position came from the UI,
|
||||
// which shows the whole item, so during a background-audio handoff it has
|
||||
// to be resolved against the episode's timeline rather than the handoff
|
||||
// stream's. (DR-159)
|
||||
let controller = player.0.lock().await;
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
controller.seek_absolute(position).await?;
|
||||
}
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
@@ -1305,7 +1392,6 @@ pub async fn player_seek_video(
|
||||
.get_video_stream_url(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
Some(position),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
@@ -1316,6 +1402,13 @@ pub async fn player_seek_video(
|
||||
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 {
|
||||
new_url,
|
||||
seek_offset: position,
|
||||
@@ -1329,7 +1422,6 @@ pub async fn player_seek_video(
|
||||
.get_video_stream_url(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
Some(position),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
@@ -1367,6 +1459,11 @@ pub async fn player_seek_video(
|
||||
} else {
|
||||
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!(
|
||||
@@ -1417,12 +1514,13 @@ pub async fn player_switch_audio_track(
|
||||
.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
|
||||
.get_video_stream_url(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
current_position,
|
||||
Some(stream_index),
|
||||
)
|
||||
.await
|
||||
@@ -1443,6 +1541,115 @@ pub async fn player_switch_audio_track(
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the bandwidth ceiling of the video that is playing *right now*.
|
||||
///
|
||||
/// A cap is a property of the stream the server is producing, so unlike a volume
|
||||
/// change it cannot be applied to a stream already in flight — the stream has to
|
||||
/// be re-opened at the new quality and resumed at the current position. That is
|
||||
/// the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
/// native backend is reloaded here.
|
||||
///
|
||||
/// The change applies to this playback *and* to everything started afterwards
|
||||
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||
/// the in-player picker is a "this film, this connection" control, and the
|
||||
/// durable default belongs to Settings. `player_set_video_settings` is the one
|
||||
/// that writes to the database.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_stream_quality(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
video_settings: State<'_, VideoSettingsWrapper>,
|
||||
repository_handle: String,
|
||||
quality: crate::settings::StreamingQuality,
|
||||
use_html5: bool,
|
||||
current_position: Option<f64>,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<StreamQualityResponse, String> {
|
||||
info!(
|
||||
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
|
||||
quality.label(),
|
||||
use_html5,
|
||||
current_position
|
||||
);
|
||||
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
let jellyfin_item_id = {
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||
|
||||
if current_item.media_type != MediaType::Video {
|
||||
return Err("Current item is not a video".to_string());
|
||||
}
|
||||
|
||||
current_item
|
||||
.jellyfin_id()
|
||||
.ok_or("Current item has no Jellyfin ID")?
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Set the ceiling *before* building the URL — the builder reads it.
|
||||
crate::repository::online::set_streaming_quality(quality);
|
||||
{
|
||||
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||
settings.streaming_quality = quality;
|
||||
}
|
||||
|
||||
let position = current_position.unwrap_or(0.0);
|
||||
let new_url = repository
|
||||
.get_video_stream_url(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
||||
|
||||
if use_html5 {
|
||||
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
|
||||
}
|
||||
|
||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
|
||||
// position without 400ing every segment — DR-181), so it is seeked back to
|
||||
// where the picture was.
|
||||
{
|
||||
let controller = player.0.lock().await;
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
|
||||
let queue_arc = controller.queue();
|
||||
{
|
||||
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
if !queue.update_current_stream_url(new_url.clone()) {
|
||||
return Err("Failed to update stream URL in queue".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
let updated_item = queue.current().ok_or("No current item after URL update")?;
|
||||
controller
|
||||
.load_and_play(updated_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if position > 0.0 {
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_audio_track(
|
||||
@@ -1713,7 +1920,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||
|
||||
PlayerStatus {
|
||||
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(),
|
||||
volume: controller.volume(),
|
||||
muted: controller.muted(),
|
||||
@@ -2705,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
|
||||
/// 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.
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
|
||||
|
||||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
|
||||
///
|
||||
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
|
||||
/// process-lifetime state) because forgetting it is the one failure that costs
|
||||
/// the user something real: a limit set for a metered connection that silently
|
||||
/// reverts to uncapped on the next launch spends their data allowance without
|
||||
/// ever showing them a changed setting.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
|
||||
pub async fn player_set_video_settings(
|
||||
video_settings: State<'_, VideoSettingsWrapper>,
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
settings: VideoSettings,
|
||||
) -> Result<VideoSettings, String> {
|
||||
let validated = settings.with_countdown_clamped();
|
||||
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
|
||||
*current = validated.clone();
|
||||
} // Drop MutexGuard before await
|
||||
|
||||
// The bandwidth ceiling is read by the repository's URL builders and by the
|
||||
// PlaybackInfo negotiation, neither of which can see this wrapper.
|
||||
// TRACES: UR-074 | DR-162
|
||||
crate::repository::online::set_streaming_quality(validated.streaming_quality);
|
||||
persist_streaming_quality(&db, validated.streaming_quality).await;
|
||||
|
||||
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
||||
let controller = player.0.lock().await;
|
||||
controller.set_autoplay_settings(AutoplaySettings {
|
||||
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
/// The bandwidth ceilings the quality picker may offer, each with the label and
|
||||
/// one-line detail to show for it, highest first.
|
||||
///
|
||||
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||
/// frontend reads them here rather than encoding them — the same arrangement as
|
||||
/// [`player_get_eq_presets`].
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_streaming_qualities(
|
||||
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
|
||||
Ok(StreamingQuality::ALL
|
||||
.iter()
|
||||
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
|
||||
/// setting has already been applied in memory, and refusing the whole call
|
||||
/// because the write failed would leave the UI showing a cap that *is* active.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
|
||||
let db_service = {
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let encoded = match serde_json::to_string(&quality) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
|
||||
QueryParam::String(encoded),
|
||||
],
|
||||
);
|
||||
|
||||
if let Err(e) = db_service.execute(query).await {
|
||||
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted bandwidth ceiling at startup, into both the repository
|
||||
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
|
||||
///
|
||||
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||
/// default — uncapped — in place, so a database problem degrades to the old
|
||||
/// behaviour rather than to an arbitrary limit.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
|
||||
let db_service = {
|
||||
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
|
||||
return;
|
||||
};
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
|
||||
);
|
||||
|
||||
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(stored) = stored else { return };
|
||||
let quality: StreamingQuality = match serde_json::from_str(&stored) {
|
||||
Ok(quality) => quality,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
|
||||
stored, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::repository::online::set_streaming_quality(quality);
|
||||
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
|
||||
video_settings.0.lock_safe().streaming_quality = quality;
|
||||
}
|
||||
info!(
|
||||
"[VideoSettings] Restored streaming quality cap: {}",
|
||||
quality.label()
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_video_settings(
|
||||
|
||||
@@ -571,7 +571,13 @@ pub async fn repository_get_playback_info(
|
||||
.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]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_video_stream_url(
|
||||
@@ -579,17 +585,11 @@ pub async fn repository_get_video_stream_url(
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: Option<String>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.get_video_stream_url(
|
||||
&item_id,
|
||||
media_source_id.as_deref(),
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -804,7 +804,7 @@ pub fn repository_get_subtitle_url(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(dead_code)]
|
||||
pub fn repository_get_video_download_url(
|
||||
pub async fn repository_get_video_download_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
@@ -812,9 +812,16 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
// Async because the audio-codec policy has to know what the source's audio
|
||||
// is before it can decide whether the file may be copied verbatim (DR-171).
|
||||
// The frontend calls this exactly as before — the decision stays in Rust.
|
||||
Ok(crate::repository::resolve_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
media_source_id.as_deref(),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
|
||||
@@ -867,6 +867,86 @@ pub async fn storage_mark_played(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the watched flag locally for an item **and everything inside it**.
|
||||
///
|
||||
/// This backs the watched toggle, and is deliberately separate from
|
||||
/// [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
/// increments `play_count` — because the toggle has two directions and applies
|
||||
/// to containers.
|
||||
///
|
||||
/// The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
/// online the server fixes up the children on the next read; with no server to
|
||||
/// ask, marking a season watched would otherwise tick the season and leave every
|
||||
/// episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
/// columns the rest of the offline layer uses, so an id that is not cached
|
||||
/// selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
///
|
||||
/// Un-marking clears the resume position too, matching the server, so an item
|
||||
/// un-marked offline does not come back offering to resume from a position it is
|
||||
/// no longer meant to have.
|
||||
///
|
||||
/// `pending_sync = 1` hands the rows to the sync drain.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_set_watched(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
watched: bool,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself plus its descendants: a season's episodes reach it by
|
||||
// season_id, a series' by series_id, its seasons by parent_id, an album's
|
||||
// tracks by album_id.
|
||||
let targets = "SELECT id FROM items
|
||||
WHERE id = ? OR parent_id = ? OR album_id = ?
|
||||
OR season_id = ? OR series_id = ?";
|
||||
|
||||
let sql = if watched {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 1,
|
||||
play_count = MAX(user_data.play_count, 1),
|
||||
last_played_at = CURRENT_TIMESTAMP,
|
||||
pending_sync = 1"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
|
||||
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_played = 0,
|
||||
play_count = 0,
|
||||
playback_position_ticks = 0,
|
||||
pending_sync = 1"
|
||||
)
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get playback progress for an item
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -52,6 +52,12 @@ pub enum QueuedOp {
|
||||
MarkPlayed {
|
||||
item_id: String,
|
||||
},
|
||||
/// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
|
||||
/// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
|
||||
/// item un-marked offline does not come back carrying a stale position.
|
||||
MarkUnplayed {
|
||||
item_id: String,
|
||||
},
|
||||
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
|
||||
/// (DR-120). Supported so a row written by an older build still lands.
|
||||
Favorite {
|
||||
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
|
||||
position_ticks: ticks(),
|
||||
}),
|
||||
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
|
||||
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
|
||||
"mark_favorite" => Ok(QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite: true,
|
||||
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
|
||||
position_ticks,
|
||||
} => self.report_playback_stopped(item_id, *position_ticks).await,
|
||||
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
|
||||
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
|
||||
QueuedOp::Favorite {
|
||||
item_id,
|
||||
is_favorite,
|
||||
@@ -1031,4 +1039,55 @@ mod tests {
|
||||
assert!(parse_queued_op("mark_played", None, None).is_err());
|
||||
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
|
||||
}
|
||||
|
||||
/// Un-marking watched queues like marking watched does, so the toggle works
|
||||
/// in both directions while the server is unreachable rather than only one.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158 | UT-154
|
||||
#[test]
|
||||
fn test_parse_accepts_mark_unplayed() {
|
||||
assert_eq!(
|
||||
parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
|
||||
QueuedOp::MarkUnplayed {
|
||||
item_id: "ep1".to_string()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(parse_queued_op("mark_unplayed", None, None).is_err());
|
||||
}
|
||||
|
||||
/// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
|
||||
/// mark-unplayed, which also zeroes the resume position, so a series returns
|
||||
/// to "never watched" rather than keeping a stale position.
|
||||
///
|
||||
/// TRACES: UR-073 | DR-158 | UT-154
|
||||
#[tokio::test]
|
||||
async fn test_drain_pushes_mark_unplayed() {
|
||||
let db = test_db();
|
||||
seed(
|
||||
&db,
|
||||
&[(
|
||||
"u1",
|
||||
"mark_unplayed",
|
||||
"ep9",
|
||||
None,
|
||||
"pending",
|
||||
0,
|
||||
"2026-08-01T10:00:00Z",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sink = RecordingSink::new();
|
||||
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sink.calls(),
|
||||
vec![QueuedOp::MarkUnplayed {
|
||||
item_id: "ep9".to_string()
|
||||
}],
|
||||
);
|
||||
assert_eq!(report.pushed, 1);
|
||||
assert_eq!(report.remaining, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod stop;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Stop signalling for in-flight downloads.
|
||||
//!
|
||||
//! TRACES: UR-055 | DR-168
|
||||
//!
|
||||
//! Pausing and cancelling used to be database-only: `pause_download` wrote
|
||||
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
|
||||
//! download stack — no token, no flag, no abort — so the streaming task kept
|
||||
//! running, kept writing bytes, and on finishing overwrote the row with
|
||||
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
|
||||
//! which is precisely the reported "pause does not work".
|
||||
//!
|
||||
//! This is the missing half: a flag per in-flight download that the worker reads
|
||||
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
|
||||
//! leave the `.part` file **intact**, which is what lets a resume pick up from
|
||||
//! where it stopped via the existing HTTP Range request.
|
||||
//!
|
||||
//! Kept as a module-level registry rather than on `DownloadManager` because the
|
||||
//! two sides never meet: the command handler holds the manager's lock, while the
|
||||
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
|
||||
//! Tauri state. A registry both can reach is the smallest thing that works.
|
||||
//!
|
||||
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// download id → its stop flag, for downloads currently in flight.
|
||||
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
|
||||
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Register `download_id` as in-flight and hand back its stop flag.
|
||||
///
|
||||
/// Called by the worker as it starts. A previous flag for the same id is
|
||||
/// replaced, so a download that is paused and later resumed does not inherit the
|
||||
/// set flag from its last run and stop immediately.
|
||||
pub fn register(download_id: i64) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
registry().lock_safe().insert(download_id, flag.clone());
|
||||
flag
|
||||
}
|
||||
|
||||
/// Ask an in-flight download to stop.
|
||||
///
|
||||
/// Returns whether one was actually in flight — the caller uses this to tell a
|
||||
/// running download (which will stop shortly) from a merely queued one (which
|
||||
/// the database update alone has already handled).
|
||||
pub fn signal(download_id: i64) -> bool {
|
||||
match registry().lock_safe().get(&download_id) {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a download's flag. Called when its task finishes, however it ended.
|
||||
pub fn clear(download_id: i64) {
|
||||
registry().lock_safe().remove(&download_id);
|
||||
}
|
||||
|
||||
/// Whether a stop has been requested for `download_id`.
|
||||
///
|
||||
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
|
||||
/// up, so this exists for the tests that assert the registry's behaviour.
|
||||
#[cfg(test)]
|
||||
pub fn is_stopping(download_id: i64) -> bool {
|
||||
registry()
|
||||
.lock_safe()
|
||||
.get(&download_id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are per-test so the shared registry cannot leak between them.
|
||||
fn unique_id(seed: i64) -> i64 {
|
||||
900_000 + seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_registered_download_starts_unflagged() {
|
||||
let id = unique_id(1);
|
||||
let flag = register(id);
|
||||
assert!(!flag.load(Ordering::SeqCst));
|
||||
assert!(!is_stopping(id));
|
||||
clear(id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_sets_the_flag_the_worker_reads() {
|
||||
let id = unique_id(2);
|
||||
let flag = register(id);
|
||||
|
||||
assert!(signal(id), "a registered download reports as in flight");
|
||||
assert!(
|
||||
flag.load(Ordering::SeqCst),
|
||||
"the worker's own handle sees it"
|
||||
);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
clear(id);
|
||||
}
|
||||
|
||||
/// The pump only needs to abort a task that exists; a queued row is handled
|
||||
/// by its database status alone.
|
||||
#[test]
|
||||
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
|
||||
assert!(!signal(unique_id(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_forgets_the_download() {
|
||||
let id = unique_id(4);
|
||||
register(id);
|
||||
signal(id);
|
||||
clear(id);
|
||||
|
||||
assert!(!is_stopping(id));
|
||||
assert!(!signal(id), "a cleared download is no longer in flight");
|
||||
}
|
||||
|
||||
/// The bug this guards: pause sets the flag, and resume re-runs the same
|
||||
/// download id. If registering reused the old flag, the resumed run would see
|
||||
/// a set flag and stop instantly — a download that could never be resumed.
|
||||
#[test]
|
||||
fn test_reregistering_clears_a_previous_stop() {
|
||||
let id = unique_id(5);
|
||||
register(id);
|
||||
signal(id);
|
||||
assert!(is_stopping(id));
|
||||
|
||||
let fresh = register(id);
|
||||
assert!(!fresh.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
!is_stopping(id),
|
||||
"a resumed download must not inherit the pause"
|
||||
);
|
||||
|
||||
clear(id);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||
|
||||
use log::warn;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -31,10 +32,18 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file with retry logic and progress tracking
|
||||
/// Download a file with retry logic and progress tracking.
|
||||
///
|
||||
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
|
||||
/// checked between chunks and again between retries, so a paused download
|
||||
/// stops promptly rather than after its next backoff — up to 45 seconds
|
||||
/// away, which reads as the pause having done nothing.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-168
|
||||
pub async fn download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -43,7 +52,10 @@ impl DownloadWorker {
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
match self.try_download(task, &on_progress).await {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
match self.try_download(task, stop, &on_progress).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
@@ -63,6 +75,7 @@ impl DownloadWorker {
|
||||
async fn try_download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
stop: &AtomicBool,
|
||||
on_progress: &F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
@@ -76,7 +89,7 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let temp_path = partial_path(&task.target_path);
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
@@ -100,22 +113,32 @@ impl DownloadWorker {
|
||||
return Err(DownloadError::Http(response.status().as_u16()));
|
||||
}
|
||||
|
||||
// Get content length
|
||||
// Did the server actually honour the Range? A transcode does not, and
|
||||
// answers 200 with the whole stream — appending that would duplicate what
|
||||
// we already hold. (DR-170)
|
||||
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
|
||||
if existing_bytes > 0 && resume_from == 0 {
|
||||
warn!(
|
||||
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
|
||||
instead of appending to {} existing bytes",
|
||||
response.status().as_u16(),
|
||||
task.target_path.display(),
|
||||
existing_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Get content length. Absent on a chunked transcode, which is why progress
|
||||
// for a non-`original` preset has no percentage to show.
|
||||
let _total_bytes = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| {
|
||||
if existing_bytes > 0 {
|
||||
len + existing_bytes
|
||||
} else {
|
||||
len
|
||||
}
|
||||
});
|
||||
.map(|len| len + resume_from);
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
// Append only when resuming a range the server agreed to; otherwise
|
||||
// create/truncate so the restarted stream replaces the stale bytes.
|
||||
let mut file = if resume_from > 0 {
|
||||
fs::OpenOptions::new().append(true).open(&temp_path).await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
@@ -123,11 +146,22 @@ impl DownloadWorker {
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Stream download with progress tracking
|
||||
let mut downloaded = existing_bytes;
|
||||
let mut downloaded = resume_from;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_progress_emit = std::time::Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
// Checked before writing, so a paused download stops on a byte
|
||||
// boundary the `.part` file already accounts for — the Range request
|
||||
// on resume then asks for exactly what is missing. Flushing what we
|
||||
// have and leaving the file in place is the whole mechanism behind
|
||||
// "resume", so this must never delete it. (DR-168)
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
return Err(DownloadError::Stopped);
|
||||
}
|
||||
|
||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
@@ -138,7 +172,7 @@ impl DownloadWorker {
|
||||
|
||||
// Emit progress every 500ms or every MB
|
||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||
|| downloaded % (1024 * 1024) == 0
|
||||
|| downloaded.is_multiple_of(1024 * 1024)
|
||||
{
|
||||
last_progress_emit = std::time::Instant::now();
|
||||
on_progress(downloaded, _total_bytes);
|
||||
@@ -167,6 +201,56 @@ impl DownloadWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where to resume writing a partial download, given how the server answered.
|
||||
///
|
||||
/// A byte offset of 0 means "start the file again"; anything else means "append
|
||||
/// from here".
|
||||
///
|
||||
/// This is what makes non-`original` downloads survive. Those presets ask
|
||||
/// Jellyfin to **transcode**, and a live transcode is chunked with no
|
||||
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
|
||||
/// answers `200` with the whole stream from the beginning, not `206` with the
|
||||
/// requested tail. The worker sent the header and appended the body regardless,
|
||||
/// so every retry — and every resume — concatenated a fresh copy of the whole
|
||||
/// transcode onto the bytes already on disk. The file grew past its real size
|
||||
/// and would not play. Only a `206` actually promises the tail; a `200` means we
|
||||
/// must discard what we have and take the stream from the top.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170
|
||||
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||
if existing_bytes == 0 {
|
||||
return 0;
|
||||
}
|
||||
// 206 Partial Content is the only answer that honours the Range request.
|
||||
if status == 206 {
|
||||
existing_bytes
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The partial-download sidecar for `target`.
|
||||
///
|
||||
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
|
||||
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
|
||||
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
|
||||
/// cancelled or failed download was left on disk forever, invisible to the
|
||||
/// disk-usage totals because no `downloads` row pointed at it. That is the
|
||||
/// reported "failure is not cleaned".
|
||||
///
|
||||
/// Appending also removes a collision the old form had: `movie.mp4` and
|
||||
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
|
||||
///
|
||||
/// One function so the writer and the cleaners cannot disagree again.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169
|
||||
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = target.as_os_str().to_os_string();
|
||||
s.push(".part");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Result of a successful download
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadResult {
|
||||
@@ -179,6 +263,10 @@ pub enum DownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
FileSystem(String),
|
||||
/// The download was asked to stop (paused or cancelled). Not a failure: the
|
||||
/// row's status already says what happened, and the partial file is kept so a
|
||||
/// resume can continue from it.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl DownloadError {
|
||||
@@ -188,8 +276,16 @@ impl DownloadError {
|
||||
DownloadError::Network(_) => true,
|
||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||
DownloadError::FileSystem(_) => false,
|
||||
// Retrying would restart the very download the user just paused.
|
||||
DownloadError::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this outcome means "the user stopped it", rather than a failure to
|
||||
/// record and report.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
matches!(self, DownloadError::Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
|
||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||
DownloadError::Stopped => write!(f, "Download stopped by request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||
/// duplicated them, so every retry grew the file past its real size and left
|
||||
/// it unplayable. Only `206` promises the requested tail.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-170 | UT-164
|
||||
#[test]
|
||||
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
|
||||
// Nothing on disk: start at the beginning either way.
|
||||
assert_eq!(resume_offset(0, 200), 0);
|
||||
assert_eq!(resume_offset(0, 206), 0);
|
||||
|
||||
// The server agreed to the range — append to what we have.
|
||||
assert_eq!(resume_offset(5_000, 206), 5_000);
|
||||
|
||||
// The server ignored it and is sending the whole file (a transcode).
|
||||
// Restart, or the bytes are duplicated.
|
||||
assert_eq!(
|
||||
resume_offset(5_000, 200),
|
||||
0,
|
||||
"a 200 carries the whole stream; appending it corrupts the file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression: `with_extension` replaced the extension, so the worker
|
||||
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
|
||||
/// Nothing matched, and partial files accumulated forever.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-169 | UT-163
|
||||
#[test]
|
||||
fn test_partial_path_appends_rather_than_replacing_the_extension() {
|
||||
use std::path::Path;
|
||||
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
Path::new("/media/movie.mp4.part"),
|
||||
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
|
||||
);
|
||||
|
||||
// Two sources for one title must not fight over a single partial file.
|
||||
assert_ne!(
|
||||
partial_path(Path::new("/media/movie.mp4")),
|
||||
partial_path(Path::new("/media/movie.mkv")),
|
||||
);
|
||||
|
||||
// Extension-less targets still get a sidecar rather than being clobbered.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/track")),
|
||||
Path::new("/media/track.part"),
|
||||
);
|
||||
|
||||
// A dotted name keeps every part of its own name.
|
||||
assert_eq!(
|
||||
partial_path(Path::new("/media/S01.E02.episode.mkv")),
|
||||
Path::new("/media/S01.E02.episode.mkv.part"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(
|
||||
@@ -231,5 +386,9 @@ mod tests {
|
||||
assert!(DownloadError::Http(503).is_retryable());
|
||||
assert!(!DownloadError::Http(404).is_retryable());
|
||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||
// Retrying a paused download would restart what the user just stopped.
|
||||
assert!(!DownloadError::Stopped.is_retryable());
|
||||
assert!(DownloadError::Stopped.is_stopped());
|
||||
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
|
||||
}
|
||||
}
|
||||
|
||||
+42
-7
@@ -130,6 +130,7 @@ use commands::{
|
||||
player_get_session,
|
||||
player_get_sleep_timer,
|
||||
player_get_status,
|
||||
player_get_streaming_qualities,
|
||||
player_get_video_settings,
|
||||
// Preload commands
|
||||
player_local_media_path,
|
||||
@@ -159,6 +160,7 @@ use commands::{
|
||||
player_set_cache_config,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_set_stream_quality,
|
||||
player_set_subtitle_track,
|
||||
player_set_video_settings,
|
||||
player_set_volume,
|
||||
@@ -265,6 +267,7 @@ use commands::{
|
||||
storage_save_user,
|
||||
storage_search_items,
|
||||
storage_set_active_user,
|
||||
storage_set_watched,
|
||||
storage_toggle_favorite,
|
||||
storage_update_playback_context,
|
||||
storage_update_playback_progress,
|
||||
@@ -424,6 +427,28 @@ impl MediaSessionHandler {
|
||||
|
||||
/// Drive the local player for a transport command.
|
||||
fn handle_local_command(&self, command: &str) {
|
||||
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
||||
// whole episode — and resolving it during a background-audio handoff means
|
||||
// re-opening the stream, which is async. So it runs on the runtime and,
|
||||
// critically, is handled *before* the blocking lock below: taking that
|
||||
// guard and then spawning a task that waits for the same mutex would
|
||||
// deadlock the media session. (DR-159)
|
||||
if let Some(raw) = command.strip_prefix("seek:") {
|
||||
match raw.parse::<f64>() {
|
||||
Ok(position) => {
|
||||
let player = self.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let controller = player.lock().await;
|
||||
if let Err(e) = controller.seek_absolute(position).await {
|
||||
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use blocking_lock since this is called from a non-async JNI callback
|
||||
let controller = self.player.blocking_lock();
|
||||
|
||||
@@ -433,13 +458,6 @@ impl MediaSessionHandler {
|
||||
"next" => controller.next(),
|
||||
"previous" => controller.previous(),
|
||||
"stop" => controller.stop(),
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(pos) => controller.seek(pos),
|
||||
Err(_) => {
|
||||
warn!("[MediaSession] Bad seek command: {}", command);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("[MediaSession] Unknown command: {}", command);
|
||||
Ok(())
|
||||
@@ -695,6 +713,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
player_get_streaming_qualities,
|
||||
player_set_stream_quality,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer,
|
||||
player_cancel_sleep_timer,
|
||||
@@ -789,6 +809,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
storage_mark_played,
|
||||
storage_set_watched,
|
||||
storage_get_playback_progress,
|
||||
storage_mark_synced,
|
||||
storage_toggle_favorite,
|
||||
@@ -1225,6 +1246,20 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Restore the persisted streaming bandwidth ceiling. Deferred to the
|
||||
// async runtime because the read is async, and ordered after the
|
||||
// wrapper above because it writes into it. Until it lands, streams
|
||||
// are uncapped — the pre-existing behaviour — and no playback can
|
||||
// have started this early anyway (login happens after setup).
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::commands::restore_streaming_quality(&handle).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
|
||||
@@ -1474,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||
/// Set the background-audio handoff base (seconds) on the playback service.
|
||||
///
|
||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
|
||||
/// place the relative handoff timeline is converted to the episode's own — see
|
||||
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
|
||||
/// service isn't running yet, so it's safe to call unconditionally.
|
||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
@@ -1525,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
|
||||
env.call_method(
|
||||
&service_obj,
|
||||
"setPositionOffset",
|
||||
"setHandoffBase",
|
||||
"(D)V",
|
||||
&[JValue::Double(offset_seconds)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+837
-116
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
|
||||
live.filter(|d| *d > 0.0).or(self.duration)
|
||||
|
||||
@@ -61,14 +61,98 @@ const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
||||
/// it rather than give up.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// play the **video** can decode.
|
||||
///
|
||||
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||
/// but video does not play through ExoPlayer. Both Android and Linux render it
|
||||
/// in a webview `<video>` element, and Chromium/WebKit decode a much smaller set
|
||||
/// than the platform does. Advertising the raw list makes Jellyfin direct-play a
|
||||
/// track the webview cannot decode, and the user gets picture with no sound.
|
||||
/// but the webview `<video>` element may be what renders the video, and
|
||||
/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
@@ -120,28 +204,77 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
/// delegate this decision; it knows what its own renderer can decode and must
|
||||
/// apply that itself.
|
||||
///
|
||||
/// The track that matters is the one the server will actually serve: the
|
||||
/// default, or the first when none is marked. An unknown codec is left alone —
|
||||
/// forcing a transcode on a guess would burn server CPU for files that play.
|
||||
/// The track that matters is the one the server will actually serve (see
|
||||
/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
|
||||
/// on a guess would burn server CPU for files that play.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||
let served = streams
|
||||
match served_audio_codec(streams) {
|
||||
Some(codec) => !webview_can_decode_audio(codec),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec of the audio track the server will actually serve, given the
|
||||
/// source's audio streams as `(codec, is_default)` in source order: the default,
|
||||
/// or the first when none is marked.
|
||||
///
|
||||
/// `None` means "nothing to judge" — no audio streams, or the server named no
|
||||
/// codec for the one it would serve. Both callers of this rule treat that as
|
||||
/// leave-well-alone, never as a licence to assume compatibility.
|
||||
///
|
||||
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
|
||||
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
|
||||
streams
|
||||
.iter()
|
||||
.find(|(_, is_default)| *is_default)
|
||||
.or_else(|| streams.first());
|
||||
|
||||
match served {
|
||||
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
||||
// No audio at all, or a codec the server did not name: leave it alone.
|
||||
Some((None, _)) | None => false,
|
||||
}
|
||||
.or_else(|| streams.first())
|
||||
.and_then(|(codec, _)| *codec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn an_undecodable_default_track_forces_a_transcode() {
|
||||
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||
@@ -193,6 +326,25 @@ mod tests {
|
||||
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||
}
|
||||
|
||||
/// The download path needs the codec itself, not just the verdict, so it can
|
||||
/// tell the server what to re-encode. It picks the same track the streaming
|
||||
/// verdict is formed from — one rule, one place.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(
|
||||
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
|
||||
Some("eac3")
|
||||
);
|
||||
assert_eq!(served_audio_codec(&[]), None);
|
||||
assert_eq!(served_audio_codec(&[(None, true)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||
|
||||
@@ -79,22 +79,20 @@ impl HybridRepository {
|
||||
self.online.get_jray_actors(item_id, t).await
|
||||
}
|
||||
|
||||
/// Get video stream URL with optional seeking support.
|
||||
/// This method is online-only since offline playback uses local file paths.
|
||||
/// Get video stream URL. This method is online-only since offline playback
|
||||
/// 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(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
self.online
|
||||
.get_video_stream_url(
|
||||
item_id,
|
||||
media_source_id,
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.get_video_stream_url(item_id, media_source_id, audio_stream_index)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -119,6 +117,40 @@ impl HybridRepository {
|
||||
.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).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
@@ -872,10 +904,11 @@ impl MediaRepository for HybridRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Always use online URL for downloads
|
||||
self.online
|
||||
.get_video_download_url(item_id, quality, media_source_id)
|
||||
.get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
@@ -1299,6 +1332,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1573,6 +1607,7 @@ mod tests {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -197,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
|
||||
format: &str,
|
||||
) -> String;
|
||||
|
||||
/// Get video download URL (synchronous - just constructs URL)
|
||||
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
||||
/// Build the URL a video download is fetched from. Synchronous — it only
|
||||
/// constructs a URL, so it stays testable without a server. Reach it through
|
||||
/// [`resolve_video_download_url`] rather than calling it directly.
|
||||
///
|
||||
/// `source_audio_codec` is the codec of the audio track the server would
|
||||
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
|
||||
/// `original` quality it decides whether the file can be copied byte-for-byte
|
||||
/// or has to have its audio re-encoded on the way down — a downloaded file is
|
||||
/// played back with no server in reach, so it has to be decodable *here*.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
#[allow(dead_code)]
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
@@ -323,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
|
||||
new_index: u32,
|
||||
) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
/// The audio codec the server would serve for `item_id` — the default track, or
|
||||
/// the first when none is marked, matching the track Jellyfin picks.
|
||||
///
|
||||
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
|
||||
/// caller must read that as "unknown", never as "fine": it is the input to a
|
||||
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
|
||||
/// exactly as it was.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
|
||||
let item = repo.get_item(item_id).await.ok()?;
|
||||
let audio: Vec<(Option<&str>, bool)> = item
|
||||
.media_streams
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|s| s.stream_type == "Audio")
|
||||
.map(|s| (s.codec.as_deref(), s.is_default))
|
||||
.collect();
|
||||
|
||||
device_profile::served_audio_codec(&audio).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Resolve the download URL for a video, applying the audio-codec policy that
|
||||
/// keeps the saved file playable offline (DR-171).
|
||||
///
|
||||
/// Every video download goes through here rather than calling the builder
|
||||
/// directly: the builder is pure and cannot look the codec up, and a caller that
|
||||
/// forgets to is exactly how the silent downloads shipped.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171
|
||||
pub async fn resolve_video_download_url(
|
||||
repo: &dyn MediaRepository,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
let codec = served_audio_codec(repo, item_id).await;
|
||||
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
|
||||
}
|
||||
|
||||
@@ -828,6 +828,32 @@ impl OfflineRepository {
|
||||
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
||||
/// is authoritative regardless of the process-wide catalog-browse flag.
|
||||
///
|
||||
/// Whether cached item `i` belongs to library `l`, decided by media kind.
|
||||
///
|
||||
/// The cache leaves `library_id`/`parent_id` NULL on every item
|
||||
/// ([[offline-libraries-never-cached]]), so there is no link to follow: a
|
||||
/// library's `collection_type` and an item's `item_type` are the only things
|
||||
/// that can associate them. This is Jellyfin taxonomy and therefore lives in
|
||||
/// Rust, never in the frontend.
|
||||
///
|
||||
/// It is a named constant because it is needed in two places that must agree
|
||||
/// — which library *appears* in the Downloaded list, and which items appear
|
||||
/// *inside* it. They disagreed: the listing query used this mapping while the
|
||||
/// browse query only checked that the requested library existed, so opening
|
||||
/// any library showed every downloaded top-level item on the server.
|
||||
///
|
||||
/// A library of some other (or unknown) type keeps everything, since there is
|
||||
/// no mapping to narrow it by and hiding its contents would be worse.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-167
|
||||
const LIBRARY_HOLDS_ITEM: &'static str = "(
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR l.collection_type IS NULL
|
||||
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||
)";
|
||||
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
WITH downloaded_items AS (
|
||||
@@ -908,6 +934,7 @@ impl OfflineRepository {
|
||||
EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
AND {membership}
|
||||
)
|
||||
-- Top-level only: hide leaves whose container is downloaded.
|
||||
AND NOT EXISTS (
|
||||
@@ -922,6 +949,7 @@ impl OfflineRepository {
|
||||
ORDER BY i.sort_name ASC, i.name ASC
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
@@ -966,7 +994,7 @@ impl OfflineRepository {
|
||||
// We match a library by collection_type ↔ item_type instead: any
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
&format!(
|
||||
format!(
|
||||
"{cte}
|
||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||
FROM libraries l
|
||||
@@ -975,29 +1003,24 @@ impl OfflineRepository {
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND (
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
|
||||
)
|
||||
AND {membership}
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
membership = Self::LIBRARY_HOLDS_ITEM,
|
||||
),
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
|
||||
self.db_service
|
||||
.query_many(query, |row| {
|
||||
Ok(Library {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
collection_type: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
Ok(Library::new(
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: row.get(3)?,
|
||||
})
|
||||
row.get(3)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })
|
||||
@@ -1190,14 +1213,13 @@ impl MediaRepository for OfflineRepository {
|
||||
|
||||
self.db_service
|
||||
.query_many(query, |row| {
|
||||
Ok(Library {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
collection_type: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
Ok(Library::new(
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: row.get(3)?,
|
||||
})
|
||||
row.get(3)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })
|
||||
@@ -1959,6 +1981,7 @@ impl MediaRepository for OfflineRepository {
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// Cannot download while offline
|
||||
String::new()
|
||||
@@ -3441,18 +3464,13 @@ mod tests {
|
||||
|
||||
// Simulate the online path persisting the server's library list.
|
||||
let server_libs = vec![
|
||||
Library {
|
||||
id: "music".into(),
|
||||
name: "Music".into(),
|
||||
collection_type: "music".into(),
|
||||
image_tag: None,
|
||||
},
|
||||
Library {
|
||||
id: "movies".into(),
|
||||
name: "Movies".into(),
|
||||
collection_type: "movies".into(),
|
||||
image_tag: Some("tag".into()),
|
||||
},
|
||||
Library::new("music".into(), "Music".into(), "music".into(), None),
|
||||
Library::new(
|
||||
"movies".into(),
|
||||
"Movies".into(),
|
||||
"movies".into(),
|
||||
Some("tag".into()),
|
||||
),
|
||||
];
|
||||
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
|
||||
assert_eq!(saved, 2);
|
||||
@@ -3720,6 +3738,82 @@ mod tests {
|
||||
assert_eq!(track_ids, vec!["track-1", "track-2"]);
|
||||
}
|
||||
|
||||
/// Regression: each downloaded library shows **only its own media**.
|
||||
///
|
||||
/// Cached items carry no link back to their library (`library_id`/`parent_id`
|
||||
/// are NULL — [[offline-libraries-never-cached]]), and the library branch of
|
||||
/// the query only asserted that the requested library *exists*, never that
|
||||
/// the item belongs to it. So opening any downloaded library listed every
|
||||
/// downloaded top-level item on the server: films in the music library,
|
||||
/// albums under TV. The library's `collection_type` decides which item types
|
||||
/// belong to it, the same mapping `get_downloaded_libraries` already uses.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-167 | UT-162
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_library_does_not_mix_media_types() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
seed_library(&db, "tv-lib", "tvshows").await;
|
||||
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||
insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
|
||||
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
seed_completed_download(&db, "movie-1", 2000).await;
|
||||
seed_completed_download(&db, "episode-1", 3000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
let music: Vec<String> = repo
|
||||
.get_downloaded_items("music-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
music,
|
||||
vec!["album-1"],
|
||||
"the music library must not list films or series; got {:?}",
|
||||
music
|
||||
);
|
||||
|
||||
let movies: Vec<String> = repo
|
||||
.get_downloaded_items("movie-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
movies,
|
||||
vec!["movie-1"],
|
||||
"the movie library must not list albums or series; got {:?}",
|
||||
movies
|
||||
);
|
||||
|
||||
let tv: Vec<String> = repo
|
||||
.get_downloaded_items("tv-lib", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tv,
|
||||
vec!["series-1"],
|
||||
"the TV library must not list albums or films; got {:?}",
|
||||
tv
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a downloaded TV library lists the Series, not its Seasons or
|
||||
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
||||
/// season and episode are still reachable by drilling into the series.
|
||||
|
||||
@@ -3,11 +3,93 @@
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::{types::*, MediaRepository};
|
||||
use crate::connectivity::ConnectivityReporter;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::settings::StreamingQuality;
|
||||
use crate::utils::lock::RwLockSafe;
|
||||
|
||||
/// The bandwidth ceiling every video stream this process opens is built against.
|
||||
///
|
||||
/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
|
||||
/// preference about *this device's connection*, not about a server session: it
|
||||
/// must survive a repository being rebuilt on re-login, and every URL builder and
|
||||
/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
|
||||
/// negotiation would authorise a direct play the URL builder then never gets to
|
||||
/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
|
||||
///
|
||||
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
|
||||
/// restored from the database at startup.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
|
||||
|
||||
/// Apply a bandwidth ceiling to every subsequently-opened video stream.
|
||||
///
|
||||
/// Streams already playing keep the bitrate they were opened at — a cap is a
|
||||
/// property of the URL the server is transcoding for, so changing it mid-stream
|
||||
/// requires re-opening at the new quality (`player_set_stream_quality`).
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub fn set_streaming_quality(quality: StreamingQuality) {
|
||||
*STREAMING_QUALITY.write_safe() = quality;
|
||||
}
|
||||
|
||||
/// The ceiling currently applied to new video streams.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
pub fn streaming_quality() -> StreamingQuality {
|
||||
*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.
|
||||
///
|
||||
@@ -370,38 +452,108 @@ impl OnlineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a video stream URL for playback at an arbitrary position (resume,
|
||||
/// transcoded seeking, audio-track switching).
|
||||
/// Ask the server to tear down a transcode this device started.
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
|
||||
/// forces the server to transcode the whole file before playback can begin —
|
||||
/// which manifests as playback never starting. `StartTimeTicks` makes the
|
||||
/// server begin the transcode at the requested position.
|
||||
/// which manifests as playback never starting.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-140 | UT-130
|
||||
/// **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:
|
||||
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
|
||||
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
|
||||
/// allowance, which is a transcode ceiling rather than a user-facing limit.
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> 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();
|
||||
// `Original` is uncapped as a *user* setting, but a transcode still needs
|
||||
// a ceiling to encode against — keep the values this endpoint has always
|
||||
// used so nothing changes for the default.
|
||||
let max_bitrate = quality.max_bitrate().unwrap_or(20_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
|
||||
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
||||
let mut params = vec![
|
||||
("api_key", self.access_token.clone()),
|
||||
("DeviceId", "jellytau-tauri".to_string()),
|
||||
("DeviceId", DEVICE_ID.to_string()),
|
||||
("PlaySessionId", play_session_id),
|
||||
("VideoCodec", "h264".to_string()),
|
||||
("AudioCodec", "aac".to_string()),
|
||||
("MaxStreamingBitrate", "20000000".to_string()),
|
||||
("VideoBitrate", "18000000".to_string()),
|
||||
("AudioBitrate", "384000".to_string()),
|
||||
("MaxStreamingBitrate", max_bitrate.to_string()),
|
||||
("VideoBitrate", video_bitrate.to_string()),
|
||||
("AudioBitrate", quality.audio_bitrate().to_string()),
|
||||
(
|
||||
"TranscodingMaxAudioChannels",
|
||||
super::device_profile::max_audio_channels().to_string(),
|
||||
@@ -409,8 +561,26 @@ impl OnlineRepository {
|
||||
("SegmentContainer", "ts".to_string()),
|
||||
("TranscodingContainer", "ts".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
|
||||
// uncapped steps so the source resolution is preserved.
|
||||
if let Some(height) = quality.max_height() {
|
||||
params.push(("MaxHeight", height.to_string()));
|
||||
}
|
||||
|
||||
// Only pin an audio track when the user actually picked one. Jellyfin's
|
||||
// `MediaStream.Index` is global across *all* streams in a media source, so
|
||||
// index 0 is the video stream on virtually every file — defaulting to 0
|
||||
@@ -425,10 +595,6 @@ impl OnlineRepository {
|
||||
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)
|
||||
let query = params
|
||||
.iter()
|
||||
@@ -475,13 +641,20 @@ impl OnlineRepository {
|
||||
let mut params = vec![
|
||||
("UserId", self.user_id.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.
|
||||
("Container", "mp3".to_string()),
|
||||
("AudioCodec", "mp3".to_string()),
|
||||
("TranscodingContainer", "mp3".to_string()),
|
||||
("TranscodingProtocol", "http".to_string()),
|
||||
("MaxStreamingBitrate", "384000".to_string()),
|
||||
// Audio-only is already far under any video cap, but a user on the
|
||||
// bottom rungs of the ladder asked for *less traffic*, so take the
|
||||
// lower of the two rather than always 384 kbps.
|
||||
// TRACES: UR-074 | DR-162
|
||||
(
|
||||
"MaxStreamingBitrate",
|
||||
streaming_quality().audio_bitrate().min(384_000).to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
// Carry the track over only if one was actually selected — index 0 is the
|
||||
@@ -829,15 +1002,28 @@ impl JellyfinItem {
|
||||
media_streams: self.media_streams.map(|streams| {
|
||||
streams
|
||||
.into_iter()
|
||||
.map(|s| crate::repository::types::MediaStream {
|
||||
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
|
||||
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,
|
||||
.map(|s| {
|
||||
let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
|
||||
// Only a subtitle can be a sidecar; asked of anything
|
||||
// else the question has no answer. TRACES: UR-020 |
|
||||
// DR-176 | UT-168
|
||||
let supports_external_delivery =
|
||||
(kind == crate::domain::StreamKind::Subtitle).then(|| {
|
||||
super::device_profile::subtitle_supports_external_delivery(
|
||||
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()
|
||||
}),
|
||||
@@ -886,11 +1072,13 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|lib| Library {
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: lib.image_tags.and_then(|tags| tags.primary()),
|
||||
.map(|lib| {
|
||||
Library::new(
|
||||
lib.id,
|
||||
lib.name,
|
||||
lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
|
||||
lib.image_tags.and_then(|tags| tags.primary()),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1406,11 +1594,29 @@ impl MediaRepository for OnlineRepository {
|
||||
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
|
||||
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
|
||||
|
||||
// The user's bandwidth ceiling has to be part of the *negotiation*, not
|
||||
// just the transcode URL: `max_static_bitrate` is what makes the server
|
||||
// refuse to direct-play a source fatter than the cap, and without it a
|
||||
// 30 Mbps remux is handed over untouched and every URL parameter
|
||||
// downstream is moot. `Original` keeps the historical "no ceiling"
|
||||
// sentinel so the default path negotiates exactly as before.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
let quality = streaming_quality();
|
||||
let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
|
||||
if let Some(cap) = quality.max_bitrate() {
|
||||
info!(
|
||||
"[DeviceProfile] Streaming quality cap active: {} ({} bps)",
|
||||
quality.label(),
|
||||
cap
|
||||
);
|
||||
}
|
||||
|
||||
// Create device profile with detected hardware capabilities
|
||||
let device_profile = DeviceProfile {
|
||||
name: "JellyTau Native Player".to_string(),
|
||||
max_streaming_bitrate: 999_999_999,
|
||||
max_static_bitrate: 999_999_999,
|
||||
max_streaming_bitrate: negotiated_bitrate,
|
||||
max_static_bitrate: negotiated_bitrate,
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
direct_play_profiles: vec![
|
||||
DirectPlayProfile {
|
||||
@@ -1450,27 +1656,32 @@ impl MediaRepository for OnlineRepository {
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
},
|
||||
],
|
||||
subtitle_profiles: vec![
|
||||
SubtitleProfile {
|
||||
format: "srt".to_string(),
|
||||
method: "External".to_string(),
|
||||
},
|
||||
SubtitleProfile {
|
||||
format: "vtt".to_string(),
|
||||
method: "External".to_string(),
|
||||
},
|
||||
],
|
||||
subtitle_profiles: super::device_profile::subtitle_profiles()
|
||||
.into_iter()
|
||||
.map(|(format, method)| SubtitleProfile {
|
||||
format: format.to_string(),
|
||||
method: method.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// POST to PlaybackInfo with device profile containing detected codecs
|
||||
let request_body = PlaybackInfoRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
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,
|
||||
is_playback: true,
|
||||
auto_open_live_stream: true,
|
||||
max_streaming_bitrate: 20_000_000, // 20 Mbps
|
||||
// The user's cap, or the historical 20 Mbps allowance when uncapped.
|
||||
max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
|
||||
device_profile: Some(device_profile), // Now sending profile with detected codecs
|
||||
};
|
||||
|
||||
@@ -1492,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
|
||||
// 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
|
||||
@@ -1507,13 +1735,19 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||
let stream_url = if let Some(transcoding_url) = &source.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;
|
||||
}
|
||||
format!("{}{}", self.server_url, transcoding_url)
|
||||
} else if audio_forces_transcode {
|
||||
warn!(
|
||||
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
||||
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?
|
||||
} else {
|
||||
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
||||
@@ -1633,7 +1867,10 @@ impl MediaRepository for OnlineRepository {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
is_playback: true,
|
||||
max_streaming_bitrate: 20_000_000,
|
||||
// Live TV is video like any other, so the user's cap applies here
|
||||
// too — a channel opened at the source bitrate would walk straight
|
||||
// past a limit set for the connection. TRACES: UR-074 | DR-162
|
||||
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
|
||||
};
|
||||
|
||||
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
||||
@@ -1794,6 +2031,7 @@ impl MediaRepository for OnlineRepository {
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
source_audio_codec: Option<&str>,
|
||||
) -> String {
|
||||
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
||||
// available (returns 404 on many server configs), which silently broke
|
||||
@@ -1844,10 +2082,39 @@ impl MediaRepository for OnlineRepository {
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy.
|
||||
_ => {
|
||||
params.push("Static=true".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy —
|
||||
// unless the audio in that copy is undecodable where the file will
|
||||
// be played back. A download is watched with no server in reach, so
|
||||
// it has to satisfy the same constraint DR-149 applies to streams:
|
||||
// the webview `<video>` element renders video on both platforms and
|
||||
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
||||
// disk is what made a downloaded film play offline as picture with
|
||||
// no sound while the same film had sound when streamed.
|
||||
//
|
||||
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
||||
// h264 source's picture byte-for-byte, so "original" still means
|
||||
// original quality, and no bitrate or resolution cap is added. A
|
||||
// source the webview could not have rendered anyway (HEVC) is
|
||||
// re-encoded to h264 as a side effect, which is the only form of it
|
||||
// that would have played.
|
||||
//
|
||||
// The cost of the transcode is that the response is no longer
|
||||
// range-resumable, which is exactly why this is decided per item
|
||||
// rather than applied to every `original` download.
|
||||
//
|
||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
_ => match source_audio_codec {
|
||||
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("allowVideoStreamCopy=true".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
params.push("audioBitRate=384000".to_string());
|
||||
}
|
||||
// Decodable, or unknown: an unknown codec must not provoke a
|
||||
// transcode — that would burn server CPU on a guess for files
|
||||
// that play perfectly well.
|
||||
_ => params.push("Static=true".to_string()),
|
||||
},
|
||||
}
|
||||
|
||||
// Add media source ID if provided
|
||||
@@ -2233,6 +2500,7 @@ impl MediaRepository for OnlineRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_test_repository() -> OnlineRepository {
|
||||
@@ -2379,15 +2647,120 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialises every test whose expectations depend on the process-wide
|
||||
/// streaming ceiling, and restores the uncapped default afterwards — without
|
||||
/// it, a capped test running concurrently changes what an uncapped one sees.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
impl QualityFixture {
|
||||
fn set(quality: StreamingQuality) -> Self {
|
||||
let guard = QUALITY_LOCK.lock_safe();
|
||||
set_streaming_quality(quality);
|
||||
Self(guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QualityFixture {
|
||||
fn drop(&mut self) {
|
||||
set_streaming_quality(StreamingQuality::Original);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cap has to reach the transcode URL as all four of its parts: the total
|
||||
/// ceiling, the split between video and audio, and the resolution the budget
|
||||
/// can carry. Capping only `MaxStreamingBitrate` would leave the server
|
||||
/// encoding 1080p into 2 Mbps.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_get_video_stream_url_returns_hls_with_position() {
|
||||
// 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.
|
||||
async fn test_video_stream_url_applies_bitrate_cap() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
|
||||
.get_video_stream_url("vid-1", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
|
||||
// 2 Mbps total less the 192 kbps audio share — the two must not sum to
|
||||
// more than the cap the user asked for.
|
||||
assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=192000"), "url: {url}");
|
||||
assert!(url.contains("MaxHeight=720"), "url: {url}");
|
||||
}
|
||||
|
||||
/// The uncapped default must keep the exact transcode allowance this
|
||||
/// endpoint has always used, and must not start constraining resolution.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
|
||||
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("MaxStreamingBitrate=20000000"), "url: {url}");
|
||||
assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=384000"), "url: {url}");
|
||||
assert!(
|
||||
!url.contains("MaxHeight"),
|
||||
"uncapped must not scale the picture down: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The background-audio handoff is already cheap, but someone who capped the
|
||||
/// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
|
||||
///
|
||||
/// TRACES: UR-040, UR-074 | DR-162 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
|
||||
{
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
|
||||
}
|
||||
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
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]
|
||||
async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
|
||||
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();
|
||||
|
||||
@@ -2398,17 +2771,57 @@ mod tests {
|
||||
assert!(url.contains("VideoCodec=h264"));
|
||||
assert!(url.contains("MediaSourceId=source-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"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
async fn test_get_video_stream_url_omits_position_when_absent() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", None, None, None)
|
||||
.get_video_stream_url("vid-1", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -2425,6 +2838,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]
|
||||
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
|
||||
// TRACES: UR-040 | JA-032 | UT-059
|
||||
@@ -2557,7 +3104,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", None);
|
||||
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||
|
||||
// Must NOT use the /download endpoint (404 on real servers).
|
||||
assert!(
|
||||
@@ -2575,7 +3122,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_video_download_url_original_is_static_direct_copy() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", None);
|
||||
let url = repo.get_video_download_url("item123", "original", None, None);
|
||||
|
||||
// "original" must request a direct static copy (byte-range resumable),
|
||||
// with no transcode params.
|
||||
@@ -2595,7 +3142,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
||||
let url = repo.get_video_download_url("item123", quality, None);
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert!(
|
||||
url.contains("/Videos/item123/stream.mp4"),
|
||||
"{quality} must use stream.mp4: {url}"
|
||||
@@ -2628,7 +3175,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let url = repo.get_video_download_url("item123", quality, None);
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
|
||||
assert!(
|
||||
url.contains("videoBitRate="),
|
||||
@@ -2662,7 +3209,7 @@ mod tests {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let url = repo.get_video_download_url("item123", quality, None);
|
||||
let url = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert!(
|
||||
url.contains("allowVideoStreamCopy=false"),
|
||||
"{quality} must forbid video stream copy: {url}"
|
||||
@@ -2670,17 +3217,99 @@ mod tests {
|
||||
}
|
||||
|
||||
// "original" is a deliberate direct copy — it must NOT disable copying.
|
||||
let original = repo.get_video_download_url("item123", "original", None);
|
||||
let original = repo.get_video_download_url("item123", "original", None, None);
|
||||
assert!(
|
||||
!original.contains("allowVideoStreamCopy=false"),
|
||||
"original must remain a direct copy: {original}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A downloaded file is played with no server in reach, so `original`
|
||||
/// quality cannot mean "copy whatever the source holds" when the source
|
||||
/// holds audio this device cannot decode.
|
||||
///
|
||||
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||
/// track included, and video plays through the webview `<video>` element on
|
||||
/// both platforms — which decodes none of them. Streaming already knows this
|
||||
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
||||
/// download path did not, so a downloaded film played offline as picture with
|
||||
/// no sound while the very same film had sound when streamed.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
|
||||
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||
assert!(
|
||||
!url.contains("Static=true"),
|
||||
"{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
|
||||
);
|
||||
assert!(
|
||||
url.contains("audioCodec=aac"),
|
||||
"{codec} must be re-encoded to aac on the way down: {url}"
|
||||
);
|
||||
// "Original" still has to mean original picture: the video stream is
|
||||
// copied when it can be, so no bitrate or resolution cap appears.
|
||||
assert!(
|
||||
url.contains("allowVideoStreamCopy=true"),
|
||||
"the video stream must still be copied where possible: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("videoBitRate") && !url.contains("maxHeight"),
|
||||
"original must not degrade the picture to fix the audio: {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The converse, and the reason the policy is per-item rather than blanket:
|
||||
/// audio that plays here keeps the byte-exact, range-resumable copy that the
|
||||
/// download worker's resume depends on.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
|
||||
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
||||
assert!(
|
||||
url.contains("Static=true"),
|
||||
"{codec} plays here — the download must stay a direct copy: {url}"
|
||||
);
|
||||
assert!(
|
||||
!url.contains("audioCodec="),
|
||||
"{codec} needs no transcode: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown codec: the policy only ever *adds* a transcode, so an item we
|
||||
// could not look up behaves exactly as it did before.
|
||||
let unknown = repo.get_video_download_url("item123", "original", None, None);
|
||||
assert!(unknown.contains("Static=true"), "url: {unknown}");
|
||||
}
|
||||
|
||||
/// The explicit quality presets already transcode audio to AAC, so the
|
||||
/// policy has nothing to add — and must not start overriding a chosen cap.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-171 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_presets_ignore_the_audio_policy() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
for quality in ["high", "medium", "low"] {
|
||||
let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
|
||||
let without = repo.get_video_download_url("item123", quality, None, None);
|
||||
assert_eq!(with, without, "{quality} must not vary with source audio");
|
||||
assert!(with.contains("audioCodec=aac"), "url: {with}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_passes_media_source_id() {
|
||||
let repo = create_test_repository();
|
||||
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
|
||||
let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
|
||||
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,38 @@ pub struct Library {
|
||||
pub collection_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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.)
|
||||
@@ -221,6 +253,18 @@ pub struct MediaStream {
|
||||
pub index: i32,
|
||||
pub is_default: 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
|
||||
@@ -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
|
||||
@@ -653,6 +718,54 @@ mod search_scope_tests {
|
||||
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
|
||||
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)]
|
||||
|
||||
@@ -148,6 +148,138 @@ impl AudioSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// A ceiling on how much bandwidth a *video* stream may consume.
|
||||
///
|
||||
/// A quality step is a bundle of concrete transcode parameters — total stream
|
||||
/// ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||
/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||
/// they live here and the frontend only ever names a variant; the labels the
|
||||
/// picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||
///
|
||||
/// The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||
/// exists to fit a connection, and the resolution cap is chosen *from* the
|
||||
/// bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||
/// afford. See docs/specs/streaming-bitrate-cap.md.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StreamingQuality {
|
||||
/// No client-imposed cap — the server may direct-play the source as-is.
|
||||
#[default]
|
||||
Original,
|
||||
Mbps20,
|
||||
Mbps10,
|
||||
Mbps8,
|
||||
Mbps4,
|
||||
Mbps2,
|
||||
Mbps1,
|
||||
Kbps720,
|
||||
}
|
||||
|
||||
impl StreamingQuality {
|
||||
/// The ladder, highest first, for enumerating across the IPC boundary.
|
||||
pub const ALL: [StreamingQuality; 8] = [
|
||||
StreamingQuality::Original,
|
||||
StreamingQuality::Mbps20,
|
||||
StreamingQuality::Mbps10,
|
||||
StreamingQuality::Mbps8,
|
||||
StreamingQuality::Mbps4,
|
||||
StreamingQuality::Mbps2,
|
||||
StreamingQuality::Mbps1,
|
||||
StreamingQuality::Kbps720,
|
||||
];
|
||||
|
||||
/// Total bits per second the stream may use (video + audio), or `None` for
|
||||
/// the uncapped `Original`.
|
||||
///
|
||||
/// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
|
||||
/// and into the device profile. Sending it there — not just on the transcode
|
||||
/// URL — is what makes the cap real: a stream the server decides to *direct
|
||||
/// play* is served at the source file's own bitrate, and no URL parameter
|
||||
/// afterwards can reduce it.
|
||||
pub fn max_bitrate(&self) -> Option<u64> {
|
||||
match self {
|
||||
StreamingQuality::Original => None,
|
||||
StreamingQuality::Mbps20 => Some(20_000_000),
|
||||
StreamingQuality::Mbps10 => Some(10_000_000),
|
||||
StreamingQuality::Mbps8 => Some(8_000_000),
|
||||
StreamingQuality::Mbps4 => Some(4_000_000),
|
||||
StreamingQuality::Mbps2 => Some(2_000_000),
|
||||
StreamingQuality::Mbps1 => Some(1_000_000),
|
||||
StreamingQuality::Kbps720 => Some(720_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per second allotted to the audio track.
|
||||
///
|
||||
/// The value shrinks with the ladder because at the bottom rungs a fixed
|
||||
/// 384 kbps would be a third of the entire budget.
|
||||
pub fn audio_bitrate(&self) -> u64 {
|
||||
match self {
|
||||
StreamingQuality::Original
|
||||
| StreamingQuality::Mbps20
|
||||
| StreamingQuality::Mbps10
|
||||
| StreamingQuality::Mbps8 => 384_000,
|
||||
StreamingQuality::Mbps4 => 256_000,
|
||||
StreamingQuality::Mbps2 => 192_000,
|
||||
StreamingQuality::Mbps1 => 128_000,
|
||||
StreamingQuality::Kbps720 => 96_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per second allotted to the video track: the total minus the audio
|
||||
/// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
|
||||
/// rather than overshooting it by the size of the audio track.
|
||||
pub fn video_bitrate(&self) -> Option<u64> {
|
||||
self.max_bitrate()
|
||||
.map(|total| total.saturating_sub(self.audio_bitrate()))
|
||||
}
|
||||
|
||||
/// Resolution ceiling that suits the bitrate, or `None` to leave the source
|
||||
/// resolution alone. Scaling down is what keeps a small budget looking like
|
||||
/// clean video instead of blocky 1080p.
|
||||
pub fn max_height(&self) -> Option<u32> {
|
||||
match self {
|
||||
// 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
|
||||
StreamingQuality::Original | StreamingQuality::Mbps20 => None,
|
||||
StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
|
||||
StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
|
||||
StreamingQuality::Mbps1 => Some(480),
|
||||
StreamingQuality::Kbps720 => Some(360),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for the picker. Lives in Rust with the numbers it describes,
|
||||
/// so the two cannot drift apart.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
StreamingQuality::Original => "Original",
|
||||
StreamingQuality::Mbps20 => "20 Mbps",
|
||||
StreamingQuality::Mbps10 => "10 Mbps",
|
||||
StreamingQuality::Mbps8 => "8 Mbps",
|
||||
StreamingQuality::Mbps4 => "4 Mbps",
|
||||
StreamingQuality::Mbps2 => "2 Mbps",
|
||||
StreamingQuality::Mbps1 => "1 Mbps",
|
||||
StreamingQuality::Kbps720 => "720 kbps",
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary line for the picker: what the cap means in practice.
|
||||
pub fn detail(&self) -> &'static str {
|
||||
match self {
|
||||
StreamingQuality::Original => "No limit — highest quality",
|
||||
StreamingQuality::Mbps20 => "Up to 4K",
|
||||
StreamingQuality::Mbps10 => "1080p, high quality",
|
||||
StreamingQuality::Mbps8 => "1080p",
|
||||
StreamingQuality::Mbps4 => "720p",
|
||||
StreamingQuality::Mbps2 => "720p, reduced",
|
||||
StreamingQuality::Mbps1 => "480p",
|
||||
StreamingQuality::Kbps720 => "360p — slowest connections",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Video playback settings
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -159,6 +291,14 @@ pub struct VideoSettings {
|
||||
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||
#[serde(default)]
|
||||
pub auto_play_max_episodes: u32,
|
||||
/// Bandwidth ceiling applied to every video stream.
|
||||
///
|
||||
/// `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
/// loads as the previous behaviour (uncapped).
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162
|
||||
#[serde(default)]
|
||||
pub streaming_quality: StreamingQuality,
|
||||
}
|
||||
|
||||
impl Default for VideoSettings {
|
||||
@@ -167,6 +307,7 @@ impl Default for VideoSettings {
|
||||
auto_play_next_episode: true,
|
||||
auto_play_countdown_seconds: 10,
|
||||
auto_play_max_episodes: 0,
|
||||
streaming_quality: StreamingQuality::Original,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,12 +568,14 @@ mod tests {
|
||||
auto_play_next_episode: false,
|
||||
auto_play_countdown_seconds: 15,
|
||||
auto_play_max_episodes: 5,
|
||||
streaming_quality: StreamingQuality::Mbps4,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
||||
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
||||
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
|
||||
assert!(json.contains("\"streamingQuality\":\"mbps4\""));
|
||||
|
||||
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.auto_play_next_episode);
|
||||
@@ -448,5 +591,90 @@ mod tests {
|
||||
assert!(parsed.auto_play_next_episode);
|
||||
assert_eq!(parsed.auto_play_countdown_seconds, 10);
|
||||
assert_eq!(parsed.auto_play_max_episodes, 0);
|
||||
// Settings persisted before the cap existed must load as uncapped —
|
||||
// inventing a limit for an upgrading user would silently degrade their
|
||||
// picture with no setting having been changed.
|
||||
assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
|
||||
}
|
||||
|
||||
/// The whole point of a step is the number of bits it promises not to
|
||||
/// exceed, so video + audio must fit inside the total — a video bitrate set
|
||||
/// to the full cap would overshoot it by the size of the audio track.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_budget_is_internally_consistent() {
|
||||
for quality in StreamingQuality::ALL {
|
||||
let Some(total) = quality.max_bitrate() else {
|
||||
assert_eq!(
|
||||
quality,
|
||||
StreamingQuality::Original,
|
||||
"only Original may be uncapped"
|
||||
);
|
||||
assert!(quality.video_bitrate().is_none());
|
||||
assert!(quality.max_height().is_none());
|
||||
continue;
|
||||
};
|
||||
|
||||
let video = quality.video_bitrate().expect("a capped step caps video");
|
||||
assert_eq!(
|
||||
video + quality.audio_bitrate(),
|
||||
total,
|
||||
"{:?}: video + audio must equal the cap",
|
||||
quality
|
||||
);
|
||||
assert!(
|
||||
video > 0,
|
||||
"{:?}: audio must not consume the budget",
|
||||
quality
|
||||
);
|
||||
assert!(!quality.label().is_empty());
|
||||
assert!(!quality.detail().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder is presented to the user as descending, and the resolution cap
|
||||
/// must fall with it — a lower bitrate paired with a higher resolution would
|
||||
/// spend the smaller budget on more pixels, which is backwards.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_ladder_descends() {
|
||||
let steps = StreamingQuality::ALL;
|
||||
for pair in steps.windows(2) {
|
||||
let (higher, lower) = (pair[0], pair[1]);
|
||||
let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
|
||||
let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
|
||||
assert!(
|
||||
higher_bitrate > lower_bitrate,
|
||||
"{:?} must sit above {:?}",
|
||||
higher,
|
||||
lower
|
||||
);
|
||||
assert!(
|
||||
higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
|
||||
"{:?} must not cap resolution below {:?}",
|
||||
higher,
|
||||
lower
|
||||
);
|
||||
assert!(higher.audio_bitrate() >= lower.audio_bitrate());
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted form is the serde token, and it must survive a round trip —
|
||||
/// a rename here silently resets everyone's saved cap to uncapped.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-162 | UT-157
|
||||
#[test]
|
||||
fn test_streaming_quality_round_trips_through_json() {
|
||||
for quality in StreamingQuality::ALL {
|
||||
let json = serde_json::to_string(&quality).expect("serialises");
|
||||
let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
|
||||
assert_eq!(parsed, quality);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
|
||||
"\"mbps10\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.4.8",
|
||||
"version": "0.5.5",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+182
-11
@@ -197,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
|
||||
async playerGetVideoSettings() : Promise<VideoSettings> {
|
||||
return await TAURI_INVOKE("player_get_video_settings");
|
||||
},
|
||||
/**
|
||||
* The bandwidth ceilings the quality picker may offer, each with the label and
|
||||
* one-line detail to show for it, highest first.
|
||||
*
|
||||
* The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
|
||||
* frontend reads them here rather than encoding them — the same arrangement as
|
||||
* [`player_get_eq_presets`].
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
|
||||
return await TAURI_INVOKE("player_get_streaming_qualities");
|
||||
},
|
||||
/**
|
||||
* Change the bandwidth ceiling of the video that is playing *right now*.
|
||||
*
|
||||
* A cap is a property of the stream the server is producing, so unlike a volume
|
||||
* change it cannot be applied to a stream already in flight — the stream has to
|
||||
* be re-opened at the new quality and resumed at the current position. That is
|
||||
* the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
* native backend is reloaded here.
|
||||
*
|
||||
* The change applies to this playback *and* to everything started afterwards
|
||||
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
|
||||
* the in-player picker is a "this film, this connection" control, and the
|
||||
* durable default belongs to Settings. `player_set_video_settings` is the one
|
||||
* that writes to the database.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Set sleep timer mode
|
||||
*/
|
||||
@@ -746,6 +780,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
|
||||
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
|
||||
},
|
||||
/**
|
||||
* Set the watched flag locally for an item **and everything inside it**.
|
||||
*
|
||||
* This backs the watched toggle, and is deliberately separate from
|
||||
* [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
* increments `play_count` — because the toggle has two directions and applies
|
||||
* to containers.
|
||||
*
|
||||
* The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
* online the server fixes up the children on the next read; with no server to
|
||||
* ask, marking a season watched would otherwise tick the season and leave every
|
||||
* episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
* columns the rest of the offline layer uses, so an id that is not cached
|
||||
* selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
*
|
||||
* Un-marking clears the resume position too, matching the server, so an item
|
||||
* un-marked offline does not come back offering to resume from a position it is
|
||||
* no longer meant to have.
|
||||
*
|
||||
* `pending_sync = 1` hands the rows to the sync drain.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
|
||||
},
|
||||
/**
|
||||
* Get playback progress for an item
|
||||
*/
|
||||
@@ -779,10 +840,23 @@ async downloadItemAndStart(request: DownloadItemAndStartRequest) : Promise<numbe
|
||||
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[]> {
|
||||
return await TAURI_INVOKE("download_album", { albumId, userId, basePath });
|
||||
async downloadAlbum(handle: string, albumId: string, userId: string, basePath: string) : Promise<number[]> {
|
||||
return await TAURI_INVOKE("download_album", { handle, albumId, userId, basePath });
|
||||
},
|
||||
/**
|
||||
* Queue a video item (movie or episode) for download with quality preset
|
||||
@@ -809,13 +883,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
|
||||
return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
|
||||
},
|
||||
/**
|
||||
* Pause a download
|
||||
* Pause a download.
|
||||
*
|
||||
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
|
||||
* streaming task knew nothing about the row and kept running, then overwrote it
|
||||
* with `completed`/`failed` when it finished. The row flicked to "paused" and
|
||||
* undid itself — the reported "pause does not work". Signalling the worker is
|
||||
* what actually stops the bytes; it leaves the `.part` file in place so
|
||||
* [`resume_download`] can continue from it.
|
||||
*
|
||||
* A queued (not yet started) download has no worker to signal, and the status
|
||||
* write alone is enough — the pump skips anything that is not `pending`.
|
||||
*
|
||||
* TRACES: UR-055 | DR-168
|
||||
*/
|
||||
async pauseDownload(downloadId: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("pause_download", { downloadId });
|
||||
},
|
||||
/**
|
||||
* Resume a paused download
|
||||
* Resume a paused download.
|
||||
*
|
||||
* Flipping the row back to `pending` is likewise not enough on its own: the
|
||||
* pump is not a poller, it runs when something calls it, so a resumed download
|
||||
* sat untouched until some unrelated event happened to pump the queue. That is
|
||||
* the other half of "resume does not work".
|
||||
*
|
||||
* TRACES: UR-055 | DR-168
|
||||
*/
|
||||
async resumeDownload(downloadId: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("resume_download", { downloadId });
|
||||
@@ -1421,10 +1514,16 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
|
||||
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> {
|
||||
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
|
||||
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, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Get audio stream URL for a track
|
||||
@@ -1992,7 +2091,19 @@ export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jell
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -2130,7 +2241,20 @@ type: string;
|
||||
/**
|
||||
* 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"
|
||||
/**
|
||||
* Lightweight media item for merged playback state
|
||||
@@ -2830,6 +2954,44 @@ export type StreamKind = "audio" | "video" | "subtitle" |
|
||||
* Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||
*/
|
||||
"other"
|
||||
/**
|
||||
* Response for a mid-playback streaming-quality change.
|
||||
*
|
||||
* Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
|
||||
* has to reload anything, so no strategy branch lives in the UI.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
export type StreamQualityResponse =
|
||||
/**
|
||||
* The native backend was reloaded here; nothing left for the frontend.
|
||||
*/
|
||||
{ strategy: "native"; position: number } |
|
||||
/**
|
||||
* HTML5 must reload its element with this URL.
|
||||
*/
|
||||
{ strategy: "reloadStream"; new_url: string; position: number }
|
||||
/**
|
||||
* A ceiling on how much bandwidth a *video* stream may consume.
|
||||
*
|
||||
* A quality step is a bundle of concrete transcode parameters — total stream
|
||||
* ceiling, the audio share of it, and the resolution that ceiling can carry —
|
||||
* not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
|
||||
* they live here and the frontend only ever names a variant; the labels the
|
||||
* picker shows are served over IPC by `player_get_streaming_qualities`.
|
||||
*
|
||||
* The ladder is deliberately expressed in bandwidth rather than resolution: it
|
||||
* exists to fit a connection, and the resolution cap is chosen *from* the
|
||||
* bitrate so the encoder does not spend a small budget on pixels it cannot
|
||||
* afford. See docs/specs/streaming-bitrate-cap.md.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
export type StreamingQuality =
|
||||
/**
|
||||
* No client-imposed cap — the server may direct-play the source as-is.
|
||||
*/
|
||||
"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720"
|
||||
/**
|
||||
* Represents a subtitle track
|
||||
*
|
||||
@@ -2951,7 +3113,16 @@ autoPlayCountdownSeconds: number;
|
||||
/**
|
||||
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||
*/
|
||||
autoPlayMaxEpisodes?: number }
|
||||
autoPlayMaxEpisodes?: number;
|
||||
/**
|
||||
* Bandwidth ceiling applied to every video stream.
|
||||
*
|
||||
* `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
* loads as the previous behaviour (uncapped).
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
streamingQuality?: StreamingQuality }
|
||||
/**
|
||||
* Volume normalization levels matching Spotify's presets
|
||||
*/
|
||||
|
||||
@@ -409,23 +409,26 @@ describe("RepositoryClient", () => {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: null,
|
||||
startTimeSeconds: 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 () => {
|
||||
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);
|
||||
|
||||
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0);
|
||||
const url = await client.getVideoStreamUrl("item123", "source456", 0);
|
||||
|
||||
expect(url).toBe(mockUrl);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
mediaSourceId: "source456",
|
||||
startTimeSeconds: 300,
|
||||
audioStreamIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,17 +213,25 @@ export class RepositoryClient {
|
||||
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(
|
||||
itemId: string,
|
||||
mediaSourceId?: string,
|
||||
startTimeSeconds?: number,
|
||||
audioStreamIndex?: number
|
||||
): Promise<string> {
|
||||
return commands.repositoryGetVideoStreamUrl(
|
||||
this.ensureHandle(),
|
||||
itemId,
|
||||
mediaSourceId ?? null,
|
||||
startTimeSeconds ?? null,
|
||||
audioStreamIndex ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
maxHeight?: number;
|
||||
class?: 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 {
|
||||
@@ -21,6 +28,7 @@
|
||||
maxHeight,
|
||||
class: className = "",
|
||||
alt = "",
|
||||
onNaturalSize,
|
||||
}: Props = $props();
|
||||
|
||||
let imageUrl = $state<string | null>(null);
|
||||
@@ -86,5 +94,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
{: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}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
@@ -83,28 +82,14 @@
|
||||
}
|
||||
}
|
||||
} 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 basePath = `albums/${albumId}`;
|
||||
const downloadIds = await downloads.downloadAlbum(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);
|
||||
}
|
||||
}
|
||||
await downloads.downloadAlbum(repo.getHandle(), albumId, userId, basePath);
|
||||
|
||||
// Refresh to get updated statuses
|
||||
await downloads.refresh(userId);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CastSection from "./CastSection.svelte";
|
||||
import GenreTags from "./GenreTags.svelte";
|
||||
import RelatedItemsSection from "./RelatedItemsSection.svelte";
|
||||
@@ -250,6 +251,12 @@
|
||||
episodeNumber={episode.indexNumber ?? undefined}
|
||||
size="lg"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="lg"
|
||||
/>
|
||||
<FavoriteButton
|
||||
itemId={episode.id}
|
||||
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -17,9 +18,17 @@
|
||||
*/
|
||||
current?: boolean;
|
||||
onclick?: () => void;
|
||||
/** Fired when the watched toggle changes, so the series page can reload. */
|
||||
onWatchedChanged?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||
let {
|
||||
episode,
|
||||
focused = false,
|
||||
current = false,
|
||||
onclick,
|
||||
onWatchedChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
@@ -177,6 +186,16 @@
|
||||
{duration}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Watched toggle - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="sm"
|
||||
onChanged={onWatchedChanged}
|
||||
/>
|
||||
</div>
|
||||
<!-- Download button - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<VideoDownloadButton
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,7 @@
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
|
||||
@@ -65,18 +66,26 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Season info -->
|
||||
<!-- Season info.
|
||||
|
||||
The header stacks on narrow screens and only shares a row from `sm` up.
|
||||
Three action buttons and a season title cannot both fit across a phone,
|
||||
and side-by-side they ended up overlapping. -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<!-- The whole title block toggles the season open/closed. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="{anchor}-episodes"
|
||||
class="flex-1 min-w-0 text-left group/season"
|
||||
class="min-w-0 sm:flex-1 text-left group/season"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
|
||||
a flex item will not shrink below its content width without it, so
|
||||
a long season name grew the row instead of ellipsising and ran
|
||||
under the buttons. -->
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||
@@ -88,7 +97,7 @@
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="truncate">{seasonName}</span>
|
||||
<span class="truncate min-w-0">{seasonName}</span>
|
||||
{#if holdsCurrentEpisode}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
@@ -120,8 +129,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<!-- Per-season actions. `self-start` keeps them level with the title on
|
||||
wide rows; on a stacked phone layout they sit under it. -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2 self-start">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -130,6 +140,13 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={season.id}
|
||||
watched={watchedCount === episodeCount && episodeCount > 0}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onChanged={onHistoryCleared}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
@@ -151,6 +168,7 @@
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
current={episode.id === currentEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
onWatchedChanged={onHistoryCleared}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
Mark an episode, season or series watched — or unwatched again.
|
||||
|
||||
The backend already had both halves (`mark_played` / `clear_watch_history`,
|
||||
both recursive over a container on the server) and the sync queue already
|
||||
replayed the first; nothing in the UI had ever called them, so the only way to
|
||||
mark something watched was to sit through it. This is that control.
|
||||
|
||||
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
|
||||
this series", confirms, and needs the server — this is an everyday toggle: no
|
||||
confirmation, and it works offline by queueing, in both directions.
|
||||
|
||||
TRACES: UR-073 | DR-158
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
|
||||
interface Props {
|
||||
/** Episode, season or series id. */
|
||||
itemId: string;
|
||||
/** Current watched state, as the caller knows it. */
|
||||
watched: boolean;
|
||||
/** What is being marked, for the tooltip wording. */
|
||||
scope: "episode" | "season" | "series";
|
||||
size?: "sm" | "lg";
|
||||
/** Show a text label beside the icon rather than icon-only. */
|
||||
showLabel?: boolean;
|
||||
/** Called after a successful toggle so the caller can reload. */
|
||||
onChanged?: (watched: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
itemId,
|
||||
watched,
|
||||
scope,
|
||||
size = "lg",
|
||||
showLabel = false,
|
||||
onChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
// Optimistic state: the caller's `watched` prop only catches up once it has
|
||||
// reloaded from the repository, which on a season means a round trip. Without
|
||||
// this the button visibly ignores the first tap.
|
||||
let optimistic = $state<boolean | null>(null);
|
||||
const isWatched = $derived(optimistic ?? watched);
|
||||
|
||||
// A new item in the same slot (scrolling a virtualised list, switching series)
|
||||
// must drop the previous item's optimistic state or it shows the wrong tick.
|
||||
$effect(() => {
|
||||
itemId;
|
||||
optimistic = null;
|
||||
});
|
||||
|
||||
const subject = $derived(
|
||||
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
||||
);
|
||||
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
||||
const title = $derived(
|
||||
isWatched
|
||||
? `Mark this ${subject} unwatched`
|
||||
: scope === "episode"
|
||||
? "Mark this episode watched"
|
||||
: `Mark every episode in this ${subject} watched`
|
||||
);
|
||||
|
||||
async function handleClick() {
|
||||
if (busy) return;
|
||||
|
||||
const next = !isWatched;
|
||||
busy = true;
|
||||
optimistic = next;
|
||||
try {
|
||||
if (next) {
|
||||
await syncService.queueMarkPlayed(itemId);
|
||||
} else {
|
||||
await syncService.queueMarkUnplayed(itemId);
|
||||
}
|
||||
onChanged?.(next);
|
||||
} catch (e) {
|
||||
// Put the button back where it was — the change did not happen.
|
||||
optimistic = null;
|
||||
console.error("Failed to change watched state:", e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
disabled={busy}
|
||||
{title}
|
||||
aria-label={title}
|
||||
aria-pressed={isWatched}
|
||||
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
{isWatched
|
||||
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
||||
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
||||
>
|
||||
{#if busy}
|
||||
<div
|
||||
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
||||
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||
></div>
|
||||
{:else if isWatched}
|
||||
<!-- Filled check: this one is done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
|
||||
6.4-6.4L18.4 8.8l-7.8 7.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Outline check: available, not yet done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if showLabel}
|
||||
<span>{busy ? "Saving…" : label}</span>
|
||||
{/if}
|
||||
</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)) };
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
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(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
@@ -61,6 +82,10 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
|
||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||
// The player loads the streaming-quality picker on mount; without these the
|
||||
// mock throws and every test in the file fails before it starts.
|
||||
playerGetStreamingQualities: vi.fn(async () => []),
|
||||
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { JRayActor } from "$lib/api/bindings";
|
||||
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Hls from "hls.js";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -14,7 +14,9 @@
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
import { fatalNetworkErrorAction } from "./hlsRecovery";
|
||||
import {
|
||||
subtitleStreamsOf,
|
||||
resolveSubtitleTracks,
|
||||
reconcileSelectedSubtitle,
|
||||
videoCrossOriginMode,
|
||||
@@ -38,7 +40,13 @@
|
||||
enableNativeVideoCompositing,
|
||||
disableNativeVideoCompositing,
|
||||
} from "$lib/utils/videoSurface";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isPipSupported,
|
||||
enterPip,
|
||||
setAutoEnterEnabled,
|
||||
setHtml5VideoState,
|
||||
} from "$lib/utils/pictureInPicture";
|
||||
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
@@ -109,8 +117,41 @@
|
||||
endedFired = true;
|
||||
onEnded?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||
*
|
||||
* PiP is driven by the Activity, and it only ever knew about the native
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
|
||||
* time defaulted to off. So in the then-shipping configuration nothing
|
||||
* satisfied its "is a video playing?" check and the PiP button did nothing at
|
||||
* all. Reporting the element gives it a surface it can shrink into, and still
|
||||
* has to: the flag defaults to on now (DR-161) but a user who turns it off is
|
||||
* back on the element. (UR-041, DR-160, DR-161)
|
||||
*/
|
||||
function reportPipVideoState() {
|
||||
if (!useHtml5Element || !videoElement) {
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
/**
|
||||
* True while the Activity is in picture-in-picture.
|
||||
*
|
||||
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
|
||||
* itself down to the video — controls, header and gradients would otherwise be
|
||||
* rendered into a window a couple of inches wide. (UR-041, DR-160)
|
||||
*/
|
||||
let isInPip = $state(false);
|
||||
let pipListenerCleanup: (() => void) | null = null;
|
||||
let showSleepTimerModal = $state(false);
|
||||
let isBuffering = $state(false);
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -207,6 +248,14 @@
|
||||
let showSubtitleMenu = $state(false);
|
||||
let selectedSubtitleIndex = $state<number | null>(null);
|
||||
|
||||
// Streaming bandwidth ceiling. The ladder and the current value both come from
|
||||
// Rust — the frontend never encodes what a step means.
|
||||
// TRACES: UR-074 | DR-162
|
||||
let showQualityMenu = $state(false);
|
||||
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||
let selectedQuality = $state<StreamingQuality>("original");
|
||||
let changingQuality = $state(false);
|
||||
|
||||
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
||||
let videoDuration = $state(0);
|
||||
|
||||
@@ -285,13 +334,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(() => {
|
||||
if (!media || !media.mediaStreams) {
|
||||
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
||||
return [];
|
||||
}
|
||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
|
||||
const tracks = subtitleStreamsOf(media.mediaStreams);
|
||||
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
||||
return tracks;
|
||||
});
|
||||
@@ -486,26 +540,32 @@
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('[VideoPlayer] HLS error:', data);
|
||||
if (data.fatal) {
|
||||
// Check if we're near the end of the video - if so, this is likely
|
||||
// end-of-stream rather than a real error. Jellyfin transcoded HLS
|
||||
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
|
||||
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||
// 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 effectiveTime = currentTime + seekOffset;
|
||||
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
||||
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsFatalRecoveryAttempts++;
|
||||
if (isNearEnd) {
|
||||
// Near end of stream - treat as natural end, don't restart
|
||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||
notifyEnded();
|
||||
} else if (hlsFatalRecoveryAttempts <= 3) {
|
||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
hls!.startLoad();
|
||||
} else {
|
||||
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
||||
hls!.destroy();
|
||||
switch (fatalNetworkErrorAction({
|
||||
positionSeconds: currentTime,
|
||||
knownDurationSeconds: knownDuration,
|
||||
attempts: hlsFatalRecoveryAttempts,
|
||||
})) {
|
||||
case 'ended':
|
||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||
notifyEnded();
|
||||
break;
|
||||
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;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
@@ -603,6 +663,27 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Populate the quality menu. Deliberately its own *synchronous* onMount that
|
||||
// fires the load without awaiting it: an await inside the main onMount below
|
||||
// flips the component into HTML5 mode and breaks native seeking, and nothing
|
||||
// about playback waits on this list.
|
||||
//
|
||||
// TRACES: UR-074 | DR-162
|
||||
onMount(() => {
|
||||
Promise.all([
|
||||
commands.playerGetStreamingQualities(),
|
||||
commands.playerGetVideoSettings(),
|
||||
])
|
||||
.then(([qualities, settings]) => {
|
||||
streamingQualities = qualities;
|
||||
// Optional on the wire (serde default) — absent means uncapped.
|
||||
selectedQuality = settings.streamingQuality ?? "original";
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Set up progress reporting interval
|
||||
onMount(async () => {
|
||||
// Background-audio lifecycle listeners MUST be registered synchronously —
|
||||
@@ -810,6 +891,24 @@
|
||||
// Load series audio preference (for TV shows)
|
||||
await loadSeriesAudioPreference();
|
||||
|
||||
// PiP: keep native's view of the `<video>` current, and react to the window
|
||||
// shrinking. The listeners are torn down in onDestroy. (DR-160)
|
||||
reportPipVideoState();
|
||||
const onPipEntered = () => (isInPip = true);
|
||||
const onPipExited = () => (isInPip = false);
|
||||
const onPipPlay = () => void videoElement?.play().catch(() => {});
|
||||
const onPipPause = () => videoElement?.pause();
|
||||
window.addEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.addEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.addEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.addEventListener("jellytau-pip-pause", onPipPause);
|
||||
pipListenerCleanup = () => {
|
||||
window.removeEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.removeEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.removeEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.removeEventListener("jellytau-pip-pause", onPipPause);
|
||||
};
|
||||
|
||||
// Report progress every 10 seconds while playing. Live streams have no
|
||||
// meaningful position to report, so skip progress reporting entirely.
|
||||
if (!isLive) {
|
||||
@@ -855,6 +954,16 @@
|
||||
// and idempotent — a no-op when compositing was never enabled.
|
||||
disableNativeVideoCompositing();
|
||||
|
||||
// Same reasoning for the system bars: they belong to the Activity, not to
|
||||
// this component, so a player torn down while immersive would leave every
|
||||
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
|
||||
exitImmersive();
|
||||
|
||||
// The `<video>` is going away, so PiP must stop being offered over it.
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
pipListenerCleanup?.();
|
||||
pipListenerCleanup = null;
|
||||
|
||||
// Stop RAF loop
|
||||
stopTimeUpdates();
|
||||
|
||||
@@ -968,6 +1077,9 @@
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
console.log("[VideoPlayer] loadedmetadata event");
|
||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||
reportPipVideoState();
|
||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
@@ -1239,6 +1351,8 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// PiP's play/pause action reflects this. (DR-160)
|
||||
reportPipVideoState();
|
||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||
// Rust cannot observe directly). See html5Adapter.ts.
|
||||
@@ -1268,6 +1382,7 @@
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||
// Report progress when paused
|
||||
@@ -1497,14 +1612,19 @@
|
||||
// Determine the target URL + how the element/offset should be positioned.
|
||||
let targetUrl: string;
|
||||
if (needsTranscoding && onSeek) {
|
||||
// Transcoded HLS can't seek by setting currentTime — the stream must be
|
||||
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
|
||||
// The reloaded segment's timeline starts at 0, so seekOffset carries the
|
||||
// absolute base and the element seeks to 0 (handled on canplay).
|
||||
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
|
||||
// stream starts at the BEGINNING of the item, not at `pos`: a start
|
||||
// position on an HLS playlist is copied onto every segment URI and
|
||||
// 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);
|
||||
seekOffset = pos;
|
||||
seekOffset = 0;
|
||||
currentTime = pos;
|
||||
pendingForegroundSeek = 0;
|
||||
pendingForegroundSeek = pos;
|
||||
} else {
|
||||
// Direct stream: reload the original URL and seek the element to pos.
|
||||
targetUrl = streamUrl;
|
||||
@@ -1528,12 +1648,24 @@
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
// On Android the Activity owns the system bars, and requestFullscreen() cannot
|
||||
// reach them — the WebView already spans the window under an edge-to-edge
|
||||
// Activity, so on its own it left the status and navigation bars painted over
|
||||
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||
});
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
exitImmersive();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
@@ -1593,7 +1725,9 @@
|
||||
toggleFullscreen();
|
||||
} else if (e.key === "Escape") {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen();
|
||||
// Through the toggle, not document.exitFullscreen() directly: leaving
|
||||
// fullscreen also has to restore the system bars and clear the flag.
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -1803,6 +1937,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleQualityMenu() {
|
||||
showQualityMenu = !showQualityMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open the current stream at a different bandwidth ceiling.
|
||||
*
|
||||
* The backend owns everything about how that happens — it decides whether the
|
||||
* caller reloads (HTML5) or it reloads the native backend itself — so this
|
||||
* only supplies the position to resume at and reverts the selection if the
|
||||
* switch fails.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async function selectQuality(quality: StreamingQuality) {
|
||||
showQualityMenu = false;
|
||||
if (quality === selectedQuality || changingQuality) return;
|
||||
|
||||
const previous = selectedQuality;
|
||||
selectedQuality = quality;
|
||||
changingQuality = true;
|
||||
try {
|
||||
stopTimeUpdates();
|
||||
await playerController.setStreamQuality(
|
||||
quality,
|
||||
videoElement ? videoElement.currentTime + seekOffset : null,
|
||||
mediaSourceId ?? null,
|
||||
selectedAudioTrackIndex
|
||||
);
|
||||
if (videoElement && !videoElement.paused) {
|
||||
startTimeUpdates();
|
||||
}
|
||||
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
||||
} catch (err) {
|
||||
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
||||
selectedQuality = previous;
|
||||
} finally {
|
||||
changingQuality = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSubtitleMenu() {
|
||||
showSubtitleMenu = !showSubtitleMenu;
|
||||
}
|
||||
@@ -2090,8 +2265,8 @@
|
||||
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||
style:padding-left="calc(1rem + var(--safe-left))"
|
||||
style:padding-right="calc(1rem + var(--safe-right))"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
class:opacity-0={!showControls || isInPip}
|
||||
class:pointer-events-none={!showControls || isInPip}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="mb-2">
|
||||
@@ -2199,6 +2374,49 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 -->
|
||||
{#if streamingQualities.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleQualityMenu}
|
||||
class="text-white hover:text-gray-300 disabled:opacity-50"
|
||||
disabled={changingQuality}
|
||||
aria-label="Select streaming quality"
|
||||
>
|
||||
<!-- Speedometer: bitrate ceiling -->
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{#if showQualityMenu}
|
||||
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto">
|
||||
<div class="p-2">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Quality
|
||||
</div>
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => selectQuality(quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality === quality ? 'bg-white/20' : ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{label}</span>
|
||||
<span class="text-xs text-gray-400">{detail}</span>
|
||||
</div>
|
||||
{#if selectedQuality === quality}
|
||||
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
{#if subtitleTracks().length > 0}
|
||||
<div class="relative">
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
setHtml5VideoState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
|
||||
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
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(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
@@ -63,6 +84,10 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
playerCancelSleepTimer: vi.fn(async () => ({})),
|
||||
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
||||
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
||||
// The player loads the streaming-quality picker on mount; without these the
|
||||
// mock throws and every test in the file fails before it starts.
|
||||
playerGetStreamingQualities: vi.fn(async () => []),
|
||||
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
||||
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
||||
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -53,6 +53,68 @@ describe("subtitleStreamsOf", () => {
|
||||
expect(subtitleStreamsOf(null)).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", () => {
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface SubtitleStreamLike {
|
||||
displayTitle?: string | null;
|
||||
isDefault?: 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. */
|
||||
@@ -46,12 +53,32 @@ export interface RenderableSubtitleTrack {
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** Subtitle streams of a media item, in stream order. */
|
||||
export function subtitleStreamsOf(
|
||||
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||
): SubtitleStreamLike[] {
|
||||
/**
|
||||
* Subtitle streams of a media item that the app can actually show, in stream
|
||||
* order. This is the one list behind everything: the picker, the `<track>`
|
||||
* 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 [];
|
||||
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. */
|
||||
|
||||
@@ -192,18 +192,86 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(120);
|
||||
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
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 () => {
|
||||
video.paused = true;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
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
|
||||
* and resume at `offset`. Contains NO strategy decision; the backend already
|
||||
* decided to reload and supplied the url/offset. Preserves the hard-won
|
||||
* dual-audio teardown and canplay wait.
|
||||
* PRIMITIVE: compound reload — swap the source and resume at
|
||||
* `positionSeconds`, an **absolute** position on the item's own timeline.
|
||||
* Contains NO strategy decision; the backend already decided to reload and
|
||||
* 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;
|
||||
if (!el) {
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
@@ -171,9 +184,24 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
el.load();
|
||||
}
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -221,14 +249,26 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
const done = () => {
|
||||
el.removeEventListener(event, done);
|
||||
resolve();
|
||||
/**
|
||||
* Resolves `true` when the event fires, `false` if the budget runs out. The
|
||||
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
|
||||
* missing `canplay` means the reload failed.
|
||||
*/
|
||||
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);
|
||||
setTimeout(done, timeoutMs);
|
||||
const listener = () => done(true);
|
||||
el.addEventListener(event, listener);
|
||||
timer = setTimeout(() => done(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PlayTracksContext,
|
||||
PlayAlbumTrackRequest,
|
||||
PlayItemRequest,
|
||||
StreamingQuality,
|
||||
} from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { PlayerAdapter } from "./adapters/types";
|
||||
@@ -151,6 +152,9 @@ async function seekVideo(
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
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);
|
||||
} else {
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
@@ -182,6 +186,36 @@ async function switchAudioTrack(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the bandwidth ceiling of the video playing now. The backend re-opens
|
||||
* the stream at the new quality and decides who reloads: it handles a native
|
||||
* backend itself, and hands HTML5 a URL for the same `reloadSource` primitive
|
||||
* the audio-track switch uses. Requires an active video adapter.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
*/
|
||||
async function setStreamQuality(
|
||||
quality: StreamingQuality,
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null
|
||||
): Promise<void> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
const response = (await commands.playerSetStreamQuality(
|
||||
requireHandle(),
|
||||
quality,
|
||||
adapter.kind === "html5",
|
||||
currentPosition,
|
||||
mediaSourceId,
|
||||
audioTrackIndex
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
await commands.playerNext();
|
||||
}
|
||||
@@ -300,6 +334,7 @@ export const playerController = {
|
||||
setSubtitleTrack,
|
||||
seekVideo,
|
||||
switchAudioTrack,
|
||||
setStreamQuality,
|
||||
playTracks,
|
||||
playAlbumTrack,
|
||||
playItem,
|
||||
|
||||
@@ -16,6 +16,7 @@ const OPERATION_LABELS: Record<string, string> = {
|
||||
report_playback_stopped: "Watch position",
|
||||
update_progress: "Watch position",
|
||||
mark_played: "Marked as watched",
|
||||
mark_unplayed: "Marked as unwatched",
|
||||
mark_favorite: "Added to favourites",
|
||||
unmark_favorite: "Removed from favourites",
|
||||
playlist_create: "Playlist created",
|
||||
|
||||
@@ -13,12 +13,15 @@ import { commands } from "$lib/api/bindings";
|
||||
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,
|
||||
* automatically queueing for sync if the server is unreachable.
|
||||
* The server is told by Rust: loading an item into the controller reports the
|
||||
* 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(
|
||||
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 final position is captured by reportPlaybackStopped.
|
||||
* The server's copy is not sent from here. Position ticks already flow into Rust
|
||||
* 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(
|
||||
itemId: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type { SyncQueueItem };
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
@@ -101,12 +102,30 @@ class SyncService {
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Update local state first
|
||||
await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
|
||||
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
|
||||
// it has to cover a season or series' episodes too. storageMarkPlayed stays
|
||||
// the single-item "this finished playing" path.
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
|
||||
|
||||
return this.queueMutation("mark_played", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
|
||||
*
|
||||
* Same shape deliberately: the watched toggle has to work in both directions
|
||||
* offline, or un-marking would be the one half that needs a connection. The
|
||||
* drain pushes this as `clear_watch_history` — Jellyfin's mark-unplayed, which
|
||||
* is recursive over a season or series and also clears resume positions.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async queueMarkUnplayed(itemId: string): Promise<number> {
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
|
||||
|
||||
return this.queueMutation("mark_unplayed", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending sync operations
|
||||
*/
|
||||
|
||||
@@ -158,9 +158,10 @@ describe("downloads store", () => {
|
||||
},
|
||||
}); // 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", {
|
||||
handle: "handle-1",
|
||||
albumId: "album-1",
|
||||
userId: "user-1",
|
||||
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 {
|
||||
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);
|
||||
|
||||
// Refresh downloads
|
||||
|
||||
@@ -26,6 +26,26 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
|
||||
/** The attribute app.css keys its transparency rules off. */
|
||||
const NATIVE_VIDEO_ATTR = "data-native-video";
|
||||
|
||||
/**
|
||||
* Whether the native path is on. **Off** unless the user turned it on.
|
||||
*
|
||||
* DR-161 briefly made this default to on, so picture-in-picture could shrink a
|
||||
* real video surface. On a device that shipped as **audio with no picture**:
|
||||
* ExoPlayer decoded correctly and fed its SurfaceView, but the SurfaceView sits
|
||||
* *behind* the WebView and the compositing that clears the opaque layers above it
|
||||
* never took effect — logcat showed `WebView transparent = false` and never
|
||||
* `= true`. So the video was rendering the whole time, behind the page.
|
||||
*
|
||||
* That is the defect the flag existed to contain, and it is why the default is
|
||||
* back off: video working matters more than PiP showing the native surface, and
|
||||
* PiP still works without it via the HTML5 path (DR-160). Native video remains
|
||||
* available in Settings for anyone testing it.
|
||||
*
|
||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||
* it on keeps it on.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-172
|
||||
*/
|
||||
function load(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
try {
|
||||
@@ -59,7 +79,11 @@ function createExperimentalNativeVideoStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** User opt-in for the native Android video path. Default off. */
|
||||
/**
|
||||
* User opt-in for the native Android video path. **Defaults to off** again since
|
||||
* DR-172 — see `load()`. The name says "experimental" because the flag
|
||||
* remains a suppressor of Rust's backend choice, not a promoter of it.
|
||||
*/
|
||||
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
|
||||
|
||||
function createNativeVideoActiveStore() {
|
||||
|
||||
@@ -33,6 +33,25 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
|
||||
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. */
|
||||
export function favoritesRouteUrl(scope: FavoritesScope): string {
|
||||
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Immersive (system-bar-free) full-screen video, Android only.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* `requestFullscreen()` is the only fullscreen control the web layer has, and in
|
||||
* an Android WebView it does not touch the Activity window — it expands the
|
||||
* element inside a viewport that already spans the whole screen (MainActivity
|
||||
* calls `enableEdgeToEdge()`, and SDK 36 makes that mandatory). So the status and
|
||||
* navigation bars stayed painted over full-screen video, and "fullscreen"
|
||||
* changed nothing visible.
|
||||
*
|
||||
* Hiding them needs `WindowInsetsControllerCompat` on the Activity, so it goes
|
||||
* through the `AndroidImmersive` @JavascriptInterface installed by MainActivity.
|
||||
* Elsewhere (desktop, the Linux WebKitGTK webview) the real `requestFullscreen()`
|
||||
* already does the right thing and these calls are no-ops.
|
||||
*/
|
||||
|
||||
interface AndroidImmersiveBridge {
|
||||
enter(): void;
|
||||
exit(): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidImmersive?: AndroidImmersiveBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidImmersiveBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidImmersive;
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists on this platform. */
|
||||
export function isImmersiveSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[Immersive] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide the system bars. No-op where unsupported. */
|
||||
export function enterImmersive(): void {
|
||||
try {
|
||||
bridge()?.enter();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to hide the system bars:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the system bars. No-op where unsupported.
|
||||
*
|
||||
* Call this on leaving fullscreen *and* on player teardown — the bars belong to
|
||||
* the Activity, not the player, so a player destroyed while immersive would
|
||||
* leave every screen behind it without a status or navigation bar.
|
||||
*/
|
||||
export function exitImmersive(): void {
|
||||
try {
|
||||
bridge()?.exit();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to restore the system bars:", err);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface AndroidPictureInPictureBridge {
|
||||
isSupported(): boolean;
|
||||
canEnterPip(): boolean;
|
||||
setAutoEnterEnabled(enabled: boolean): void;
|
||||
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -84,3 +85,36 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
|
||||
*
|
||||
* This is what makes PiP work on the HTML5 path. The native side only ever knew
|
||||
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
|
||||
* which defaulted to off when this was written — so `canEnterPip` was always
|
||||
* false and pressing the button did nothing. Reporting the element's state gives
|
||||
* native a surface it can legitimately shrink into, plus the intrinsic size it
|
||||
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
||||
* action.
|
||||
*
|
||||
* The flag is back to defaulting **off** (DR-172, after native video shipped as
|
||||
* audio with no picture), so this is once again the path Android normally takes —
|
||||
* 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
|
||||
* video that is no longer there.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
export function setHtml5VideoState(
|
||||
active: boolean,
|
||||
width: number,
|
||||
height: number,
|
||||
playing: boolean
|
||||
): void {
|
||||
try {
|
||||
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
||||
} catch (err) {
|
||||
console.warn("[PiP] Failed to report HTML5 video state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Wires a persistent scroll container to the per-route scroll memory.
|
||||
*
|
||||
* The decision logic is pure and lives in `scrollRestore.ts`; this is the thin
|
||||
* DOM/SvelteKit half. Call it once at component init (SvelteKit's navigation
|
||||
* hooks must be registered during initialisation, not from `onMount`), passing
|
||||
* a getter for the element — the element itself is bound later, so a getter is
|
||||
* the only way to hand it over from the top of `<script>`.
|
||||
*
|
||||
* let scroller: HTMLElement | undefined = $state();
|
||||
* useScrollRestore(() => scroller, "library");
|
||||
* …
|
||||
* <div bind:this={scroller} class="flex-1 overflow-y-auto">
|
||||
*
|
||||
* Memories are keyed by container id and held at module scope, not per call.
|
||||
* Two containers must never share one (the root, home and library scrollers
|
||||
* hold different content for the same URL, so a shared map would restore one
|
||||
* into another) — but a container that *remounts* has to find its offsets again
|
||||
* when it comes back. The home scroller is destroyed on every navigation away,
|
||||
* so a memory owned by the component instance would be empty on return and Back
|
||||
* could only ever land at the top.
|
||||
*
|
||||
* TRACES: UR-072 | DR-156
|
||||
*/
|
||||
|
||||
import { beforeNavigate, afterNavigate } from "$app/navigation";
|
||||
import { tick } from "svelte";
|
||||
import { ScrollMemory, classifyNavigation, scrollKey } from "./scrollRestore";
|
||||
|
||||
/** Container id → its offsets. Outlives the components that mount them. */
|
||||
const memories = new Map<string, ScrollMemory>();
|
||||
|
||||
function memoryFor(containerId: string): ScrollMemory {
|
||||
let memory = memories.get(containerId);
|
||||
if (!memory) {
|
||||
memory = new ScrollMemory();
|
||||
memories.set(containerId, memory);
|
||||
}
|
||||
return memory;
|
||||
}
|
||||
|
||||
/** Forget every container's offsets. For sign-out and tests. */
|
||||
export function clearScrollMemories(): void {
|
||||
memories.clear();
|
||||
}
|
||||
|
||||
export function useScrollRestore(
|
||||
getElement: () => HTMLElement | null | undefined,
|
||||
containerId: string
|
||||
): void {
|
||||
const memory = memoryFor(containerId);
|
||||
|
||||
// Record where we were before the route changes. `nav.from` is absent on the
|
||||
// very first navigation, which is exactly when there is nothing to save.
|
||||
beforeNavigate((nav) => {
|
||||
const element = getElement();
|
||||
if (!element || !nav.from) return;
|
||||
memory.save(scrollKey(nav.from.url), element.scrollTop);
|
||||
});
|
||||
|
||||
afterNavigate(async (nav) => {
|
||||
const target = nav.to;
|
||||
if (!target) return;
|
||||
|
||||
const action = memory.decide(scrollKey(target.url), classifyNavigation(nav));
|
||||
if (action.kind === "none") return;
|
||||
|
||||
const top = action.kind === "restore" ? action.top : 0;
|
||||
|
||||
// Wait for the new route's markup to be in the DOM before moving the
|
||||
// scroller — setting scrollTop past the current content height is clamped,
|
||||
// and a reset applied too early is undone by the incoming render.
|
||||
await tick();
|
||||
const element = getElement();
|
||||
if (!element) return;
|
||||
|
||||
element.scrollTop = top;
|
||||
|
||||
// A restore often targets content that is still loading (a library grid
|
||||
// fetches after mount), so the offset would clamp to a short page. Re-apply
|
||||
// on the next frame, once, which is enough for the common case without
|
||||
// fighting a user who has already started scrolling.
|
||||
if (action.kind === "restore" && top > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = getElement();
|
||||
if (el && el.scrollTop < top) el.scrollTop = top;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ScrollMemory, classifyNavigation } from "./scrollRestore";
|
||||
|
||||
describe("classifyNavigation", () => {
|
||||
it("treats the initial page load as an entry", () => {
|
||||
expect(classifyNavigation({ type: "enter" })).toBe("enter");
|
||||
});
|
||||
|
||||
it("treats back/forward gestures as a popstate", () => {
|
||||
expect(classifyNavigation({ type: "popstate" })).toBe("popstate");
|
||||
});
|
||||
|
||||
it("treats link and goto navigations as forward moves", () => {
|
||||
expect(classifyNavigation({ type: "link" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "goto" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "form" })).toBe("forward");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrollMemory", () => {
|
||||
let memory: ScrollMemory;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = new ScrollMemory();
|
||||
});
|
||||
|
||||
// The bug: a scroll container that lives in a persistent layout keeps its
|
||||
// offset across a forward navigation, so a page opened from a scrolled list
|
||||
// starts part-way down. A forward move must always land at the top.
|
||||
it("resets to the top on a forward navigation, even from a scrolled page", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library/abc123", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("resets to the top when navigating forward to a page seen before", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
// Re-entering /library by tapping a nav link is a fresh visit, not a Back.
|
||||
expect(memory.decide("/library", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("restores the saved offset on Back", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
|
||||
it("restores the top when Back targets a page with no saved offset", () => {
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 0 });
|
||||
});
|
||||
|
||||
it("keeps offsets per route rather than sharing one across pages", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
expect(memory.decide("/search", "popstate")).toEqual({ kind: "restore", top: 340 });
|
||||
});
|
||||
|
||||
it("leaves the container alone on the initial load", () => {
|
||||
expect(memory.decide("/", "enter")).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("overwrites a stale offset when the same route is saved again", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/library", 80);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 80 });
|
||||
});
|
||||
|
||||
it("forgets nothing on decide, so a repeated Back still restores", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
memory.decide("/library", "popstate");
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Per-route scroll memory for the app's persistent scroll containers.
|
||||
*
|
||||
* The shell keeps its scrollers alive across navigation on purpose: the root
|
||||
* layout, the home page and the library layout each own a
|
||||
* `flex-1 overflow-y-auto` box that outlives the route rendered inside it. That
|
||||
* is what makes the bottom UI a flex sibling rather than a measured overlay —
|
||||
* but it also means the *element* never remounts, so its `scrollTop` survives a
|
||||
* route change and the next page opens part-way down.
|
||||
*
|
||||
* SvelteKit's own scroll restoration cannot help here: it saves and restores
|
||||
* `window` scroll, and in this app the window never scrolls at all.
|
||||
*
|
||||
* So each container gets its own memory, which reproduces normal browser
|
||||
* behaviour:
|
||||
*
|
||||
* - **forward** (link/goto/form) — a fresh visit, always lands at the top;
|
||||
* - **popstate** (hardware/gesture Back or Forward) — restores the offset the
|
||||
* route was left at, so Back out of a detail page returns you to your place
|
||||
* in the list rather than to the top of it;
|
||||
* - **enter** (initial load) — left alone; there is nothing to leak yet.
|
||||
*
|
||||
* The decision is pure and lives here so it can be unit-tested without a DOM;
|
||||
* `scrollContainer.svelte.ts` is the thin action that applies it.
|
||||
*
|
||||
* TRACES: UR-054 | DR-156
|
||||
*/
|
||||
|
||||
/** How a navigation should affect a persistent scroll container. */
|
||||
export type NavKind = "enter" | "popstate" | "forward";
|
||||
|
||||
/** What to do with the container once the new route has rendered. */
|
||||
export type ScrollAction =
|
||||
| { kind: "reset" }
|
||||
| { kind: "restore"; top: number }
|
||||
| { kind: "none" };
|
||||
|
||||
/**
|
||||
* Collapse SvelteKit's navigation types into the three cases that matter.
|
||||
*
|
||||
* `enter` is the initial load. `popstate` is a Back/Forward gesture. Everything
|
||||
* else — `link`, `goto`, `form` — is a forward move into a new page.
|
||||
*/
|
||||
export function classifyNavigation(nav: { type?: string | null }): NavKind {
|
||||
if (nav.type === "enter") return "enter";
|
||||
if (nav.type === "popstate") return "popstate";
|
||||
return "forward";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the offset each route was left at, for one scroll container.
|
||||
*
|
||||
* One instance per container: the root scroller, the home scroller and the
|
||||
* library scroller hold different content for the same URL, so a shared map
|
||||
* would restore one container's offset into another.
|
||||
*/
|
||||
export class ScrollMemory {
|
||||
#offsets = new Map<string, number>();
|
||||
|
||||
/** Record where `key` was scrolled to, before we navigate away from it. */
|
||||
save(key: string, top: number): void {
|
||||
this.#offsets.set(key, Math.max(0, top));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what the container should do on arriving at `key`.
|
||||
*
|
||||
* Note this does not consume the saved offset: a route can be returned to
|
||||
* more than once, and each Back should restore the same place.
|
||||
*/
|
||||
decide(key: string, kind: NavKind): ScrollAction {
|
||||
if (kind === "enter") return { kind: "none" };
|
||||
if (kind === "popstate") return { kind: "restore", top: this.#offsets.get(key) ?? 0 };
|
||||
return { kind: "reset" };
|
||||
}
|
||||
|
||||
/** Drop everything. Intended for tests and sign-out. */
|
||||
clear(): void {
|
||||
this.#offsets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory key for a URL.
|
||||
*
|
||||
* Path plus query: a library grid filtered by genre is a different list from
|
||||
* the unfiltered one, and returning to it should restore its own place.
|
||||
*/
|
||||
export function scrollKey(url: { pathname: string; search?: string }): string {
|
||||
return `${url.pathname}${url.search ?? ""}`;
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
shellReservesBottomInset,
|
||||
} from "$lib/utils/layoutShell";
|
||||
import { registerNavigationTracking } from "$lib/utils/navigation";
|
||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||
import { startNetworkReporting } from "$lib/services/networkType";
|
||||
import { initSafeArea } from "$lib/utils/safeArea";
|
||||
|
||||
@@ -52,6 +53,12 @@
|
||||
// context, not the async onMount callback below.
|
||||
registerNavigationTracking();
|
||||
|
||||
// The shell's scroller outlives every route rendered into it, so without this
|
||||
// a new page inherits the previous page's offset. Must be registered here at
|
||||
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||
let shellScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => shellScroller, "shell");
|
||||
|
||||
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
||||
//
|
||||
@@ -313,6 +320,7 @@
|
||||
sibling, so the list is physically bounded above it and can never
|
||||
render behind it. No measurement, no reserved padding. -->
|
||||
<div
|
||||
bind:this={shellScroller}
|
||||
class="flex-1 overflow-y-auto min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
>
|
||||
|
||||
+38
-14
@@ -9,9 +9,18 @@
|
||||
import { currentMedia } from "$lib/stores/player";
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.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 type { MediaItem, Library } from "$lib/api/types";
|
||||
|
||||
// Home scrolls in its own box rather than the shell's, and is destroyed on
|
||||
// every navigation away — so its offsets live in the module-level memory,
|
||||
// letting Back return the viewer to their row instead of the top. (DR-156)
|
||||
let homeScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => homeScroller, "home");
|
||||
|
||||
// Track if we've done an initial load (plain variable, not reactive)
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
@@ -106,6 +115,15 @@
|
||||
$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) {
|
||||
// Mirror /library routing: dedicated landing pages need currentLibrary set.
|
||||
library.setCurrentLibrary(lib);
|
||||
@@ -147,7 +165,7 @@
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div bind:this={homeScroller} class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div class="space-y-8">
|
||||
|
||||
<!-- Hero Banner -->
|
||||
@@ -159,19 +177,25 @@
|
||||
{#if shortcutLibraries.length > 0}
|
||||
<div>
|
||||
<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">
|
||||
{#each shortcutLibraries as lib (lib.id)}
|
||||
<div class="flex-shrink-0">
|
||||
<!-- Uniform 16:9 artwork so music (square) and video libraries
|
||||
line up at the same height in this mixed row. -->
|
||||
<MediaCard
|
||||
item={lib}
|
||||
size="medium"
|
||||
aspect="video"
|
||||
onclick={() => handleLibraryClick(lib)}
|
||||
<div class="px-4">
|
||||
<MosaicGrid
|
||||
items={libraryTiles}
|
||||
layout="strip"
|
||||
targetHeight={LIBRARY_STRIP_HEIGHT}
|
||||
gap={12}
|
||||
>
|
||||
{#snippet tile(entry)}
|
||||
<MosaicTile
|
||||
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>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</MosaicGrid>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
|
||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||
@@ -11,6 +12,12 @@
|
||||
const scrollGuard = useScrollGuard(300);
|
||||
setContext("scrollGuard", scrollGuard);
|
||||
|
||||
// This scroller outlives every /library/* route rendered into it, so opening
|
||||
// an item from half-way down a grid used to drop the viewer half-way down the
|
||||
// detail page. Registered at init, as SvelteKit's nav hooks require. (DR-156)
|
||||
let libraryScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => libraryScroller, "library");
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let showSleepTimerModal = $state(false);
|
||||
@@ -45,6 +52,7 @@
|
||||
scroller is physically bounded above it and its last row can never
|
||||
render behind the nav — no measurement, no reserved padding. -->
|
||||
<main
|
||||
bind:this={libraryScroller}
|
||||
class="flex-1 overflow-y-auto p-4 min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
onscroll={scrollGuard.onScroll}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||
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 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)
|
||||
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
||||
@@ -40,6 +42,12 @@
|
||||
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
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
@@ -242,15 +250,46 @@
|
||||
<p>No libraries found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{#each visibleLibraries as lib (lib.id)}
|
||||
<MediaCard
|
||||
item={lib}
|
||||
size="medium"
|
||||
onclick={() => handleLibraryClick(lib)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Favourites are destinations in their own right, not just the icon in
|
||||
the header above. The cross-library entry leads the mosaic and each
|
||||
category's own favourites sits beside its library — a labelled tile
|
||||
at the same weight as a library is the difference between a feature
|
||||
people find and one they don't. ux-flows §5C.2.
|
||||
TRACES: UR-067, UR-075 | DR-117, DR-174 -->
|
||||
<MosaicGrid items={mosaicEntries} gap={8}>
|
||||
{#snippet tile(entry)}
|
||||
{#if entry.kind === "favorites"}
|
||||
<MosaicTile
|
||||
label={entry.label}
|
||||
width={entry.width}
|
||||
height={entry.height}
|
||||
accent
|
||||
onclick={() => goto(entry.href)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<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" />
|
||||
</svg>
|
||||
{/snippet}
|
||||
</MosaicTile>
|
||||
{:else}
|
||||
<MosaicTile
|
||||
label={entry.label}
|
||||
width={entry.width}
|
||||
height={entry.height}
|
||||
itemId={entry.library.id}
|
||||
imageTag={entry.library.imageTag}
|
||||
onRatio={entry.reportRatio}
|
||||
onclick={() => handleLibraryClick(entry.library)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MosaicGrid>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
|
||||
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
|
||||
import WatchedToggleButton from "$lib/components/library/WatchedToggleButton.svelte";
|
||||
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
@@ -549,6 +550,14 @@
|
||||
seriesName={item.name}
|
||||
episodeCount={allEpisodes.length || undefined}
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={item.id}
|
||||
watched={allEpisodes.length > 0 &&
|
||||
allEpisodes.every((e) => e.userData?.isPlayed)}
|
||||
scope="series"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
@@ -562,6 +571,14 @@
|
||||
isMovie={true}
|
||||
size="lg"
|
||||
/>
|
||||
<!-- A movie is a leaf, so its own played flag is the whole story. -->
|
||||
<WatchedToggleButton
|
||||
itemId={item.id}
|
||||
watched={item.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
|
||||
|
||||
@@ -334,22 +334,20 @@
|
||||
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
||||
);
|
||||
|
||||
// Set initial position for video player to seek to after load
|
||||
// Use explicit startPosition, or fall back to retrieved progress from database
|
||||
// For transcoded content, we need to request a new stream with StartTimeTicks
|
||||
// Set initial position for the video player to seek to after load.
|
||||
// Use explicit startPosition, or fall back to retrieved progress.
|
||||
//
|
||||
// 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;
|
||||
if (effectivePosition > 0) {
|
||||
if (videoNeedsTranscoding) {
|
||||
// For transcoded streams, get a new URL starting at the position
|
||||
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;
|
||||
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
|
||||
if (videoInitialPosition > 0) {
|
||||
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
||||
}
|
||||
} else {
|
||||
// For audio, use MPV backend
|
||||
@@ -531,14 +529,22 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle video seeking by requesting a new stream URL starting at the given position.
|
||||
* Transcoded streams don't support native seeking, so we restart from a new position.
|
||||
* Rebuild the stream for a transcoded seek or an audio-track switch.
|
||||
*
|
||||
* 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 id = itemId;
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
AudioSettings,
|
||||
CacheConfig,
|
||||
EqPreset,
|
||||
StreamingQuality,
|
||||
VideoSettings,
|
||||
VolumeLevel,
|
||||
} from "$lib/api/bindings";
|
||||
@@ -62,8 +63,14 @@
|
||||
autoPlayNextEpisode: true,
|
||||
autoPlayCountdownSeconds: 10,
|
||||
autoPlayMaxEpisodes: 0,
|
||||
streamingQuality: "original",
|
||||
});
|
||||
|
||||
// Bandwidth ceilings offered by the streaming-quality picker, as
|
||||
// [variant, label, detail] — the numbers behind each step are Jellyfin
|
||||
// encoding vocabulary, so Rust serves the list. TRACES: UR-074 | DR-162
|
||||
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
|
||||
|
||||
// Download/caching behaviour, incl. the WiFi-only gate (UR-053).
|
||||
let cacheConfig = $state<CacheConfig>({
|
||||
queuePrecacheEnabled: true,
|
||||
@@ -126,11 +133,12 @@
|
||||
try {
|
||||
loading = true;
|
||||
networkDetectionSupported = isNetworkDetectionSupported();
|
||||
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([
|
||||
const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([
|
||||
commands.playerGetAudioSettings(),
|
||||
commands.playerGetVideoSettings(),
|
||||
getCacheConfig(),
|
||||
commands.playerGetEqPresets(),
|
||||
commands.playerGetStreamingQualities(),
|
||||
]);
|
||||
// equalizerBands is optional on the wire (serde default); guarantee a
|
||||
// dense 10-band array so the slider bindings are never undefined.
|
||||
@@ -141,6 +149,7 @@
|
||||
videoSettings = videoResult;
|
||||
cacheConfig = cacheResult;
|
||||
eqPresets = presets;
|
||||
streamingQualities = qualities;
|
||||
// Load cache stats in parallel but don't block on it
|
||||
loadCacheStats();
|
||||
} catch (e) {
|
||||
@@ -331,6 +340,12 @@
|
||||
persistVideo();
|
||||
}
|
||||
|
||||
/** TRACES: UR-074 | DR-162 */
|
||||
function handleStreamingQualityChange(quality: StreamingQuality) {
|
||||
videoSettings.streamingQuality = quality;
|
||||
persistVideo();
|
||||
}
|
||||
|
||||
function handleSmartCachingToggle() {
|
||||
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
|
||||
persistCache();
|
||||
@@ -681,6 +696,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Streaming quality: the bandwidth ceiling every video stream is
|
||||
opened against. The steps and their labels come from Rust.
|
||||
TRACES: UR-074 | DR-162 -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
|
||||
<h3 class="text-xl font-semibold text-white">Streaming Quality</h3>
|
||||
<p class="text-sm text-gray-400 mt-1 mb-4">
|
||||
Limit how much bandwidth video streams may use. Lower settings ask the
|
||||
server to transcode before sending, which saves data on metered or slow
|
||||
connections at the cost of picture quality. You can also change this for
|
||||
a single video from the player's quality menu.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{#each streamingQualities as [quality, label, detail]}
|
||||
<button
|
||||
onclick={() => handleStreamingQualityChange(quality)}
|
||||
class="py-3 px-3 rounded-lg transition-all text-left
|
||||
{videoSettings.streamingQuality === quality
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
aria-pressed={videoSettings.streamingQuality === quality}
|
||||
>
|
||||
<div class="font-semibold text-sm">{label}</div>
|
||||
<div class="text-xs opacity-75 mt-0.5">{detail}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
Applies to videos started from now on; a video already playing keeps the
|
||||
quality it started at.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Native video (experimental). Only rendered where the platform's Rust
|
||||
backend actually has a native video surface (Android). -->
|
||||
{#if supportsNativeVideo}
|
||||
@@ -697,8 +744,10 @@
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Decode video with the device's hardware decoder instead of the
|
||||
built-in web player. Better performance and battery life, but
|
||||
less tested — turn this off if video fails to appear.
|
||||
built-in web player. Better performance and battery life in
|
||||
principle, but incomplete: on some devices the picture does not
|
||||
appear at all and only the sound plays. Leave this off unless
|
||||
you are helping test it.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user