Compare commits

...
10 Commits
Author SHA1 Message Date
dtourolle 99ceeadb83 docs: regenerate the traceability matrix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 5m5s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The generated matrix had drifted well behind the code — this pass picks up
DR-171/UT-166 along with everything else that had accumulated since it was last
run, which is why the diff is large for a mechanical regeneration.

Coverage 87% (265/303), no orphaned IDs, comfortably above the workflow's 50%
floor. No hand edits: `bun run traces:markdown` output as-is.
2026-08-16 00:04:58 +02:00
dtourolle 7387f35c7e docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still
described the pre-flip world and one of them was load-bearing:

- `nativeVideo.ts` labelled the store "Default off" directly above a `load()`
  that returns true when nothing is stored.
- The two PiP comments explained themselves as "what makes PiP work in the
  shipping configuration", which stopped being true when Android started
  shrinking the real ExoPlayer surface. They still describe the Linux path and
  the flag-off case, so they say that instead.
- `video_audio_codecs` justified its narrow codec list with "video does not play
  through ExoPlayer", which is no longer so on Android. The narrow list is still
  right, for a different reason now recorded: the flag is a user setting and a
  download outlives it, so only the intersection holds on both sides of the
  switch. DR-171 carries the same caveat.

No behaviour change.
2026-08-16 00:00:10 +02:00
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: 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.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00
dtourolle d49d027020 docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
2026-08-15 16:39:53 +02:00
dtourolle 9c352fdb77 Merge branch 'fix/android-versioncode-floor' 2026-08-15 16:35:27 +02:00
dtourolle dda2ff86a3 feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile 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 URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — 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 see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
2026-08-15 16:34:56 +02:00
dtourolle 8ad3dc5c4f fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from 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 — lower than what is already installed, so Android refuses
the update as a downgrade. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.

This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.

Widen the multipliers and raise the floor past 5002:

    code = 10000 + major*1000000 + minor*1000 + patch

    0.0.14 -> 10014    0.5.2 -> 15002    0.6.0 -> 16000
    0.1.0  -> 11000    0.5.3 -> 15003    1.0.0 -> 1010000

Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
2026-08-15 16:31:38 +02:00
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that 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. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:47:43 +02:00
60 changed files with 8393 additions and 4279 deletions
+16
View File
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable # Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION 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 # 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 # or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
+34 -6
View File
@@ -82,6 +82,9 @@ 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-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-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-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-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 +321,18 @@ 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-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-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-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-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-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-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-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-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 | | 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,7 +351,7 @@ Internal architecture, components, and application logic.
| UR-001 | IR-001, IR-002 | - | | UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 | | UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 | | UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 | | UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171 |
| UR-005 | - | DR-001, DR-005, DR-009 | | UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - | | UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 | | UR-007 | IR-010 | DR-007, DR-008, DR-016 |
@@ -372,8 +387,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 | | UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 | | UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 | | UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 | | UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
| UR-041 | IR-026 | DR-053 | | UR-041 | IR-026 | DR-053, DR-160, DR-161 |
| UR-042 | IR-009, IR-014 | DR-054 | | UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 | | UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 | | UR-044 | - | DR-056 |
@@ -387,7 +402,7 @@ Internal architecture, components, and application logic.
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 | | UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-053 | IR-029 | DR-074 | | UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 | | UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 | | UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169 |
| UR-056 | - | DR-085 | | UR-056 | - | DR-085 |
| UR-057 | - | DR-086 | | UR-057 | - | DR-086 |
| UR-058 | - | DR-087, DR-142 | | UR-058 | - | DR-087, DR-142 |
@@ -397,12 +412,15 @@ Internal architecture, components, and application logic.
| UR-063 | - | DR-105 | | UR-063 | - | DR-105 |
| UR-064 | - | DR-106 | | UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 | | UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112 | | UR-066 | IR-031 | DR-112, DR-157 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 | | UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 | | UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 | | UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 | | UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138 | | UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162 |
--- ---
@@ -553,6 +571,16 @@ 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-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-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-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-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-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done | | UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done | | UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| 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-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 |
+146
View File
@@ -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.
+4850 -4037
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.4.8", "version": "0.5.3",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
+3 -3
View File
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
); );
const defined = countDefinedRequirements(md); const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(71); expect(defined.UR).toBe(74);
expect(defined.IR).toBe(32); expect(defined.IR).toBe(32);
expect(defined.DR).toBe(150); expect(defined.DR).toBe(162);
expect(defined.JA).toBe(35); expect(defined.JA).toBe(35);
expect(defined.total).toBe(288); expect(defined.total).toBe(303);
}); });
}); });
+11 -3
View File
@@ -81,8 +81,16 @@ fi
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a # builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
# *downgrade* and Android refuses the update. # *downgrade* and Android refuses the update.
# #
# code = 1000 + major*10000 + minor*100 + patch # The floor has to clear the highest code actually in the field, which is not the
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000. # 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" PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to # 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) MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3) PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}" : "${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)" echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS" sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
+24 -12
View File
@@ -106,21 +106,33 @@ describe("set-version.sh", () => {
}); });
describe("Android versionCode", () => { describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never // A newer release must never produce a smaller number than an older one, or
// produce a smaller number than an older one. // Android refuses the update. The floor tracks the highest code actually in
it("clears the 1000 floor shipped by earlier builds", () => { // 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"); 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]> = [ const cases: Array<[string, number]> = [
["0.0.14", 1014], ["0.0.14", 10014],
["0.0.15", 1015], ["0.0.15", 10015],
["0.1.0", 1100], ["0.1.0", 11000],
["0.4.8", 1408], ["0.4.8", 14008],
["0.5.0", 1500], ["0.5.0", 15000],
["1.0.0", 11000], ["0.5.3", 15003],
["1.0.0", 1010000],
]; ];
for (const [version, code] of cases) { for (const [version, code] of cases) {
seed(tmp); seed(tmp);
@@ -145,7 +157,7 @@ describe("set-version.sh", () => {
// stripped before the arithmetic. // stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => { it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1"); 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"); expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
}); });
}); });
+22
View File
@@ -118,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
"$RES_DST"/drawable*/ic_launcher_background.xml "$RES_DST"/drawable*/ic_launcher_background.xml
fi 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" echo "✓ Android sources synced successfully"
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]] [[package]]
name = "jellytau" name = "jellytau"
version = "0.4.8" version = "0.5.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jellytau" name = "jellytau"
version = "0.4.8" version = "0.5.3"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -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) { fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled 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") }, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -325,6 +338,28 @@ class MainActivity : TauriActivity() {
}, "AndroidVideoSurface") }, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added") 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 // Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge. // the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets") webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
@@ -46,6 +46,62 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = 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, * 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. * and the user (or device manufacturer) can disable the feature per-app.
@@ -64,15 +120,10 @@ object PictureInPictureManager {
*/ */
fun canEnterPip(activity: Activity): Boolean { fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false if (!isPipSupported(activity)) return false
return try { // Either surface will do: the native one, or the WebView's `<video>`,
val player = JellyTauPlayer.getInstance() // which is what actually plays while experimentalNativeVideo is off.
player.isPlayingVideo() && // (DR-160)
player.getSurfaceView() != null && return isNativeVideoPath() || html5VideoActive
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
} }
/** /**
@@ -125,32 +176,47 @@ object PictureInPictureManager {
val player = try { val player = try {
JellyTauPlayer.getInstance() JellyTauPlayer.getInstance()
} catch (e: Exception) { } catch (e: Exception) {
return null null
} }
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio // The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape. // by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width val surface = player?.getSurfaceView()
val height = surface.height if (surface != null && surface.width > 0 && surface.height > 0) {
if (width <= 0 || height <= 0) return null return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
val ratio = width.toDouble() / height.toDouble() // No native surface: the WebView is the video, so use the intrinsic size
val minRatio = 1.0 / 2.39 // the frontend reported. (DR-160)
val maxRatio = 2.39 return html5AspectRatio
val clamped = ratio.coerceIn(minRatio, maxRatio) }
// 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) return Rational((clamped * 1000).toInt(), 1000)
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction { private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try { // On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
JellyTauPlayer.getInstance().getExoPlayer().isPlaying // and the button would be stuck showing "Play" mid-playback. (DR-160)
} catch (e: Exception) { val isPlaying = if (isNativeVideoPath()) {
false try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
} }
val (iconRes, title, controlType, requestCode) = if (isPlaying) { val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -222,11 +288,20 @@ object PictureInPictureManager {
*/ */
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) { fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) { 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) registerReceiver(activity)
dispatchWebEvent(activity, "jellytau-pip-entered")
} else { } else {
unregisterReceiver(activity) unregisterReceiver(activity)
showWebView() showWebView()
dispatchWebEvent(activity, "jellytau-pip-exited")
// The surface was laid out against the tiny PiP bounds; re-fit it to // 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. // the restored full-screen bounds or the video stays postage-stamp sized.
try { 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) { private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView) val webView = findWebView(activity.window.decorView)
if (webView == null) { if (webView == null) {
@@ -264,14 +356,30 @@ object PictureInPictureManager {
val r = object : BroadcastReceiver() { val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try { val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
JellyTauPlayer.getInstance()
} catch (e: Exception) { if (isNativeVideoPath()) {
return val player = try {
} JellyTauPlayer.getInstance()
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { } catch (e: Exception) {
CONTROL_PLAY -> player.play() return
CONTROL_PAUSE -> player.pause() }
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. // Swap the button to reflect the new state.
updatePipActions(activity) updatePipActions(activity)
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("seek:$positionSeconds") 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() { override fun stop() {
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
} }
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = "" private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen // The handoff base (ms): during a background-audio handoff the audio stream is
// MediaSession. During a background-audio handoff the audio stream is // requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports // starts at 0 *there* and every position it reports is relative to it. This
// position RELATIVE to that point (starting at 0). The metadata duration, // is the number that converts one back to a real position on the episode.
// 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 // It is deliberately read, not applied, here. This used to be a display-only
// position via setPositionOffset(); 0 for normal playback. // correction added at the two setPlaybackState calls below, which left every
private var positionOffsetMs: Long = 0L // 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. * Set the handoff base (seconds). Called by the native layer when entering or
* Called by the native layer when entering/exiting a background-audio handoff. * leaving a background-audio handoff; 0 clears it for normal playback, where
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute). * ExoPlayer's position is already absolute.
*/ */
fun setPositionOffset(offsetSeconds: Double) { fun setHandoffBase(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L) handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms") android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
} }
/** /**
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build()) session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state (position made absolute via the base offset). // Already absolute: this call comes from Rust, whose stored position is on
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs)) // the episode's timeline. (DR-159)
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes // While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive // 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 * notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback. * 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 * @param isPlaying Whether playback is currently active
*/ */
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) { fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying lastIsPlaying = isPlaying
// Absolute position for the scrubber = relative ExoPlayer position + base offset. session.setPlaybackState(buildPlaybackState(isPlaying, position))
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Only rebuild the notification when the play/pause icon actually flips. // Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) { if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying) updateNotification(lastTitle, lastArtist, isPlaying)
@@ -1030,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { 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 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") android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration) nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the // Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the // MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync. // scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true) service?.updatePlaybackPosition(positionMs, true)
} }
delay(POSITION_UPDATE_INTERVAL_MS) delay(POSITION_UPDATE_INTERVAL_MS)
} }
+3 -4
View File
@@ -631,8 +631,6 @@ pub async fn resume_queued_downloads(
) -> Result<ResumeQueuedResult, String> { ) -> Result<ResumeQueuedResult, String> {
use crate::repository::MediaRepository; use crate::repository::MediaRepository;
use crate::repository::HybridRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?; 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 // The pump needs a target_dir; use the same storage root the other download
@@ -683,12 +681,13 @@ pub async fn resume_queued_downloads(
async move { async move {
if media_type == "video" { if media_type == "video" {
Some( Some(
<HybridRepository as MediaRepository>::get_video_download_url( crate::repository::resolve_video_download_url(
repo.as_ref(), repo.as_ref(),
&item_id, &item_id,
&quality, &quality,
None, None,
), )
.await,
) )
} else { } else {
match repo.get_audio_stream_url(&item_id).await { match repo.get_audio_stream_url(&item_id).await {
+94 -21
View File
@@ -845,7 +845,19 @@ pub async fn get_downloads(
Ok(DownloadsResponse { downloads, stats }) 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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn pause_download( pub async fn pause_download(
@@ -858,19 +870,34 @@ pub async fn pause_download(
}; };
let query = Query::with_params( 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)], vec![QueryParam::Int64(download_id)],
); );
db_service.execute(query).await.map_err(|e| e.to_string())?; 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(()) 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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn resume_download( pub async fn resume_download(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
download_id: i64, download_id: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let db_service = { let db_service = {
@@ -879,11 +906,22 @@ pub async fn resume_download(
}; };
let query = Query::with_params( 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)], vec![QueryParam::Int64(download_id)],
); );
db_service.execute(query).await.map_err(|e| e.to_string())?; 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(()) Ok(())
} }
@@ -923,6 +961,13 @@ pub async fn cancel_download(
.await .await
.map_err(|e| e.to_string())?; .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) // Unregister from download manager (in case it was active)
{ {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?; let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
@@ -934,10 +979,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 { if let Some(path) = file_path {
let partial_path = format!("{}.part", path); let target = std::path::PathBuf::from(&path);
let _ = std::fs::remove_file(&partial_path); // Ignore errors let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
} }
Ok(()) Ok(())
@@ -1244,8 +1291,6 @@ pub async fn enqueue_video_downloads(
download_ids: Vec<i64>, download_ids: Vec<i64>,
target_dir: String, target_dir: String,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?; let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = { let db_service = {
@@ -1270,10 +1315,12 @@ pub async fn enqueue_video_downloads(
} }
}; };
// Build the transcode URL (pure URL builder, no server round-trip). // Build the download URL, resolving the source's audio codec first so a
let stream_url = repo // track this device cannot decode is re-encoded on the way down rather
.as_ref() // than saved as a silent file (DR-167).
.get_video_download_url(&item_id, &quality, None); let stream_url =
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
.await;
let update_query = Query::with_params( let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
@@ -1530,7 +1577,11 @@ fn spawn_download_worker(
let _ = progress_app.emit("download-event", event); 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. // Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() { if let Ok(mut active) = active_downloads.lock() {
@@ -1601,6 +1652,17 @@ fn spawn_download_worker(
Err(e) => error!(" Completed event emit failed: {:?}", e), 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) => { Err(e) => {
error!("Download failed: {:?}", e); error!("Download failed: {:?}", e);
@@ -1848,17 +1910,26 @@ pub async fn clear_stale_downloads(
Arc::new(database.service()) 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( 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())], vec![QueryParam::String(user_id.clone())],
); );
let file_paths: Vec<String> = db_service let stale: Vec<(i64, String)> = db_service
.query_many(file_query, |row| row.get(0)) .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await .await
.map_err(|e| e.to_string())?; .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) // Delete all pending, paused, and failed downloads (but keep completed ones)
let delete_query = Query::with_params( let delete_query = Query::with_params(
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
@@ -1870,10 +1941,12 @@ pub async fn clear_stale_downloads(
.await .await
.map_err(|e| e.to_string())?; .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 { for path in file_paths {
let _ = std::fs::remove_file(&path); let target = std::path::PathBuf::from(&path);
let _ = std::fs::remove_file(format!("{}.part", path)); let _ = std::fs::remove_file(&target);
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
} }
Ok(deleted_count as i64) Ok(deleted_count as i64)
+148 -15
View File
@@ -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 /// Helper function to create MediaItem from video request
/// ///
/// PlayItemRequest is now video-only, so we create a video MediaItem. /// PlayItemRequest is now video-only, so we create a video MediaItem.
@@ -770,22 +793,23 @@ pub async fn player_enter_background_audio(
pub async fn player_exit_background_audio( pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> { ) -> 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; 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 // Read the position BEFORE clearing either base. The position tick applies the
// episode advance, whose stream already starts at its own zero. // base natively, so a tick landing between "base cleared" and "position read"
let base = controller.exit_background_audio(); // would hand back a relative position — the whole bug, reintroduced at the one
// Capture position into a `let` BEFORE stop() — never hold work across a lock // moment it matters most. Capturing into a `let` before stop() is also the
// re-entrant call (deadlock discipline, CLAUDE.md). // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
let relative = controller.position(); // (DR-159)
let absolute = controller.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())?; controller.stop().map_err(|e| e.to_string())?;
let absolute = base + relative;
info!( info!(
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s", "player_exit_background_audio: resuming the video at {:.1}s",
base, relative, absolute absolute
); );
Ok(absolute) Ok(absolute)
} }
@@ -1207,9 +1231,12 @@ pub async fn player_seek(
let position_ticks = (position * 10_000_000.0) as i64; let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?; client.session_seek(session_id, position_ticks).await?;
} else { } 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; 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; let controller = player.0.lock().await;
@@ -1443,6 +1470,112 @@ 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(),
current_position,
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 URL already carries `StartTimeTicks`, so the reloaded stream begins at
// the current position rather than at zero.
{
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())?;
}
Ok(StreamQualityResponse::Native { position })
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_set_audio_track( pub async fn player_set_audio_track(
+131 -3
View File
@@ -1,12 +1,29 @@
//! Audio and video playback settings commands. //! 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 super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::commands::storage::DatabaseWrapper;
use crate::player::AutoplaySettings; 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] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
pub async fn player_set_video_settings( pub async fn player_set_video_settings(
video_settings: State<'_, VideoSettingsWrapper>, video_settings: State<'_, VideoSettingsWrapper>,
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
settings: VideoSettings, settings: VideoSettings,
) -> Result<VideoSettings, String> { ) -> Result<VideoSettings, String> {
let validated = settings.with_countdown_clamped(); let validated = settings.with_countdown_clamped();
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
*current = validated.clone(); *current = validated.clone();
} // Drop MutexGuard before await } // 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 // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.set_autoplay_settings(AutoplaySettings { controller.set_autoplay_settings(AutoplaySettings {
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
Ok(validated) 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] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_get_video_settings( pub async fn player_get_video_settings(
+11 -4
View File
@@ -804,7 +804,7 @@ pub fn repository_get_subtitle_url(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
#[allow(dead_code)] #[allow(dead_code)]
pub fn repository_get_video_download_url( pub async fn repository_get_video_download_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
@@ -812,9 +812,16 @@ pub fn repository_get_video_download_url(
media_source_id: Option<String>, media_source_id: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo // Async because the audio-codec policy has to know what the source's audio
.as_ref() // is before it can decide whether the file may be copied verbatim (DR-171).
.get_video_download_url(&item_id, &quality, media_source_id.as_deref())) // 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 /// Mark an item as favorite
+80
View File
@@ -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 /// Get playback progress for an item
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
+59
View File
@@ -52,6 +52,12 @@ pub enum QueuedOp {
MarkPlayed { MarkPlayed {
item_id: String, 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` /// 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. /// (DR-120). Supported so a row written by an older build still lands.
Favorite { Favorite {
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
position_ticks: ticks(), position_ticks: ticks(),
}), }),
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }), "mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
"mark_favorite" => Ok(QueuedOp::Favorite { "mark_favorite" => Ok(QueuedOp::Favorite {
item_id, item_id,
is_favorite: true, is_favorite: true,
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
position_ticks, position_ticks,
} => self.report_playback_stopped(item_id, *position_ticks).await, } => self.report_playback_stopped(item_id, *position_ticks).await,
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await, QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
QueuedOp::Favorite { QueuedOp::Favorite {
item_id, item_id,
is_favorite, is_favorite,
@@ -1031,4 +1039,55 @@ mod tests {
assert!(parse_queued_op("mark_played", None, None).is_err()); assert!(parse_queued_op("mark_played", None, None).is_err());
assert!(parse_queued_op("teleport", Some("ep1"), 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);
}
} }
+1
View File
@@ -9,6 +9,7 @@
pub mod cache; pub mod cache;
pub mod events; pub mod events;
pub mod network; pub mod network;
pub mod stop;
pub mod worker; pub mod worker;
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
+150
View File
@@ -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);
}
}
+174 -15
View File
@@ -1,6 +1,7 @@
//! Download worker for HTTP streaming with progress tracking and retry logic //! Download worker for HTTP streaming with progress tracking and retry logic
use log::warn; use log::warn;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use futures_util::StreamExt; 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>( pub async fn download<F>(
&self, &self,
task: &DownloadTask, task: &DownloadTask,
stop: &AtomicBool,
on_progress: F, on_progress: F,
) -> Result<DownloadResult, DownloadError> ) -> Result<DownloadResult, DownloadError>
where where
@@ -43,7 +52,10 @@ impl DownloadWorker {
let mut retries = 0; let mut retries = 0;
loop { 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), Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => { Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1; retries += 1;
@@ -63,6 +75,7 @@ impl DownloadWorker {
async fn try_download<F>( async fn try_download<F>(
&self, &self,
task: &DownloadTask, task: &DownloadTask,
stop: &AtomicBool,
on_progress: &F, on_progress: &F,
) -> Result<DownloadResult, DownloadError> ) -> Result<DownloadResult, DownloadError>
where where
@@ -76,7 +89,7 @@ impl DownloadWorker {
} }
// Check for partial download // 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() { let existing_bytes = if temp_path.exists() {
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0) fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
} else { } else {
@@ -100,22 +113,32 @@ impl DownloadWorker {
return Err(DownloadError::Http(response.status().as_u16())); 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 let _total_bytes = response
.headers() .headers()
.get(reqwest::header::CONTENT_LENGTH) .get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok()) .and_then(|v| v.parse::<u64>().ok())
.map(|len| { .map(|len| len + resume_from);
if existing_bytes > 0 {
len + existing_bytes
} else {
len
}
});
// Open file for appending // Append only when resuming a range the server agreed to; otherwise
let mut file = if existing_bytes > 0 { // 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 fs::OpenOptions::new().append(true).open(&temp_path).await
} else { } else {
fs::File::create(&temp_path).await fs::File::create(&temp_path).await
@@ -123,11 +146,22 @@ impl DownloadWorker {
.map_err(|e| DownloadError::FileSystem(e.to_string()))?; .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Stream download with progress tracking // Stream download with progress tracking
let mut downloaded = existing_bytes; let mut downloaded = resume_from;
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut last_progress_emit = std::time::Instant::now(); let mut last_progress_emit = std::time::Instant::now();
while let Some(chunk) = stream.next().await { 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()))?; let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
file.write_all(&chunk) file.write_all(&chunk)
@@ -138,7 +172,7 @@ impl DownloadWorker {
// Emit progress every 500ms or every MB // Emit progress every 500ms or every MB
if last_progress_emit.elapsed() > Duration::from_millis(500) 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(); last_progress_emit = std::time::Instant::now();
on_progress(downloaded, _total_bytes); 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 /// Result of a successful download
#[derive(Debug)] #[derive(Debug)]
pub struct DownloadResult { pub struct DownloadResult {
@@ -179,6 +263,10 @@ pub enum DownloadError {
Network(String), Network(String),
Http(u16), Http(u16),
FileSystem(String), 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 { impl DownloadError {
@@ -188,8 +276,16 @@ impl DownloadError {
DownloadError::Network(_) => true, DownloadError::Network(_) => true,
DownloadError::Http(status) => *status >= 500, // Retry server errors DownloadError::Http(status) => *status >= 500, // Retry server errors
DownloadError::FileSystem(_) => false, 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 { 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::Network(msg) => write!(f, "Network error: {}", msg),
DownloadError::Http(status) => write!(f, "HTTP error {}", status), DownloadError::Http(status) => write!(f, "HTTP error {}", status),
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg), 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 { mod tests {
use super::*; 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] #[test]
fn test_exponential_backoff() { fn test_exponential_backoff() {
assert_eq!( assert_eq!(
@@ -231,5 +386,9 @@ mod tests {
assert!(DownloadError::Http(503).is_retryable()); assert!(DownloadError::Http(503).is_retryable());
assert!(!DownloadError::Http(404).is_retryable()); assert!(!DownloadError::Http(404).is_retryable());
assert!(!DownloadError::FileSystem("disk full".to_string()).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
View File
@@ -130,6 +130,7 @@ use commands::{
player_get_session, player_get_session,
player_get_sleep_timer, player_get_sleep_timer,
player_get_status, player_get_status,
player_get_streaming_qualities,
player_get_video_settings, player_get_video_settings,
// Preload commands // Preload commands
player_local_media_path, player_local_media_path,
@@ -159,6 +160,7 @@ use commands::{
player_set_cache_config, player_set_cache_config,
// Sleep timer and autoplay commands // Sleep timer and autoplay commands
player_set_sleep_timer, player_set_sleep_timer,
player_set_stream_quality,
player_set_subtitle_track, player_set_subtitle_track,
player_set_video_settings, player_set_video_settings,
player_set_volume, player_set_volume,
@@ -265,6 +267,7 @@ use commands::{
storage_save_user, storage_save_user,
storage_search_items, storage_search_items,
storage_set_active_user, storage_set_active_user,
storage_set_watched,
storage_toggle_favorite, storage_toggle_favorite,
storage_update_playback_context, storage_update_playback_context,
storage_update_playback_progress, storage_update_playback_progress,
@@ -424,6 +427,28 @@ impl MediaSessionHandler {
/// Drive the local player for a transport command. /// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) { 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 // Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock(); let controller = self.player.blocking_lock();
@@ -433,13 +458,6 @@ impl MediaSessionHandler {
"next" => controller.next(), "next" => controller.next(),
"previous" => controller.previous(), "previous" => controller.previous(),
"stop" => controller.stop(), "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); warn!("[MediaSession] Unknown command: {}", command);
Ok(()) Ok(())
@@ -695,6 +713,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_get_eq_presets, player_get_eq_presets,
player_set_video_settings, player_set_video_settings,
player_get_video_settings, player_get_video_settings,
player_get_streaming_qualities,
player_set_stream_quality,
// Sleep timer and autoplay commands // Sleep timer and autoplay commands
player_set_sleep_timer, player_set_sleep_timer,
player_cancel_sleep_timer, player_cancel_sleep_timer,
@@ -789,6 +809,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
storage_update_playback_progress, storage_update_playback_progress,
storage_update_playback_context, storage_update_playback_context,
storage_mark_played, storage_mark_played,
storage_set_watched,
storage_get_playback_progress, storage_get_playback_progress,
storage_mark_synced, storage_mark_synced,
storage_toggle_favorite, storage_toggle_favorite,
@@ -1225,6 +1246,20 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default())); let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings); 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 // Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache..."); info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+6 -4
View File
@@ -1474,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(()) 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. /// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> { pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; 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( env.call_method(
&service_obj, &service_obj,
"setPositionOffset", "setHandoffBase",
"(D)V", "(D)V",
&[JValue::Double(offset_seconds)], &[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(()) Ok(())
} }
+137 -12
View File
@@ -754,12 +754,50 @@ impl PlayerController {
} }
} }
/// Seek to a position in seconds /// Seek to a position in seconds, **on the player's own timeline**.
///
/// During a background-audio handoff that timeline is relative to the handoff
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
/// timeline and is what every caller outside the player itself means.
pub fn seek(&self, position: f64) -> Result<(), PlayerError> { pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.seek(position) backend.seek(position)
} }
/// Seek to an **absolute** position on the item's own timeline.
///
/// This is the boundary every outside seek comes through — the UI, the
/// lockscreen scrubber, a headset gesture — because all of them are looking
/// at the whole episode, not at whatever fragment of it the player happens to
/// be streaming.
///
/// Outside a background-audio handoff the two timelines are the same and this
/// is an ordinary seek. Inside one they differ by the handoff base, and the
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
/// clamped seek lands at stream zero, which is the handoff point. That is the
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
/// re-opening the URL at the new position, which is exactly what the
/// truncation recovery already does, so it shares `resume_stream_at`.
///
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
let rebuild = self.is_background_audio_active() && {
let queue = self.queue.lock_safe();
queue
.current()
.map(Self::is_audio_only_video)
.unwrap_or(false)
};
if rebuild {
return self.resume_stream_at(position.max(0.0)).await;
}
self.seek(position).map_err(|e| e.to_string())
}
/// Set volume (0.0 - 1.0) /// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> { pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock_safe().set_volume(volume) self.backend.lock_safe().set_volume(volume)
@@ -1384,8 +1422,10 @@ impl PlayerController {
return None; return None;
} }
let base = *self.background_audio_base.lock_safe(); // Already absolute: the Android position tick shifts by the handoff base
let absolute = (base + self.position()).max(0.0); // before anything sees the value, so adding it again here would
// double-count it. (DR-159)
let absolute = self.position().max(0.0);
match self.stream_resume.lock_safe().allow_attempt(absolute) { match self.stream_resume.lock_safe().allow_attempt(absolute) {
Some(attempt) => Some((absolute, attempt)), Some(attempt) => Some((absolute, attempt)),
@@ -1425,8 +1465,8 @@ impl PlayerController {
} }
current.duration current.duration
}; };
let base = *self.background_audio_base.lock_safe(); // Already absolute — see claim_stream_resume. (DR-159)
let absolute = (base + self.position()).max(0.0); let absolute = self.position().max(0.0);
// Only spend a resume attempt once the runtime says this really was cut // Only spend a resume attempt once the runtime says this really was cut
// short — a genuine end must stay a genuine end. // short — a genuine end must stay a genuine end.
@@ -3229,7 +3269,13 @@ mod tests {
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String { fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
unimplemented!() unimplemented!()
} }
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String { fn get_video_download_url(
&self,
_: &str,
_: &str,
_: Option<&str>,
_: Option<&str>,
) -> String {
unimplemented!() unimplemented!()
} }
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> { async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
@@ -3604,10 +3650,88 @@ mod tests {
} }
} }
/// The handoff stream's timeline starts at the handoff position, so the /// A seek arriving during a background-audio handoff is **absolute** — the
/// player reports a *relative* position. The runtime it is compared against /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
/// is absolute — the base has to be added back, or every handoff looks like a /// 25:00 of the episode, not 25:00 into the handoff stream.
/// truncation. ///
/// The handoff stream cannot be seeked at all (a chunked, length-less
/// transcode), so honouring it means re-opening the URL at the new position,
/// exactly as the truncation recovery does. Passing the number through to
/// ExoPlayer instead — which is what used to happen — asked a stream that
/// cannot seek to jump past its own end, and a clamped seek lands at stream
/// zero: the handoff point.
///
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
#[tokio::test]
async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Handed off 20 minutes in, so the stream's zero is 1200s.
controller.enter_background_audio(1200.0);
// The viewer scrubs the lockscreen to 25:00 absolute.
controller.seek_absolute(1490.0).await.unwrap();
let url = {
let queue = controller.queue();
let queue = queue.lock_safe();
match &queue.current().unwrap().source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
other => panic!("expected a remote source, got {:?}", other),
}
};
assert!(
url.contains(&format!(
"StartTimeTicks={}",
(1490.0 * 10_000_000.0) as i64
)),
"the stream must be re-opened at the absolute position; got {}",
url
);
assert_eq!(
*controller.background_audio_base.lock_safe(),
1490.0,
"the re-opened stream's zero is the position it was opened at, or \
every later reading is off by the difference"
);
}
/// Outside a handoff there is no base and nothing to re-open: an absolute
/// seek is just a seek, and must not be turned into a stream rebuild.
///
/// TRACES: UR-005 | DR-159 | UT-155
#[tokio::test]
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.seek_absolute(300.0).await.unwrap();
assert_eq!(controller.position(), 300.0);
assert_eq!(
*controller.background_audio_base.lock_safe(),
0.0,
"an ordinary seek must not invent a handoff base"
);
}
/// The truncation check compares the position against the item's runtime, so
/// both must be on the same timeline.
///
/// They now are by construction: the Android position tick shifts by the
/// handoff base before anything sees the value, so what the player reports is
/// already a position on the episode. The base is therefore *not* added here —
/// doing so would double-count it and make the last minute of a handoff look
/// like a truncation. What the mock backend holds is what the real one would
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
#[tokio::test] #[tokio::test]
async fn test_truncated_check_uses_the_absolute_position() { async fn test_truncated_check_uses_the_absolute_position() {
let controller = PlayerController::default(); let controller = PlayerController::default();
@@ -3616,9 +3740,10 @@ mod tests {
controller controller
.play_queue(vec![audio_only_episode(1500.0)], 0) .play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap(); .unwrap();
// Handed off at 24:00; the stream then played its last 56 seconds out. // Handed off at 24:00; the stream then played its last 56 seconds out, so
// the player reports 24:56 of the episode.
controller.set_background_audio_base(1440.0); controller.set_background_audio_base(1440.0);
controller.seek(56.0).unwrap(); controller.seek(1496.0).unwrap();
controller.take_end_reason(); controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap(); let decision = controller.on_playback_ended().await.unwrap();
+54 -15
View File
@@ -65,10 +65,18 @@ const FALLBACK_AUDIO_CODEC: &str = "aac";
/// play the **video** can decode. /// play the **video** can decode.
/// ///
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer — /// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
/// but video does not play through ExoPlayer. Both Android and Linux render it /// but the webview `<video>` element may be what renders the video, and
/// in a webview `<video>` element, and Chromium/WebKit decode a much smaller set /// Chromium/WebKit decode a much smaller set than the platform does. Advertising
/// than the platform does. Advertising the raw list makes Jellyfin direct-play a /// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
/// track the webview cannot decode, and the user gets picture with no sound. /// 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 /// 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 /// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
@@ -120,22 +128,34 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
/// delegate this decision; it knows what its own renderer can decode and must /// delegate this decision; it knows what its own renderer can decode and must
/// apply that itself. /// apply that itself.
/// ///
/// The track that matters is the one the server will actually serve: the /// The track that matters is the one the server will actually serve (see
/// default, or the first when none is marked. An unknown codec is left alone — /// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
/// forcing a transcode on a guess would burn server CPU for files that play. /// on a guess would burn server CPU for files that play.
/// ///
/// TRACES: UR-004 | DR-149 | UT-148 /// TRACES: UR-004 | DR-149 | UT-148
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool { 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() .iter()
.find(|(_, is_default)| *is_default) .find(|(_, is_default)| *is_default)
.or_else(|| streams.first()); .or_else(|| streams.first())
.and_then(|(codec, _)| *codec)
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,
}
} }
#[cfg(test)] #[cfg(test)]
@@ -193,6 +213,25 @@ mod tests {
assert!(!audio_forces_transcode(&[(None, true)])); 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] #[test]
fn a_dolby_device_does_not_advertise_dolby_for_video() { fn a_dolby_device_does_not_advertise_dolby_for_video() {
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
+4 -1
View File
@@ -872,10 +872,11 @@ impl MediaRepository for HybridRepository {
item_id: &str, item_id: &str,
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
source_audio_codec: Option<&str>,
) -> String { ) -> String {
// Always use online URL for downloads // Always use online URL for downloads
self.online 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> { async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
@@ -1299,6 +1300,7 @@ mod tests {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
unimplemented!() unimplemented!()
} }
@@ -1573,6 +1575,7 @@ mod tests {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
unimplemented!() unimplemented!()
} }
+53 -2
View File
@@ -197,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
format: &str, format: &str,
) -> String; ) -> String;
/// Get video download URL (synchronous - just constructs URL) /// Build the URL a video download is fetched from. Synchronous — it only
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte) /// 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)] #[allow(dead_code)]
fn get_video_download_url( fn get_video_download_url(
&self, &self,
item_id: &str, item_id: &str,
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
source_audio_codec: Option<&str>,
) -> String; ) -> String;
/// Mark item as favorite /// Mark item as favorite
@@ -323,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
new_index: u32, new_index: u32,
) -> Result<(), RepoError>; ) -> 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())
}
+108 -7
View File
@@ -828,6 +828,32 @@ impl OfflineRepository {
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it /// the synced-but-not-downloaded catalog branch deliberately excluded, so it
/// is authoritative regardless of the process-wide catalog-browse flag. /// 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 /// TRACES: UR-055 | DR-082, DR-083
const DOWNLOADED_ITEMS_CTE: &'static str = " const DOWNLOADED_ITEMS_CTE: &'static str = "
WITH downloaded_items AS ( WITH downloaded_items AS (
@@ -908,6 +934,7 @@ impl OfflineRepository {
EXISTS ( EXISTS (
SELECT 1 FROM libraries l SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id WHERE l.id = ? AND l.server_id = i.server_id
AND {membership}
) )
-- Top-level only: hide leaves whose container is downloaded. -- Top-level only: hide leaves whose container is downloaded.
AND NOT EXISTS ( AND NOT EXISTS (
@@ -922,6 +949,7 @@ impl OfflineRepository {
ORDER BY i.sort_name ASC, i.name ASC ORDER BY i.sort_name ASC, i.name ASC
LIMIT {limit} OFFSET {start_index}", LIMIT {limit} OFFSET {start_index}",
cte = Self::DOWNLOADED_ITEMS_CTE, cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
); );
let query = Query::with_params( let query = Query::with_params(
@@ -966,7 +994,7 @@ impl OfflineRepository {
// We match a library by collection_type ↔ item_type instead: any // We match a library by collection_type ↔ item_type instead: any
// completed download of a given media kind qualifies that library. // completed download of a given media kind qualifies that library.
let query = Query::with_params( let query = Query::with_params(
&format!( format!(
"{cte} "{cte}
SELECT l.id, l.name, l.collection_type, l.image_tag SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l FROM libraries l
@@ -975,15 +1003,11 @@ impl OfflineRepository {
SELECT 1 FROM items i SELECT 1 FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = l.server_id WHERE i.server_id = l.server_id
AND ( AND {membership}
(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'))
)
) )
ORDER BY l.sort_order ASC, l.name ASC", ORDER BY l.sort_order ASC, l.name ASC",
cte = Self::DOWNLOADED_ITEMS_CTE, cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
), ),
vec![QueryParam::String(self.server_id.clone())], vec![QueryParam::String(self.server_id.clone())],
); );
@@ -1959,6 +1983,7 @@ impl MediaRepository for OfflineRepository {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
// Cannot download while offline // Cannot download while offline
String::new() String::new()
@@ -3720,6 +3745,82 @@ mod tests {
assert_eq!(track_ids, vec!["track-1", "track-2"]); 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 /// Regression: a downloaded TV library lists the Series, not its Seasons or
/// Episodes — the same "individual songs" bug seen for music, for TV. The /// Episodes — the same "individual songs" bug seen for music, for TV. The
/// season and episode are still reachable by drilling into the series. /// season and episode are still reachable by drilling into the series.
+313 -21
View File
@@ -3,11 +3,46 @@
use async_trait::async_trait; use async_trait::async_trait;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::{Arc, RwLock};
use super::{types::*, MediaRepository}; use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter; use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient; 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()
}
/// A single actor returned by the JRay plugin's "context at time t" endpoint. /// A single actor returned by the JRay plugin's "context at time t" endpoint.
/// ///
@@ -381,7 +416,12 @@ impl OnlineRepository {
/// which manifests as playback never starting. `StartTimeTicks` makes the /// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position. /// server begin the transcode at the requested position.
/// ///
/// TRACES: UR-004 | DR-140 | UT-130 /// 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 | UT-130, UT-156
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
&self, &self,
item_id: &str, item_id: &str,
@@ -392,6 +432,13 @@ impl OnlineRepository {
// Convert seconds to ticks (10,000,000 ticks per second) // 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 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);
// Build an HLS transcode URL. VideoCodec lists h264 first so the server // Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode. // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![ let mut params = vec![
@@ -399,9 +446,9 @@ impl OnlineRepository {
("DeviceId", "jellytau-tauri".to_string()), ("DeviceId", "jellytau-tauri".to_string()),
("VideoCodec", "h264".to_string()), ("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()), ("AudioCodec", "aac".to_string()),
("MaxStreamingBitrate", "20000000".to_string()), ("MaxStreamingBitrate", max_bitrate.to_string()),
("VideoBitrate", "18000000".to_string()), ("VideoBitrate", video_bitrate.to_string()),
("AudioBitrate", "384000".to_string()), ("AudioBitrate", quality.audio_bitrate().to_string()),
( (
"TranscodingMaxAudioChannels", "TranscodingMaxAudioChannels",
super::device_profile::max_audio_channels().to_string(), super::device_profile::max_audio_channels().to_string(),
@@ -411,6 +458,12 @@ impl OnlineRepository {
("TranscodingProtocol", "hls".to_string()), ("TranscodingProtocol", "hls".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 // 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 // `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 // index 0 is the video stream on virtually every file — defaulting to 0
@@ -481,7 +534,14 @@ impl OnlineRepository {
("AudioCodec", "mp3".to_string()), ("AudioCodec", "mp3".to_string()),
("TranscodingContainer", "mp3".to_string()), ("TranscodingContainer", "mp3".to_string()),
("TranscodingProtocol", "http".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 // Carry the track over only if one was actually selected — index 0 is the
@@ -1406,11 +1466,29 @@ impl MediaRepository for OnlineRepository {
let max_audio_channels = super::device_profile::max_audio_channels().to_string(); let max_audio_channels = super::device_profile::max_audio_channels().to_string();
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels); 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 // Create device profile with detected hardware capabilities
let device_profile = DeviceProfile { let device_profile = DeviceProfile {
name: "JellyTau Native Player".to_string(), name: "JellyTau Native Player".to_string(),
max_streaming_bitrate: 999_999_999, max_streaming_bitrate: negotiated_bitrate,
max_static_bitrate: 999_999_999, max_static_bitrate: negotiated_bitrate,
max_audio_channels: max_audio_channels.clone(), max_audio_channels: max_audio_channels.clone(),
direct_play_profiles: vec![ direct_play_profiles: vec![
DirectPlayProfile { DirectPlayProfile {
@@ -1470,7 +1548,8 @@ impl MediaRepository for OnlineRepository {
start_time_ticks: 0, start_time_ticks: 0,
is_playback: true, is_playback: true,
auto_open_live_stream: 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 device_profile: Some(device_profile), // Now sending profile with detected codecs
}; };
@@ -1633,7 +1712,10 @@ impl MediaRepository for OnlineRepository {
user_id: self.user_id.clone(), user_id: self.user_id.clone(),
auto_open_live_stream: true, auto_open_live_stream: true,
is_playback: 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?; let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
@@ -1794,6 +1876,7 @@ impl MediaRepository for OnlineRepository {
item_id: &str, item_id: &str,
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
source_audio_codec: Option<&str>,
) -> String { ) -> String {
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
// available (returns 404 on many server configs), which silently broke // available (returns 404 on many server configs), which silently broke
@@ -1844,10 +1927,39 @@ impl MediaRepository for OnlineRepository {
params.push("audioCodec=aac".to_string()); params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string()); params.push("allowVideoStreamCopy=false".to_string());
} }
// "original" (and any unknown value) → direct, resumable copy. // "original" (and any unknown value) → direct, resumable copy
_ => { // unless the audio in that copy is undecodable where the file will
params.push("Static=true".to_string()); // 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 // Add media source ID if provided
@@ -2233,6 +2345,7 @@ impl MediaRepository for OnlineRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::utils::lock::MutexSafe;
use std::sync::Arc; use std::sync::Arc;
fn create_test_repository() -> OnlineRepository { fn create_test_repository() -> OnlineRepository {
@@ -2379,11 +2492,107 @@ 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_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", None, 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, 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}");
}
#[tokio::test] #[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() { async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with // Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing // StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs. // for HEVC sources). See get_video_stream_url docs.
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository(); let repo = create_test_repository();
let url = repo let url = repo
@@ -2405,6 +2614,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() { async fn test_get_video_stream_url_omits_position_when_absent() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository(); let repo = create_test_repository();
let url = repo let url = repo
@@ -2557,7 +2767,7 @@ mod tests {
#[test] #[test]
fn test_video_download_url_uses_stream_not_download_endpoint() { fn test_video_download_url_uses_stream_not_download_endpoint() {
let repo = create_test_repository(); 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). // Must NOT use the /download endpoint (404 on real servers).
assert!( assert!(
@@ -2575,7 +2785,7 @@ mod tests {
#[test] #[test]
fn test_video_download_url_original_is_static_direct_copy() { fn test_video_download_url_original_is_static_direct_copy() {
let repo = create_test_repository(); 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), // "original" must request a direct static copy (byte-range resumable),
// with no transcode params. // with no transcode params.
@@ -2595,7 +2805,7 @@ mod tests {
let repo = create_test_repository(); let repo = create_test_repository();
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] { 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!( assert!(
url.contains("/Videos/item123/stream.mp4"), url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}" "{quality} must use stream.mp4: {url}"
@@ -2628,7 +2838,7 @@ mod tests {
let repo = create_test_repository(); let repo = create_test_repository();
for quality in ["high", "medium", "low"] { 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!( assert!(
url.contains("videoBitRate="), url.contains("videoBitRate="),
@@ -2662,7 +2872,7 @@ mod tests {
let repo = create_test_repository(); let repo = create_test_repository();
for quality in ["high", "medium", "low"] { 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!( assert!(
url.contains("allowVideoStreamCopy=false"), url.contains("allowVideoStreamCopy=false"),
"{quality} must forbid video stream copy: {url}" "{quality} must forbid video stream copy: {url}"
@@ -2670,17 +2880,99 @@ mod tests {
} }
// "original" is a deliberate direct copy — it must NOT disable copying. // "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!( assert!(
!original.contains("allowVideoStreamCopy=false"), !original.contains("allowVideoStreamCopy=false"),
"original must remain a direct copy: {original}" "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] #[test]
fn test_video_download_url_passes_media_source_id() { fn test_video_download_url_passes_media_source_id() {
let repo = create_test_repository(); 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}"); assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
} }
+228
View File
@@ -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 /// Video playback settings
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -159,6 +291,14 @@ pub struct VideoSettings {
/// Maximum number of episodes to auto-play consecutively (0 = unlimited) /// Maximum number of episodes to auto-play consecutively (0 = unlimited)
#[serde(default)] #[serde(default)]
pub auto_play_max_episodes: u32, 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 { impl Default for VideoSettings {
@@ -167,6 +307,7 @@ impl Default for VideoSettings {
auto_play_next_episode: true, auto_play_next_episode: true,
auto_play_countdown_seconds: 10, auto_play_countdown_seconds: 10,
auto_play_max_episodes: 0, auto_play_max_episodes: 0,
streaming_quality: StreamingQuality::Original,
} }
} }
} }
@@ -427,12 +568,14 @@ mod tests {
auto_play_next_episode: false, auto_play_next_episode: false,
auto_play_countdown_seconds: 15, auto_play_countdown_seconds: 15,
auto_play_max_episodes: 5, auto_play_max_episodes: 5,
streaming_quality: StreamingQuality::Mbps4,
}; };
let json = serde_json::to_string(&settings).unwrap(); let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayNextEpisode\":false"));
assert!(json.contains("\"autoPlayCountdownSeconds\":15")); assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
assert!(json.contains("\"autoPlayMaxEpisodes\":5")); assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
assert!(json.contains("\"streamingQuality\":\"mbps4\""));
let parsed: VideoSettings = serde_json::from_str(&json).unwrap(); let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
assert!(!parsed.auto_play_next_episode); assert!(!parsed.auto_play_next_episode);
@@ -448,5 +591,90 @@ mod tests {
assert!(parsed.auto_play_next_episode); assert!(parsed.auto_play_next_episode);
assert_eq!(parsed.auto_play_countdown_seconds, 10); assert_eq!(parsed.auto_play_countdown_seconds, 10);
assert_eq!(parsed.auto_play_max_episodes, 0); 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 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau", "productName": "jellytau",
"version": "0.4.8", "version": "0.5.3",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+130 -3
View File
@@ -197,6 +197,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
async playerGetVideoSettings() : Promise<VideoSettings> { async playerGetVideoSettings() : Promise<VideoSettings> {
return await TAURI_INVOKE("player_get_video_settings"); 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 * Set sleep timer mode
*/ */
@@ -746,6 +780,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> { async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("storage_mark_played", { userId, itemId }); 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 * Get playback progress for an item
*/ */
@@ -809,13 +870,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
return await TAURI_INVOKE("get_downloads", { userId, statusFilter }); 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> { async pauseDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("pause_download", { downloadId }); 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> { async resumeDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("resume_download", { downloadId }); return await TAURI_INVOKE("resume_download", { downloadId });
@@ -2830,6 +2910,44 @@ export type StreamKind = "audio" | "video" | "subtitle" |
* Any stream kind we do not model explicitly (e.g. embedded image, data). * Any stream kind we do not model explicitly (e.g. embedded image, data).
*/ */
"other" "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 * Represents a subtitle track
* *
@@ -2951,7 +3069,16 @@ autoPlayCountdownSeconds: number;
/** /**
* Maximum number of episodes to auto-play consecutively (0 = unlimited) * 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 * Volume normalization levels matching Spotify's presets
*/ */
@@ -13,6 +13,7 @@
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte"; import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import VideoDownloadButton from "./VideoDownloadButton.svelte"; import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CastSection from "./CastSection.svelte"; import CastSection from "./CastSection.svelte";
import GenreTags from "./GenreTags.svelte"; import GenreTags from "./GenreTags.svelte";
import RelatedItemsSection from "./RelatedItemsSection.svelte"; import RelatedItemsSection from "./RelatedItemsSection.svelte";
@@ -250,6 +251,12 @@
episodeNumber={episode.indexNumber ?? undefined} episodeNumber={episode.indexNumber ?? undefined}
size="lg" size="lg"
/> />
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="lg"
/>
<FavoriteButton <FavoriteButton
itemId={episode.id} itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)} isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
+20 -1
View File
@@ -5,6 +5,7 @@
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte"; import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
@@ -17,9 +18,17 @@
*/ */
current?: boolean; current?: boolean;
onclick?: () => void; 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; let buttonRef: HTMLButtonElement | null = null;
@@ -177,6 +186,16 @@
{duration} {duration}
</span> </span>
{/if} {/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 --> <!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none"> <div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton <VideoDownloadButton
@@ -4,6 +4,7 @@
import EpisodeRow from "./EpisodeRow.svelte"; import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte"; import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte"; import ClearHistoryButton from "./ClearHistoryButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation"; import { seasonAnchorId } from "./seriesNavigation";
@@ -65,18 +66,26 @@
/> />
</div> </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-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. --> <!-- The whole title block toggles the season open/closed. -->
<button <button
type="button" type="button"
onclick={onToggle} onclick={onToggle}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="{anchor}-episodes" 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 <svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200 class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}" 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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg> </svg>
<span class="truncate">{seasonName}</span> <span class="truncate min-w-0">{seasonName}</span>
{#if holdsCurrentEpisode} {#if holdsCurrentEpisode}
<span <span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold" class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
@@ -120,8 +129,9 @@
{/if} {/if}
</button> </button>
<!-- Per-season actions --> <!-- Per-season actions. `self-start` keeps them level with the title on
<div class="flex-shrink-0 flex items-center gap-2"> wide rows; on a stacked phone layout they sit under it. -->
<div class="flex-shrink-0 flex items-center gap-2 self-start">
<SeasonDownloadButton <SeasonDownloadButton
seasonId={season.id} seasonId={season.id}
seriesName={season.seriesName || ""} seriesName={season.seriesName || ""}
@@ -130,6 +140,13 @@
{episodeCount} {episodeCount}
size="sm" size="sm"
/> />
<WatchedToggleButton
itemId={season.id}
watched={watchedCount === episodeCount && episodeCount > 0}
scope="season"
size="sm"
onChanged={onHistoryCleared}
/>
<ClearHistoryButton <ClearHistoryButton
itemId={season.id} itemId={season.id}
itemName={seasonName} itemName={seasonName}
@@ -151,6 +168,7 @@
focused={episode.id === focusedEpisodeId} focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId} current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)} onclick={() => onEpisodeClick?.(episode)}
onWatchedChanged={onHistoryCleared}
/> />
{/each} {/each}
</div> </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>
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) -------------------------------- // ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {}; const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
// is off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That flag now defaults to *on*
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
// default no longer selects this path and the tests have to say which path they
// are guarding rather than inherit it. (DR-161)
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", () => ({ vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => { listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler; channelHandlers[channel] = handler;
@@ -61,6 +82,10 @@ vi.mock("$lib/api/bindings", () => ({
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])), playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
playerSetSubtitleTrack: vi.fn(async () => ({})), playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: 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), storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})), storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
}, },
+205 -6
View File
@@ -4,7 +4,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; 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 { listen } from "@tauri-apps/api/event";
import Hls from "hls.js"; import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -38,7 +38,13 @@
enableNativeVideoCompositing, enableNativeVideoCompositing,
disableNativeVideoCompositing, disableNativeVideoCompositing,
} from "$lib/utils/videoSurface"; } 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 { import {
createTapGestureState, createTapGestureState,
registerTap, registerTap,
@@ -109,8 +115,41 @@
endedFired = true; endedFired = true;
onEnded?.(); 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 isFullscreen = $state(false);
let showControls = $state(true); 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 showSleepTimerModal = $state(false);
let isBuffering = $state(false); let isBuffering = $state(false);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null; let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -207,6 +246,14 @@
let showSubtitleMenu = $state(false); let showSubtitleMenu = $state(false);
let selectedSubtitleIndex = $state<number | null>(null); 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) // Track duration from video element (for when media item doesn't have runTimeTicks)
let videoDuration = $state(0); let videoDuration = $state(0);
@@ -603,6 +650,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 // Set up progress reporting interval
onMount(async () => { onMount(async () => {
// Background-audio lifecycle listeners MUST be registered synchronously — // Background-audio lifecycle listeners MUST be registered synchronously —
@@ -810,6 +878,24 @@
// Load series audio preference (for TV shows) // Load series audio preference (for TV shows)
await loadSeriesAudioPreference(); 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 // Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely. // meaningful position to report, so skip progress reporting entirely.
if (!isLive) { if (!isLive) {
@@ -855,6 +941,16 @@
// and idempotent — a no-op when compositing was never enabled. // and idempotent — a no-op when compositing was never enabled.
disableNativeVideoCompositing(); 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 // Stop RAF loop
stopTimeUpdates(); stopTimeUpdates();
@@ -968,6 +1064,9 @@
function handleLoadedMetadata() { function handleLoadedMetadata() {
console.log("[VideoPlayer] loadedmetadata event"); 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] Video element duration:", videoElement?.duration);
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks); console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding); console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
@@ -1239,6 +1338,8 @@
function handlePlay() { function handlePlay() {
isPlaying = true; isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates 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 // 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 // source of truth for HTML5 video (the <video> lives in the webview, which
// Rust cannot observe directly). See html5Adapter.ts. // Rust cannot observe directly). See html5Adapter.ts.
@@ -1268,6 +1369,7 @@
); );
isPlaying = false; isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused stopTimeUpdates(); // Stop RAF loop when paused
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
html5Adapter.reportState("paused", reportMediaId ?? null); html5Adapter.reportState("paused", reportMediaId ?? null);
html5Adapter.reportPosition(currentTime, duration, { force: true }); html5Adapter.reportPosition(currentTime, duration, { force: true });
// Report progress when paused // Report progress when paused
@@ -1528,12 +1630,24 @@
let pendingForegroundSeek: number | null = null; let pendingForegroundSeek: number | null = null;
let pendingForegroundPlay = false; 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() { function toggleFullscreen() {
if (!document.fullscreenElement) { 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; isFullscreen = true;
} else { } else {
document.exitFullscreen(); document.exitFullscreen();
exitImmersive();
isFullscreen = false; isFullscreen = false;
} }
} }
@@ -1593,7 +1707,9 @@
toggleFullscreen(); toggleFullscreen();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
if (isFullscreen) { 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 { } else {
onClose(); onClose();
} }
@@ -1803,6 +1919,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() { function toggleSubtitleMenu() {
showSubtitleMenu = !showSubtitleMenu; showSubtitleMenu = !showSubtitleMenu;
} }
@@ -2090,8 +2247,8 @@
style:padding-bottom="calc(1rem + var(--safe-bottom))" style:padding-bottom="calc(1rem + var(--safe-bottom))"
style:padding-left="calc(1rem + var(--safe-left))" style:padding-left="calc(1rem + var(--safe-left))"
style:padding-right="calc(1rem + var(--safe-right))" style:padding-right="calc(1rem + var(--safe-right))"
class:opacity-0={!showControls} class:opacity-0={!showControls || isInPip}
class:pointer-events-none={!showControls} class:pointer-events-none={!showControls || isInPip}
> >
<!-- Title --> <!-- Title -->
<div class="mb-2"> <div class="mb-2">
@@ -2199,6 +2356,48 @@
</div> </div>
{/if} {/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"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
</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 --> <!-- Subtitle Selection -->
{#if subtitleTracks().length > 0} {#if subtitleTracks().length > 0}
<div class="relative"> <div class="relative">
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false, isPipSupported: () => false,
enterPip: vi.fn(), enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(), setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
})); }));
vi.mock("$lib/stores/auth", () => ({ vi.mock("$lib/stores/auth", () => ({
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) -------------------------------- // ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {}; const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
// is off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That flag now defaults to *on*
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
// default no longer selects this path and the tests have to say which path they
// are guarding rather than inherit it. (DR-161)
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", () => ({ vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => { listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler; channelHandlers[channel] = handler;
@@ -63,6 +84,10 @@ vi.mock("$lib/api/bindings", () => ({
playerCancelSleepTimer: vi.fn(async () => ({})), playerCancelSleepTimer: vi.fn(async () => ({})),
playerSetSubtitleTrack: vi.fn(async () => ({})), playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: 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), storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})), storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
}, },
+32
View File
@@ -21,6 +21,7 @@ import type {
PlayTracksContext, PlayTracksContext,
PlayAlbumTrackRequest, PlayAlbumTrackRequest,
PlayItemRequest, PlayItemRequest,
StreamingQuality,
} from "$lib/api/bindings"; } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import type { PlayerAdapter } from "./adapters/types"; import type { PlayerAdapter } from "./adapters/types";
@@ -182,6 +183,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() { async function next() {
await commands.playerNext(); await commands.playerNext();
} }
@@ -300,6 +331,7 @@ export const playerController = {
setSubtitleTrack, setSubtitleTrack,
seekVideo, seekVideo,
switchAudioTrack, switchAudioTrack,
setStreamQuality,
playTracks, playTracks,
playAlbumTrack, playAlbumTrack,
playItem, playItem,
+1
View File
@@ -16,6 +16,7 @@ const OPERATION_LABELS: Record<string, string> = {
report_playback_stopped: "Watch position", report_playback_stopped: "Watch position",
update_progress: "Watch position", update_progress: "Watch position",
mark_played: "Marked as watched", mark_played: "Marked as watched",
mark_unplayed: "Marked as unwatched",
mark_favorite: "Added to favourites", mark_favorite: "Added to favourites",
unmark_favorite: "Removed from favourites", unmark_favorite: "Removed from favourites",
playlist_create: "Playlist created", playlist_create: "Playlist created",
+21 -2
View File
@@ -17,6 +17,7 @@ export type { SyncQueueItem };
export type SyncOperation = export type SyncOperation =
| "mark_played" | "mark_played"
| "mark_unplayed"
| "mark_favorite" | "mark_favorite"
| "unmark_favorite" | "unmark_favorite"
| "update_progress" | "update_progress"
@@ -101,12 +102,30 @@ class SyncService {
* Also updates local state immediately * Also updates local state immediately
*/ */
async queueMarkPlayed(itemId: string): Promise<number> { async queueMarkPlayed(itemId: string): Promise<number> {
// Update local state first // storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId); // 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); 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 * Get count of pending sync operations
*/ */
+23 -5
View File
@@ -26,13 +26,27 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */ /** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video"; const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on, defaulting to **on** when the user has never
* chosen.
*
* It shipped defaulting to off while the native path was a spike. It is now the
* default because picture-in-picture is built on it: PiP shrinks the *Activity*,
* so it needs a real video surface behind the WebView to show, and on the HTML5
* path there is nothing for it to shrink into but the UI itself (DR-160).
*
* An explicit stored choice still wins in both directions, so anyone who turned
* it off keeps it off.
*/
function load(): boolean { function load(): boolean {
if (typeof localStorage === "undefined") return false; if (typeof localStorage === "undefined") return true;
try { try {
return localStorage.getItem(STORAGE_KEY) === "true"; const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "true";
} catch { } catch {
// Private-mode / disabled storage — default to the safe (HTML5) path. // Private-mode / disabled storage — no stored choice is readable, so this is
return false; // the same case as "never chosen".
return true;
} }
} }
@@ -59,7 +73,11 @@ function createExperimentalNativeVideoStore() {
}; };
} }
/** User opt-in for the native Android video path. Default off. */ /**
* User opt-out for the native Android video path. **Defaults to on** since
* DR-161 see `load()`. The name still says "experimental" because the flag
* remains a suppressor of Rust's backend choice, not a promoter of it.
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore(); export const experimentalNativeVideo = createExperimentalNativeVideoStore();
function createNativeVideoActiveStore() { function createNativeVideoActiveStore() {
+68
View File
@@ -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);
}
}
+34
View File
@@ -18,6 +18,7 @@ interface AndroidPictureInPictureBridge {
isSupported(): boolean; isSupported(): boolean;
canEnterPip(): boolean; canEnterPip(): boolean;
setAutoEnterEnabled(enabled: boolean): void; setAutoEnterEnabled(enabled: boolean): void;
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
} }
declare global { declare global {
@@ -84,3 +85,36 @@ export function setAutoEnterEnabled(enabled: boolean): void {
console.warn("[PiP] Failed to set auto-enter:", err); 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 defaults to **on** now (DR-161), so Android normally shrinks the real
* ExoPlayer surface instead; this remains the path for Linux and for anyone who
* turned the flag off.
*
* 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);
}
}
+90
View File
@@ -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;
});
}
});
}
+79
View File
@@ -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 });
});
});
+91
View File
@@ -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 ?? ""}`;
}
+8
View File
@@ -31,6 +31,7 @@
shellReservesBottomInset, shellReservesBottomInset,
} from "$lib/utils/layoutShell"; } from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation"; import { registerNavigationTracking } from "$lib/utils/navigation";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import { startNetworkReporting } from "$lib/services/networkType"; import { startNetworkReporting } from "$lib/services/networkType";
import { initSafeArea } from "$lib/utils/safeArea"; import { initSafeArea } from "$lib/utils/safeArea";
@@ -52,6 +53,12 @@
// context, not the async onMount callback below. // context, not the async onMount callback below.
registerNavigationTracking(); 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 // Layout-shell visibility rules live in one pure, unit-tested module
// ($lib/utils/layoutShell) so they can't drift per route/platform. // ($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 sibling, so the list is physically bounded above it and can never
render behind it. No measurement, no reserved padding. --> render behind it. No measurement, no reserved padding. -->
<div <div
bind:this={shellScroller}
class="flex-1 overflow-y-auto min-h-0" class="flex-1 overflow-y-auto min-h-0"
style="overscroll-behavior: contain" style="overscroll-behavior: contain"
> >
+8 -1
View File
@@ -10,8 +10,15 @@
import HeroBanner from "$lib/components/home/HeroBanner.svelte"; import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte"; import Carousel from "$lib/components/home/Carousel.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte"; import MediaCard from "$lib/components/library/MediaCard.svelte";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import type { MediaItem, Library } from "$lib/api/types"; 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) // Track if we've done an initial load (plain variable, not reactive)
let hasLoadedOnce = false; let hasLoadedOnce = false;
let previousServerReachable = false; let previousServerReachable = false;
@@ -147,7 +154,7 @@
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div> <div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div> </div>
{:else} {: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"> <div class="space-y-8">
<!-- Hero Banner --> <!-- Hero Banner -->
+8
View File
@@ -3,6 +3,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth"; import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
import { useScrollGuard } from "$lib/composables/useScrollGuard"; import { useScrollGuard } from "$lib/composables/useScrollGuard";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import AppHeader from "$lib/components/AppHeader.svelte"; import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte"; import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -11,6 +12,12 @@
const scrollGuard = useScrollGuard(300); const scrollGuard = useScrollGuard(300);
setContext("scrollGuard", scrollGuard); 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 { children } = $props();
let showSleepTimerModal = $state(false); let showSleepTimerModal = $state(false);
@@ -45,6 +52,7 @@
scroller is physically bounded above it and its last row can never scroller is physically bounded above it and its last row can never
render behind the nav — no measurement, no reserved padding. --> render behind the nav — no measurement, no reserved padding. -->
<main <main
bind:this={libraryScroller}
class="flex-1 overflow-y-auto p-4 min-h-0" class="flex-1 overflow-y-auto p-4 min-h-0"
style="overscroll-behavior: contain" style="overscroll-behavior: contain"
onscroll={scrollGuard.onScroll} onscroll={scrollGuard.onScroll}
+29
View File
@@ -243,6 +243,35 @@
</div> </div>
{:else} {:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<!-- Favourites as a destination in its own right, not just the icon in
the header above. It cuts across every library, so it leads the
grid rather than sitting inside one — and 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 | DR-117 -->
<button
onclick={() => goto('/library/favorites')}
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
>
<div
class="relative aspect-video w-full overflow-hidden rounded-lg shadow-md
flex items-center justify-center
bg-gradient-to-br from-[var(--color-jellyfin)]/30 to-[var(--color-jellyfin)]/5"
>
<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>
</div>
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
Favourites
</p>
</button>
{#each visibleLibraries as lib (lib.id)} {#each visibleLibraries as lib (lib.id)}
<MediaCard <MediaCard
item={lib} item={lib}
+17
View File
@@ -19,6 +19,7 @@
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte"; import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte"; import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.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 VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte"; import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites"; import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
@@ -549,6 +550,14 @@
seriesName={item.name} seriesName={item.name}
episodeCount={allEpisodes.length || undefined} 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 <ClearHistoryButton
itemId={item.id} itemId={item.id}
itemName={item.name} itemName={item.name}
@@ -562,6 +571,14 @@
isMovie={true} isMovie={true}
size="lg" 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} {/if}
<!-- Favourite. Sits with Play/Download rather than in the header, <!-- Favourite. Sits with Play/Download rather than in the header,
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 --> per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
+52 -3
View File
@@ -6,6 +6,7 @@
AudioSettings, AudioSettings,
CacheConfig, CacheConfig,
EqPreset, EqPreset,
StreamingQuality,
VideoSettings, VideoSettings,
VolumeLevel, VolumeLevel,
} from "$lib/api/bindings"; } from "$lib/api/bindings";
@@ -62,8 +63,14 @@
autoPlayNextEpisode: true, autoPlayNextEpisode: true,
autoPlayCountdownSeconds: 10, autoPlayCountdownSeconds: 10,
autoPlayMaxEpisodes: 0, 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). // Download/caching behaviour, incl. the WiFi-only gate (UR-053).
let cacheConfig = $state<CacheConfig>({ let cacheConfig = $state<CacheConfig>({
queuePrecacheEnabled: true, queuePrecacheEnabled: true,
@@ -126,11 +133,12 @@
try { try {
loading = true; loading = true;
networkDetectionSupported = isNetworkDetectionSupported(); networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([ const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([
commands.playerGetAudioSettings(), commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(), commands.playerGetVideoSettings(),
getCacheConfig(), getCacheConfig(),
commands.playerGetEqPresets(), commands.playerGetEqPresets(),
commands.playerGetStreamingQualities(),
]); ]);
// equalizerBands is optional on the wire (serde default); guarantee a // equalizerBands is optional on the wire (serde default); guarantee a
// dense 10-band array so the slider bindings are never undefined. // dense 10-band array so the slider bindings are never undefined.
@@ -141,6 +149,7 @@
videoSettings = videoResult; videoSettings = videoResult;
cacheConfig = cacheResult; cacheConfig = cacheResult;
eqPresets = presets; eqPresets = presets;
streamingQualities = qualities;
// Load cache stats in parallel but don't block on it // Load cache stats in parallel but don't block on it
loadCacheStats(); loadCacheStats();
} catch (e) { } catch (e) {
@@ -331,6 +340,12 @@
persistVideo(); persistVideo();
} }
/** TRACES: UR-074 | DR-162 */
function handleStreamingQualityChange(quality: StreamingQuality) {
videoSettings.streamingQuality = quality;
persistVideo();
}
function handleSmartCachingToggle() { function handleSmartCachingToggle() {
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled; cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
persistCache(); persistCache();
@@ -681,6 +696,38 @@
{/if} {/if}
</div> </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 <!-- Native video (experimental). Only rendered where the platform's Rust
backend actually has a native video surface (Android). --> backend actually has a native video surface (Android). -->
{#if supportsNativeVideo} {#if supportsNativeVideo}
@@ -697,8 +744,10 @@
</h3> </h3>
<p class="text-sm text-gray-400 mt-1"> <p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the Decode video with the device's hardware decoder instead of the
built-in web player. Better performance and battery life, but built-in web player. Better performance and battery life, and
less tested — turn this off if video fails to appear. required for picture-in-picture to show the video rather than
the app. Still less tested — turn this off if video fails to
appear or seeking misbehaves.
</p> </p>
</div> </div>
<button <button