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.
This commit is contained in:
2026-08-15 16:26:31 +02:00
parent 50934e2ac6
commit 9f5f57cba4
42 changed files with 1548 additions and 139 deletions
+16 -3
View File
@@ -82,6 +82,8 @@ For a narrative overview of the system design, see
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
---
@@ -318,6 +320,12 @@ Internal architecture, components, and application logic.
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
@@ -372,8 +380,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 |
| UR-041 | IR-026 | DR-053 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
@@ -397,12 +405,14 @@ Internal architecture, components, and application logic.
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112 |
| UR-066 | IR-031 | DR-112, DR-157 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
---
@@ -553,6 +563,9 @@ Internal architecture, components, and application logic.
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
| UT-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-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.4.8",
"version": "0.5.3",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+3 -3
View File
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(71);
expect(defined.UR).toBe(73);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(150);
expect(defined.DR).toBe(156);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(288);
expect(defined.total).toBe(296);
});
});
+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
# *downgrade* and Android refuses the update.
#
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# The floor has to clear the highest code actually in the field, which is not the
# same as the highest this formula has produced. v0.5.2 shipped versionCode
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
# every 0.5.x release built from it was an un-installable downgrade for anyone
# already on v0.5.2, which is exactly the failure this block exists to prevent.
# The multipliers are widened and the floor raised past 5002 accordingly.
#
# code = 10000 + major*1000000 + minor*1000 + patch
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
@@ -93,7 +101,7 @@ if [ -f "$PROPS" ]; then
MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
+24 -12
View File
@@ -106,21 +106,33 @@ describe("set-version.sh", () => {
});
describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never
// produce a smaller number than an older one.
it("clears the 1000 floor shipped by earlier builds", () => {
// A newer release must never produce a smaller number than an older one, or
// Android refuses the update. The floor tracks the highest code actually in
// the field, which is NOT the same as the highest this formula has produced:
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
// the `minor*100` formula that replaced it yields only 1502 for that same
// version — so every 0.5.x release built from it was an un-installable
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
it("clears the highest code shipped by earlier builds", () => {
run("0.0.1");
expect(versionCode()).toBeGreaterThan(1000);
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
expect(versionCode()).toBeGreaterThan(5002);
});
it("uses 1000 + major*10000 + minor*100 + patch", () => {
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
run("0.5.3");
expect(versionCode()).toBeGreaterThan(5002);
});
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 1014],
["0.0.15", 1015],
["0.1.0", 1100],
["0.4.8", 1408],
["0.5.0", 1500],
["1.0.0", 11000],
["0.0.14", 10014],
["0.0.15", 10015],
["0.1.0", 11000],
["0.4.8", 14008],
["0.5.0", 15000],
["0.5.3", 15003],
["1.0.0", 1010000],
];
for (const [version, code] of cases) {
seed(tmp);
@@ -145,7 +157,7 @@ describe("set-version.sh", () => {
// stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1");
expect(versionCode()).toBe(1600);
expect(versionCode()).toBe(16000);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
});
});
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.4.8"
version = "0.5.3"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.4.8"
version = "0.5.3"
description = "A Tauri App"
authors = ["you"]
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) {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -325,6 +338,28 @@ class MainActivity : TauriActivity() {
}, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
// inside a WebView cannot touch the Activity window, so without this the
// status and navigation bars stayed painted over full-screen video.
webView.addJavascriptInterface(object : Any() {
/** Hide the system bars for full-screen playback. */
@JavascriptInterface
fun enter() {
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
}
/** Restore the system bars on leaving fullscreen or the player. */
@JavascriptInterface
fun exit() {
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
}
/** Whether native immersive mode exists (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidImmersive")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
// Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
@@ -46,6 +46,62 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "native video path check failed", e)
false
}
/**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app.
@@ -64,15 +120,10 @@ object PictureInPictureManager {
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
return try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
}
/**
@@ -125,32 +176,47 @@ object PictureInPictureManager {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return null
null
}
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width
val height = surface.height
if (width <= 0 || height <= 0) return null
val surface = player?.getSurfaceView()
if (surface != null && surface.width > 0 && surface.height > 0) {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
val ratio = width.toDouble() / height.toDouble()
val minRatio = 1.0 / 2.39
val maxRatio = 2.39
val clamped = ratio.coerceIn(minRatio, maxRatio)
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
}
// Scale to integers; Rational(width, height) directly can overflow for
// large surfaces, and the clamped value may not match the raw pixels.
/**
* Clamp a ratio to the range Android accepts and express it as a [Rational].
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content. Scaled to integers because
* `Rational(width, height)` can overflow for large surfaces, and the clamped
* value may not match the raw pixels anyway.
*/
private fun clampedRatio(ratio: Double): Rational {
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
return Rational((clamped * 1000).toInt(), 1000)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -222,11 +288,20 @@ object PictureInPictureManager {
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
hideWebView(activity)
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
registerReceiver(activity)
dispatchWebEvent(activity, "jellytau-pip-entered")
} else {
unregisterReceiver(activity)
showWebView()
dispatchWebEvent(activity, "jellytau-pip-exited")
// The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized.
try {
@@ -237,6 +312,23 @@ object PictureInPictureManager {
}
}
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
}
}
private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView)
if (webView == null) {
@@ -264,14 +356,30 @@ object PictureInPictureManager {
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
}
// Swap the button to reflect the new state.
updatePipActions(activity)
@@ -119,6 +119,54 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("seek:$positionSeconds")
}
// media3 seeks by more routes than seekTo(long), and the ones below
// reach the *real* ExoPlayer if they are not overridden — bypassing
// Rust entirely and operating on the handoff stream's relative
// timeline. That is the same mechanism as the truncation bug, reached
// by a different door.
//
// seekToDefaultPosition is deliberately swallowed rather than
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
// player and then calls play(); on a handoff stream the seek lands at
// stream zero — the point the screen was locked at — which is exactly
// the reported jump-back. Sending "seek:0.0" instead would be worse
// still, restarting the whole episode. Rust already owns what "play
// after the stream ended" means (truncation recovery, or advancing to
// the next episode), and the play() that follows reaches it, so the
// right move here is to not move at all.
//
// TRACES: UR-040, UR-005 | DR-159
override fun seekToDefaultPosition() {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
)
}
override fun seekToDefaultPosition(mediaItemIndex: Int) {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
)
}
// `currentPosition` is ExoPlayer's own, so it is relative during a
// handoff; the base makes the target absolute, which is what Rust
// expects from every command on this boundary.
override fun seekBack() {
val target =
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun seekForward() {
val target =
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun stop() {
nativeOnMediaCommand("stop")
}
@@ -262,23 +310,32 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen
// MediaSession. During a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
// position RELATIVE to that point (starting at 0). The metadata duration,
// however, is the full absolute length — so without this base the scrubber
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
// position via setPositionOffset(); 0 for normal playback.
private var positionOffsetMs: Long = 0L
// The handoff base (ms): during a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
// starts at 0 *there* and every position it reports is relative to it. This
// is the number that converts one back to a real position on the episode.
//
// It is deliberately read, not applied, here. This used to be a display-only
// correction added at the two setPlaybackState calls below, which left every
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
// seeks — working in the relative timeline while treating it as absolute, each
// crossing silently losing exactly `base` seconds. The conversion now happens
// once, in JellyTauPlayer's position tick, so everything downstream of it
// speaks the episode's timeline; applying it again here would double-count.
//
// TRACES: UR-040 | DR-159
@Volatile
var handoffBaseMs: Long = 0L
private set
/**
* Set the base position offset (seconds) applied to lockscreen positions.
* Called by the native layer when entering/exiting a background-audio handoff.
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
* Set the handoff base (seconds). Called by the native layer when entering or
* leaving a background-audio handoff; 0 clears it for normal playback, where
* ExoPlayer's position is already absolute.
*/
fun setPositionOffset(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
fun setHandoffBase(offsetSeconds: Double) {
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
}
/**
@@ -314,8 +371,9 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state (position made absolute via the base offset).
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Already absolute: this call comes from Rust, whose stored position is on
// the episode's timeline. (DR-159)
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
@@ -337,15 +395,15 @@ class JellyTauPlaybackService : MediaSessionService() {
* notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback.
*
* @param position Position in milliseconds
* @param position Absolute position in milliseconds, on the item's own
* timeline the caller has already applied [handoffBaseMs].
* @param isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
@@ -1030,16 +1030,41 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) {
if (exoPlayer.isPlaying) {
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
// THE boundary between the two timelines, and the only place
// the conversion happens.
//
// During a background-audio handoff the stream is requested
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
// is that point and everything it reports is relative to it.
// The base used to be added only where a position was *shown*
// (the lockscreen scrubber), leaving progress reports to
// Jellyfin, the frontend and the truncation maths all working
// in the relative timeline while treating it as absolute —
// each crossing losing exactly `base` seconds, which is why the
// jump-back distance varied with where the screen was locked.
// Shifting once, here, means every consumer downstream speaks
// the episode's timeline and none of them needs to know a
// handoff happened.
//
// The duration is shifted with it, so position and duration
// stay on the same timeline — the stream's own length is only
// what remains after the handoff point.
//
// TRACES: UR-040 | DR-159
val service = JellyTauPlaybackService.getInstance()
val baseMs = service?.handoffBaseMs ?: 0L
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
val position = positionMs / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
val duration =
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
service?.updatePlaybackPosition(positionMs, true)
}
delay(POSITION_UPDATE_INTERVAL_MS)
}
+19 -15
View File
@@ -770,22 +770,23 @@ pub async fn player_enter_background_audio(
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
// Back to foreground playback: the lockscreen scrubber is absolute again.
let _ = crate::player::set_lockscreen_position_offset(0.0);
let controller = player.0.lock().await;
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Zero after a backend-driven
// episode advance, whose stream already starts at its own zero.
let base = controller.exit_background_audio();
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position();
// Read the position BEFORE clearing either base. The position tick applies the
// base natively, so a tick landing between "base cleared" and "position read"
// would hand back a relative position — the whole bug, reintroduced at the one
// moment it matters most. Capturing into a `let` before stop() is also the
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159)
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())?;
let absolute = base + relative;
info!(
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
base, relative, absolute
"player_exit_background_audio: resuming the video at {:.1}s",
absolute
);
Ok(absolute)
}
@@ -1207,9 +1208,12 @@ pub async fn player_seek(
let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?;
} else {
// Local playback
// Local playback. seek_absolute, not seek: the position came from the UI,
// which shows the whole item, so during a background-audio handoff it has
// to be resolved against the episode's timeline rather than the handoff
// stream's. (DR-159)
let controller = player.0.lock().await;
controller.seek(position).map_err(|e| e.to_string())?;
controller.seek_absolute(position).await?;
}
let controller = player.0.lock().await;
+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
#[tauri::command]
#[specta::specta]
+59
View File
@@ -52,6 +52,12 @@ pub enum QueuedOp {
MarkPlayed {
item_id: String,
},
/// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
/// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
/// item un-marked offline does not come back carrying a stale position.
MarkUnplayed {
item_id: String,
},
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
/// (DR-120). Supported so a row written by an older build still lands.
Favorite {
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
position_ticks: ticks(),
}),
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
"mark_favorite" => Ok(QueuedOp::Favorite {
item_id,
is_favorite: true,
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
position_ticks,
} => self.report_playback_stopped(item_id, *position_ticks).await,
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
QueuedOp::Favorite {
item_id,
is_favorite,
@@ -1031,4 +1039,55 @@ mod tests {
assert!(parse_queued_op("mark_played", None, None).is_err());
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
}
/// Un-marking watched queues like marking watched does, so the toggle works
/// in both directions while the server is unreachable rather than only one.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[test]
fn test_parse_accepts_mark_unplayed() {
assert_eq!(
parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
QueuedOp::MarkUnplayed {
item_id: "ep1".to_string()
},
);
assert!(parse_queued_op("mark_unplayed", None, None).is_err());
}
/// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
/// mark-unplayed, which also zeroes the resume position, so a series returns
/// to "never watched" rather than keeping a stale position.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[tokio::test]
async fn test_drain_pushes_mark_unplayed() {
let db = test_db();
seed(
&db,
&[(
"u1",
"mark_unplayed",
"ep9",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
)],
)
.await;
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::MarkUnplayed {
item_id: "ep9".to_string()
}],
);
assert_eq!(report.pushed, 1);
assert_eq!(report.remaining, 0);
}
}
+24 -7
View File
@@ -265,6 +265,7 @@ use commands::{
storage_save_user,
storage_search_items,
storage_set_active_user,
storage_set_watched,
storage_toggle_favorite,
storage_update_playback_context,
storage_update_playback_progress,
@@ -424,6 +425,28 @@ impl MediaSessionHandler {
/// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) {
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
// whole episode — and resolving it during a background-audio handoff means
// re-opening the stream, which is async. So it runs on the runtime and,
// critically, is handled *before* the blocking lock below: taking that
// guard and then spawning a task that waits for the same mutex would
// deadlock the media session. (DR-159)
if let Some(raw) = command.strip_prefix("seek:") {
match raw.parse::<f64>() {
Ok(position) => {
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
if let Err(e) = controller.seek_absolute(position).await {
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
}
});
}
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
}
return;
}
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
@@ -433,13 +456,6 @@ impl MediaSessionHandler {
"next" => controller.next(),
"previous" => controller.previous(),
"stop" => controller.stop(),
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(pos) => controller.seek(pos),
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown command: {}", command);
Ok(())
@@ -789,6 +805,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_set_watched,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
+6 -4
View File
@@ -1474,9 +1474,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
/// Set the background-audio handoff base (seconds) on the playback service.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
/// The service holds it for `JellyTauPlayer`'s position tick, which is the one
/// place the relative handoff timeline is converted to the episode's own — see
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
@@ -1525,11 +1527,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
env.call_method(
&service_obj,
"setPositionOffset",
"setHandoffBase",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
.map_err(|e| format!("Failed to set handoff base: {}", e))?;
Ok(())
}
+130 -11
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> {
let mut backend = self.backend.lock_safe();
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)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock_safe().set_volume(volume)
@@ -1384,8 +1422,10 @@ impl PlayerController {
return None;
}
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
// Already absolute: the Android position tick shifts by the handoff base
// 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) {
Some(attempt) => Some((absolute, attempt)),
@@ -1425,8 +1465,8 @@ impl PlayerController {
}
current.duration
};
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
// Already absolute — see claim_stream_resume. (DR-159)
let absolute = self.position().max(0.0);
// Only spend a resume attempt once the runtime says this really was cut
// short — a genuine end must stay a genuine end.
@@ -3604,10 +3644,88 @@ mod tests {
}
}
/// The handoff stream's timeline starts at the handoff position, so the
/// player reports a *relative* position. The runtime it is compared against
/// is absolute — the base has to be added back, or every handoff looks like a
/// truncation.
/// A seek arriving during a background-audio handoff is **absolute** — the
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
/// 25:00 of the episode, not 25:00 into the handoff stream.
///
/// 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]
async fn test_truncated_check_uses_the_absolute_position() {
let controller = PlayerController::default();
@@ -3616,9 +3734,10 @@ mod tests {
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.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.seek(56.0).unwrap();
controller.seek(1496.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.4.8",
"version": "0.5.3",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+27
View File
@@ -746,6 +746,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
},
/**
* Set the watched flag locally for an item **and everything inside it**.
*
* This backs the watched toggle, and is deliberately separate from
* [`storage_mark_played`] which reports a single track/episode finishing and
* increments `play_count` because the toggle has two directions and applies
* to containers.
*
* The recursion is what makes the toggle honest offline. Jellyfin applies
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
* online the server fixes up the children on the next read; with no server to
* ask, marking a season watched would otherwise tick the season and leave every
* episode inside it unwatched. Targets are drawn from `items` by the same link
* columns the rest of the offline layer uses, so an id that is not cached
* selects nothing and the statement is a no-op rather than a foreign-key error.
*
* Un-marking clears the resume position too, matching the server, so an item
* un-marked offline does not come back offering to resume from a position it is
* no longer meant to have.
*
* `pending_sync = 1` hands the rows to the sync drain.
*
* TRACES: UR-073 | DR-158
*/
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
},
/**
* Get playback progress for an item
*/
@@ -13,6 +13,7 @@
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CastSection from "./CastSection.svelte";
import GenreTags from "./GenreTags.svelte";
import RelatedItemsSection from "./RelatedItemsSection.svelte";
@@ -250,6 +251,12 @@
episodeNumber={episode.indexNumber ?? undefined}
size="lg"
/>
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="lg"
/>
<FavoriteButton
itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
+20 -1
View File
@@ -5,6 +5,7 @@
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
@@ -17,9 +18,17 @@
*/
current?: boolean;
onclick?: () => void;
/** Fired when the watched toggle changes, so the series page can reload. */
onWatchedChanged?: () => void;
}
let { episode, focused = false, current = false, onclick }: Props = $props();
let {
episode,
focused = false,
current = false,
onclick,
onWatchedChanged,
}: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
@@ -177,6 +186,16 @@
{duration}
</span>
{/if}
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton
@@ -4,6 +4,7 @@
import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation";
@@ -65,18 +66,26 @@
/>
</div>
<!-- Season info -->
<!-- Season info.
The header stacks on narrow screens and only shares a row from `sm` up.
Three action buttons and a season title cannot both fit across a phone,
and side-by-side they ended up overlapping. -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<!-- The whole title block toggles the season open/closed. -->
<button
type="button"
onclick={onToggle}
aria-expanded={expanded}
aria-controls="{anchor}-episodes"
class="flex-1 min-w-0 text-left group/season"
class="min-w-0 sm:flex-1 text-left group/season"
>
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
a flex item will not shrink below its content width without it, so
a long season name grew the row instead of ellipsising and ran
under the buttons. -->
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
<svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
@@ -88,7 +97,7 @@
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{seasonName}</span>
<span class="truncate min-w-0">{seasonName}</span>
{#if holdsCurrentEpisode}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
@@ -120,8 +129,9 @@
{/if}
</button>
<!-- Per-season actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<!-- Per-season actions. `self-start` keeps them level with the title on
wide rows; on a stacked phone layout they sit under it. -->
<div class="flex-shrink-0 flex items-center gap-2 self-start">
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
@@ -130,6 +140,13 @@
{episodeCount}
size="sm"
/>
<WatchedToggleButton
itemId={season.id}
watched={watchedCount === episodeCount && episodeCount > 0}
scope="season"
size="sm"
onChanged={onHistoryCleared}
/>
<ClearHistoryButton
itemId={season.id}
itemName={seasonName}
@@ -151,6 +168,7 @@
focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
onWatchedChanged={onHistoryCleared}
/>
{/each}
</div>
@@ -0,0 +1,140 @@
<!--
Mark an episode, season or series watched — or unwatched again.
The backend already had both halves (`mark_played` / `clear_watch_history`,
both recursive over a container on the server) and the sync queue already
replayed the first; nothing in the UI had ever called them, so the only way to
mark something watched was to sit through it. This is that control.
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
this series", confirms, and needs the server — this is an everyday toggle: no
confirmation, and it works offline by queueing, in both directions.
TRACES: UR-073 | DR-158
-->
<script lang="ts">
import { syncService } from "$lib/services/syncService";
interface Props {
/** Episode, season or series id. */
itemId: string;
/** Current watched state, as the caller knows it. */
watched: boolean;
/** What is being marked, for the tooltip wording. */
scope: "episode" | "season" | "series";
size?: "sm" | "lg";
/** Show a text label beside the icon rather than icon-only. */
showLabel?: boolean;
/** Called after a successful toggle so the caller can reload. */
onChanged?: (watched: boolean) => void;
}
let {
itemId,
watched,
scope,
size = "lg",
showLabel = false,
onChanged,
}: Props = $props();
let busy = $state(false);
// Optimistic state: the caller's `watched` prop only catches up once it has
// reloaded from the repository, which on a season means a round trip. Without
// this the button visibly ignores the first tap.
let optimistic = $state<boolean | null>(null);
const isWatched = $derived(optimistic ?? watched);
// A new item in the same slot (scrolling a virtualised list, switching series)
// must drop the previous item's optimistic state or it shows the wrong tick.
$effect(() => {
itemId;
optimistic = null;
});
const subject = $derived(
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
);
const label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived(
isWatched
? `Mark this ${subject} unwatched`
: scope === "episode"
? "Mark this episode watched"
: `Mark every episode in this ${subject} watched`
);
async function handleClick() {
if (busy) return;
const next = !isWatched;
busy = true;
optimistic = next;
try {
if (next) {
await syncService.queueMarkPlayed(itemId);
} else {
await syncService.queueMarkUnplayed(itemId);
}
onChanged?.(next);
} catch (e) {
// Put the button back where it was — the change did not happen.
optimistic = null;
console.error("Failed to change watched state:", e);
} finally {
busy = false;
}
}
</script>
<button
type="button"
onclick={handleClick}
disabled={busy}
{title}
aria-label={title}
aria-pressed={isWatched}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed
{isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
>
{#if busy}
<div
class="border-2 border-current border-t-transparent rounded-full animate-spin
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else if isWatched}
<!-- Filled check: this one is done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
6.4-6.4L18.4 8.8l-7.8 7.8z"
/>
</svg>
{:else}
<!-- Outline check: available, not yet done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
</svg>
{/if}
{#if showLabel}
<span>{busy ? "Saving…" : label}</span>
{/if}
</button>
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** 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", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
+90 -5
View File
@@ -38,7 +38,13 @@
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
isPipSupported,
enterPip,
setAutoEnterEnabled,
setHtml5VideoState,
} from "$lib/utils/pictureInPicture";
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
import {
createTapGestureState,
registerTap,
@@ -109,8 +115,39 @@
endedFired = true;
onEnded?.();
}
/**
* Keep native's picture-in-picture state in step with the `<video>` element.
*
* PiP is driven by the Activity, and it only ever knew about the native
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which defaults
* to off. So in the 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. (UR-041, DR-160)
*/
function reportPipVideoState() {
if (!useHtml5Element || !videoElement) {
setHtml5VideoState(false, 0, 0, false);
return;
}
setHtml5VideoState(
true,
videoElement.videoWidth,
videoElement.videoHeight,
isPlaying
);
}
let isFullscreen = $state(false);
let showControls = $state(true);
/**
* True while the Activity is in picture-in-picture.
*
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
* itself down to the video — controls, header and gradients would otherwise be
* rendered into a window a couple of inches wide. (UR-041, DR-160)
*/
let isInPip = $state(false);
let pipListenerCleanup: (() => void) | null = null;
let showSleepTimerModal = $state(false);
let isBuffering = $state(false);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -810,6 +847,24 @@
// Load series audio preference (for TV shows)
await loadSeriesAudioPreference();
// PiP: keep native's view of the `<video>` current, and react to the window
// shrinking. The listeners are torn down in onDestroy. (DR-160)
reportPipVideoState();
const onPipEntered = () => (isInPip = true);
const onPipExited = () => (isInPip = false);
const onPipPlay = () => void videoElement?.play().catch(() => {});
const onPipPause = () => videoElement?.pause();
window.addEventListener("jellytau-pip-entered", onPipEntered);
window.addEventListener("jellytau-pip-exited", onPipExited);
window.addEventListener("jellytau-pip-play", onPipPlay);
window.addEventListener("jellytau-pip-pause", onPipPause);
pipListenerCleanup = () => {
window.removeEventListener("jellytau-pip-entered", onPipEntered);
window.removeEventListener("jellytau-pip-exited", onPipExited);
window.removeEventListener("jellytau-pip-play", onPipPlay);
window.removeEventListener("jellytau-pip-pause", onPipPause);
};
// Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely.
if (!isLive) {
@@ -855,6 +910,16 @@
// and idempotent — a no-op when compositing was never enabled.
disableNativeVideoCompositing();
// Same reasoning for the system bars: they belong to the Activity, not to
// this component, so a player torn down while immersive would leave every
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
exitImmersive();
// The `<video>` is going away, so PiP must stop being offered over it.
setHtml5VideoState(false, 0, 0, false);
pipListenerCleanup?.();
pipListenerCleanup = null;
// Stop RAF loop
stopTimeUpdates();
@@ -968,6 +1033,9 @@
function handleLoadedMetadata() {
console.log("[VideoPlayer] loadedmetadata event");
// Intrinsic dimensions are known now, which is what PiP sizes its window
// from — before this they are 0 and the ratio would be rejected. (DR-160)
reportPipVideoState();
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
@@ -1239,6 +1307,8 @@
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
// PiP's play/pause action reflects this. (DR-160)
reportPipVideoState();
// Mirror the DOM state into the Rust PlayerController so it is the single
// source of truth for HTML5 video (the <video> lives in the webview, which
// Rust cannot observe directly). See html5Adapter.ts.
@@ -1268,6 +1338,7 @@
);
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
html5Adapter.reportState("paused", reportMediaId ?? null);
html5Adapter.reportPosition(currentTime, duration, { force: true });
// Report progress when paused
@@ -1528,12 +1599,24 @@
let pendingForegroundSeek: number | null = null;
let pendingForegroundPlay = false;
// On Android the Activity owns the system bars, and requestFullscreen() cannot
// reach them — the WebView already spans the window under an edge-to-edge
// Activity, so on its own it left the status and navigation bars painted over
// the video. The native bridge is what actually makes fullscreen full screen;
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
document.documentElement.requestFullscreen().catch((err) => {
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
// the immersive call below is what matters on Android, so don't let a
// rejection here abort it.
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
});
enterImmersive();
isFullscreen = true;
} else {
document.exitFullscreen();
exitImmersive();
isFullscreen = false;
}
}
@@ -1593,7 +1676,9 @@
toggleFullscreen();
} else if (e.key === "Escape") {
if (isFullscreen) {
document.exitFullscreen();
// Through the toggle, not document.exitFullscreen() directly: leaving
// fullscreen also has to restore the system bars and clear the flag.
toggleFullscreen();
} else {
onClose();
}
@@ -2090,8 +2175,8 @@
style:padding-bottom="calc(1rem + var(--safe-bottom))"
style:padding-left="calc(1rem + var(--safe-left))"
style:padding-right="calc(1rem + var(--safe-right))"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
class:opacity-0={!showControls || isInPip}
class:pointer-events-none={!showControls || isInPip}
>
<!-- Title -->
<div class="mb-2">
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** 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", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
+1
View File
@@ -16,6 +16,7 @@ const OPERATION_LABELS: Record<string, string> = {
report_playback_stopped: "Watch position",
update_progress: "Watch position",
mark_played: "Marked as watched",
mark_unplayed: "Marked as unwatched",
mark_favorite: "Added to favourites",
unmark_favorite: "Removed from favourites",
playlist_create: "Playlist created",
+21 -2
View File
@@ -17,6 +17,7 @@ export type { SyncQueueItem };
export type SyncOperation =
| "mark_played"
| "mark_unplayed"
| "mark_favorite"
| "unmark_favorite"
| "update_progress"
@@ -101,12 +102,30 @@ class SyncService {
* Also updates local state immediately
*/
async queueMarkPlayed(itemId: string): Promise<number> {
// Update local state first
await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
// it has to cover a season or series' episodes too. storageMarkPlayed stays
// the single-item "this finished playing" path.
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
return this.queueMutation("mark_played", itemId);
}
/**
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
*
* Same shape deliberately: the watched toggle has to work in both directions
* offline, or un-marking would be the one half that needs a connection. The
* drain pushes this as `clear_watch_history` Jellyfin's mark-unplayed, which
* is recursive over a season or series and also clears resume positions.
*
* TRACES: UR-073 | DR-158
*/
async queueMarkUnplayed(itemId: string): Promise<number> {
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
return this.queueMutation("mark_unplayed", itemId);
}
/**
* Get count of pending sync operations
*/
+18 -4
View File
@@ -26,13 +26,27 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on, 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 {
if (typeof localStorage === "undefined") return false;
if (typeof localStorage === "undefined") return true;
try {
return localStorage.getItem(STORAGE_KEY) === "true";
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "true";
} catch {
// Private-mode / disabled storage — default to the safe (HTML5) path.
return false;
// Private-mode / disabled storage — no stored choice is readable, so this is
// the same case as "never chosen".
return true;
}
}
+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);
}
}
+30
View File
@@ -18,6 +18,7 @@ interface AndroidPictureInPictureBridge {
isSupported(): boolean;
canEnterPip(): boolean;
setAutoEnterEnabled(enabled: boolean): void;
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
}
declare global {
@@ -84,3 +85,32 @@ export function setAutoEnterEnabled(enabled: boolean): void {
console.warn("[PiP] Failed to set auto-enter:", err);
}
}
/**
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
*
* This is what makes PiP work at all in the shipping configuration. The native
* side only ever knew about the ExoPlayer surface, and that path is behind
* `experimentalNativeVideo`, which defaults to off 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.
*
* 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,
} from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import { startNetworkReporting } from "$lib/services/networkType";
import { initSafeArea } from "$lib/utils/safeArea";
@@ -52,6 +53,12 @@
// context, not the async onMount callback below.
registerNavigationTracking();
// The shell's scroller outlives every route rendered into it, so without this
// a new page inherits the previous page's offset. Must be registered here at
// init, alongside the tracker above, for the same reason. (DR-156)
let shellScroller = $state<HTMLElement>();
useScrollRestore(() => shellScroller, "shell");
// Layout-shell visibility rules live in one pure, unit-tested module
// ($lib/utils/layoutShell) so they can't drift per route/platform.
//
@@ -313,6 +320,7 @@
sibling, so the list is physically bounded above it and can never
render behind it. No measurement, no reserved padding. -->
<div
bind:this={shellScroller}
class="flex-1 overflow-y-auto min-h-0"
style="overscroll-behavior: contain"
>
+8 -1
View File
@@ -10,8 +10,15 @@
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import type { MediaItem, Library } from "$lib/api/types";
// Home scrolls in its own box rather than the shell's, and is destroyed on
// every navigation away — so its offsets live in the module-level memory,
// letting Back return the viewer to their row instead of the top. (DR-156)
let homeScroller = $state<HTMLElement>();
useScrollRestore(() => homeScroller, "home");
// Track if we've done an initial load (plain variable, not reactive)
let hasLoadedOnce = false;
let previousServerReachable = false;
@@ -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>
{:else}
<div class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div bind:this={homeScroller} class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div class="space-y-8">
<!-- Hero Banner -->
+8
View File
@@ -3,6 +3,7 @@
import { goto } from "$app/navigation";
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -11,6 +12,12 @@
const scrollGuard = useScrollGuard(300);
setContext("scrollGuard", scrollGuard);
// This scroller outlives every /library/* route rendered into it, so opening
// an item from half-way down a grid used to drop the viewer half-way down the
// detail page. Registered at init, as SvelteKit's nav hooks require. (DR-156)
let libraryScroller = $state<HTMLElement>();
useScrollRestore(() => libraryScroller, "library");
let { children } = $props();
let showSleepTimerModal = $state(false);
@@ -45,6 +52,7 @@
scroller is physically bounded above it and its last row can never
render behind the nav — no measurement, no reserved padding. -->
<main
bind:this={libraryScroller}
class="flex-1 overflow-y-auto p-4 min-h-0"
style="overscroll-behavior: contain"
onscroll={scrollGuard.onScroll}
+29
View File
@@ -243,6 +243,35 @@
</div>
{:else}
<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)}
<MediaCard
item={lib}
+17
View File
@@ -19,6 +19,7 @@
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
import WatchedToggleButton from "$lib/components/library/WatchedToggleButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
@@ -549,6 +550,14 @@
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
<WatchedToggleButton
itemId={item.id}
watched={allEpisodes.length > 0 &&
allEpisodes.every((e) => e.userData?.isPlayed)}
scope="series"
showLabel={true}
onChanged={loadItem}
/>
<ClearHistoryButton
itemId={item.id}
itemName={item.name}
@@ -562,6 +571,14 @@
isMovie={true}
size="lg"
/>
<!-- A movie is a leaf, so its own played flag is the whole story. -->
<WatchedToggleButton
itemId={item.id}
watched={item.userData?.isPlayed ?? false}
scope="episode"
showLabel={true}
onChanged={loadItem}
/>
{/if}
<!-- Favourite. Sits with Play/Download rather than in the header,
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
+4 -2
View File
@@ -697,8 +697,10 @@
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the
built-in web player. Better performance and battery life, but
less tested — turn this off if video fails to appear.
built-in web player. Better performance and battery life, and
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>
</div>
<button