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 |