Compare commits

..
16 Commits
Author SHA1 Message Date
dtourolle 58f2506966 feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00
dtourolle a818fee297 fix(player): re-entering a video no longer opens the audio player (DR-100)
Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. Closing a webview-rendered video deliberately emits no
"stopped" state (that would break the autoplay handoff), and the
direct-play path does not stop the backend on unmount, so the Rust
controller still reported that item as its loaded media. Re-entering the
route therefore took the "already playing, just show the UI" shortcut,
which returns before a stream URL is fetched, and the render fell
through to the audio surface. Mostly visible on Android, where video
direct-plays; Linux transcodes and stops the backend on unmount.

Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
2026-08-03 18:12:37 +02:00
dtourolle a26a853f01 fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s
An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, and ExoPlayer parked in STATE_ENDED —
where any later play intent (lockscreen, headset, Bluetooth reconnect) replays
the ended item, surfacing as the episode randomly restarting.

End-of-playback is dispatched from two places and they disagreed. The Android
JNI callback carried the background-audio branch but can never reach it:
load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears
it, so the first real end consumes it and the decision is always Stop. The call
that actually decides is the frontend's echo of the resulting PlaybackEnded into
player_on_playback_ended — and that path had no background-audio case at all, so
it started a countdown whose advance is a webview goto() that cannot start audio
while backgrounded.

Both dispatchers now share PlayerController::auto_advance_to_next_episode, so
they cannot drift apart again.

The handoff base offset moves from the BackgroundAudioOffset Tauri state onto
the controller, and the advance clears it: the next episode's stream is built
without StartTimeTicks, so its timeline is already absolute and a stale base
made player_exit_background_audio return old_base + position_in_new_episode.
Unreachable until the advance actually worked.

Tests (red before the fix):
- test_auto_advance_background_audio_episode_advances_in_backend
- test_auto_advance_foreground_video_episode_uses_countdown
- test_advance_to_next_episode_audio_only_clears_handoff_base

Bump to 0.2.9.
2026-08-02 18:10:18 +02:00
dtourolle 9d099268b9 fix(player): make the video seek bar work by touch (DR-099)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
On Android, dragging or tapping the progress bar moved the thumb but
playback stayed where it was. Two separate defects, both touch-only,
which is why the mouse-driven scrub tests never caught either.

1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that
   land on a control, but handleTouchMove kept running. It measures
   against touchStartX/Y, which that early return leaves at the PREVIOUS
   gesture's values, so a seek-bar drag produced a huge bogus vertical
   delta: read as a brightness swipe, it dimmed the screen to the 0.3
   floor and fired a spurious play/pause "correction" mid-drag. A gesture
   is now latched at touchstart (playerGestureActive) and touchmove
   ignores anything unlatched — re-checking the move target cannot
   recover a start point that was never recorded.

2. Commit signal. The seek was committed only from `change`, which
   Android's WebView does not reliably fire for a touch interaction on a
   range input, so the thumb moved to the tapped position and no seek
   ever ran. touchend/mouseup now commit too; `input` arms a one-shot
   latch so whichever release signal arrives first commits and the other
   is a no-op. seekRelative shares the same commitSeek entry point
   instead of fabricating a synthetic change event.

Tests drive the slider with real touch events (UT-089, UT-090) and fail
against the pre-fix component.
2026-08-01 10:41:23 +02:00
dtourolle e381d626c1 docs(requirements): UR-061/DR-092 no longer describe the removed deferral
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Failing after 7m23s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
Both still described the 300ms deferred-tap design that DR-098 replaced
with immediate action, so the generated release notes advertised
behaviour the code no longer has.
2026-07-30 16:13:29 +02:00
dtourolle b12e99b7e1 fix(player): keep double-tap seek working over the play overlay (DR-098)
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
The control-surface guard added in the previous commit killed
double-tap-to-seek. The first tap pauses, which renders the full-screen
<button> play overlay over the video, so the SECOND tap lands on a
button — and the guard discarded it as "a tap on a control".

Mark that overlay `data-player-surface`: visually it IS the video, so it
must keep taking tap gestures despite being a <button>. The marker wins
over the interactive-tag check in isControlSurfaceTouch.

Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component
and dispatches real touch/click events at whatever element is genuinely
on top. This is the gap that let four bugs ship in a row: the pure-unit
tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all
passed throughout, because each helper behaved exactly as specified —
every bug was in the composition, i.e. which element actually receives a
tap after Svelte re-renders. Modelling that DOM by hand in a test would
just re-encode the same wrong assumption, so these render it instead.

The new double-tap test was verified to fail with the fix reverted and
pass with it applied, in both directions.
2026-07-30 15:44:38 +02:00
dtourolle dc8b732465 fix(player): controls bar taps are not player gestures (DR-098)
The bottom play/pause button did nothing. The gesture listener lives on
the outer container and touch events bubble, so tapping the button ran
handleTouchStart (toggle #1) and then the button's own onclick (toggle
#2). The two cancelled out, leaving the control apparently dead.

Ignore container-level gestures for touches that land on an interactive
control: buttons, links, inputs (the seek bar), or anything inside the
controls bar, now marked `data-player-controls`. The rule itself is a
pure function over the ancestor chain (isControlSurfaceTouch), so it is
unit tested without a DOM.

Same root shape as the play-overlay bug in the previous commit: a second
click target over the video that the gesture layer did not account for.
2026-07-30 15:27:01 +02:00
dtourolle b98a530f48 fix(player): guard the play overlay against the synthesized touch click
After the DR-098 tap rewrite, pausing became impossible while unpausing
always worked — an asymmetry that pointed straight at the overlay.

Pausing renders a full-screen play-overlay button over the video. The
compatibility click Android synthesizes from the tap arrives ~30-130ms
later, by which time that button exists, so the click lands on the
OVERLAY rather than the <video>. Its onclick called togglePlayPause with
no guard at all, resuming immediately. Unpausing was unaffected because
it removes the overlay, leaving nothing to intercept the click.

The suppression rule was only wired into the video element's handler.
Extract it as isSynthesizedTouchClick() in tapGestures.ts (unit-tested)
and use it from every click target layered over the video, the overlay
included.

Verified: 724 frontend tests pass, svelte-check clean. Bumped to 0.2.5
so the APK installs over 2004.
2026-07-30 15:10:59 +02:00
dtourolle b565c4ae6f fix(player): tap gestures act immediately, no deferral timer (DR-098)
Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.

The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.

Replace the deferral with immediate action — there are only first and
second taps:

  1st tap: toggle play/pause
  2nd tap: seek, then toggle play/pause again

The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.

Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.

A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.

UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
2026-07-30 14:53:20 +02:00
dtourolle 79e10d7485 chore(release): bump to 0.2.3
Android versionCode derives from this (0.2.3 -> 2003); required for the
APK to install over the 2002 build already on the device.
2026-07-30 13:56:04 +02:00
dtourolle a2dbde5492 debug(player): log pause reason and flatten the debug tick
An unexplained pause/resume loop was invisible over adb: handlePause
logged nothing at all, so only the "playing" half of each cycle showed
up, and the 1s debug tick logged an object — which the Android WebView
console bridge renders as "[object Object]", discarding every field.

Log the element state on pause (readyState, networkState, seeking,
ended, plus the component's own isSeeking/isBuffering/handoff flags) and
emit the debug tick as a flat string. This is what identified DR-097:
the element was fully buffered and healthy at every pause, ruling out a
stall and pointing at a competing controller instead.
2026-07-30 13:55:06 +02:00
dtourolle 75cd07a5c0 fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.

el.paused flips transiently while an element buffers or settles a seek.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.

The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.

Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.

Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
2026-07-30 13:54:41 +02:00
dtourolle 64d07b8940 chore(release): bump to 0.2.2
Android versionCode is derived from this (0.2.2 -> 2002), so the bump is
required for the APK to install over the 2001 build already on device.
2026-07-30 13:17:29 +02:00
dtourolle 5b810f7fc3 build(android): add --device/--abi to build only the needed architecture
An on-device test build compiled all four ABIs (arm64/arm/x86/x86_64),
so three of the four Rust compiles were thrown away. That dominated the
build time when iterating against a connected phone.

--device resolves the attached device's ABI via adb and targets just
that triple; --abi <target> selects one explicitly; ABI= works as an
env var. Default behaviour is unchanged (all four), since a
distributable universal APK genuinely needs them.

  bun run android:build:device
  bun run android:build:release:device
2026-07-30 13:16:52 +02:00
dtourolle 1ae213ff39 fix(player): stop AbortError storm from HLS stall recovery (DR-096)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.

Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.

This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.

Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
2026-07-30 12:55:49 +02:00
dtourolle 98a6bca645 fix(player): clamp seeks inside media to stop end-of-stream pause loop (DR-095)
Seeking near the end of a transcoded video locked the player into a
stall/pause loop: unpausing or skipping bounced straight back to paused.

Both seek paths clamped the target to exactly `duration`. hls.js then
requested the segment whose start time lies *past* the end of the media
(a 6330.324s item asks for segment 1055, starting at 6336.33s). Jellyfin
never produces that segment, the fetch times out, and the gap-controller
stalls forever at the last buffered position — retrying ~1x/second and
firing an endless stream of AbortErrors as play() lands mid-nudge.

Clamp strictly inside the media instead, keeping one segment length
(6s) of margin, floored at 0 so short media still seeks to the start.
The seek-bar drag path needed this too: its range input `max` is the
duration itself, so dragging fully right produced the same dead target.

Also bumps the requirement-count fixture for the new DR-095 row.
2026-07-30 12:52:03 +02:00
64 changed files with 5390 additions and 1499 deletions
+30 -6
View File
@@ -71,7 +71,10 @@ For a narrative overview of the system design, see
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
---
@@ -246,8 +249,21 @@ Internal architecture, components, and application logic.
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / 10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / 10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection``EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done |
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
---
@@ -318,6 +334,9 @@ Internal architecture, components, and application logic.
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
---
@@ -408,10 +427,15 @@ Internal architecture, components, and application logic.
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done |
| UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done |
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done |
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done |
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
| UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
### Integration Tests
@@ -0,0 +1,239 @@
# Spec: series navigation lands on the current episode
**Status:** Accepted
**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106
**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
## Summary
Opening a TV series lands you where you actually are in it: the seasons render
as collapsible sections with **only the current season expanded**, the current
episode highlighted and scrolled into view, and the hero button opens that
episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first
season. A season stops being a destination of its own — every route that used to
land on `/library/<seasonId>` now lands on the series with that season in view,
so the full cross-season episode list is always reachable in one place. Watch
history can be erased per series and per season. Separately, each video library
collapses from three routes (landing, all-titles, genres) to one route with
in-page tabs.
## Motivation
Two problems, reported together.
**1. Series navigation dead-ends at season 1.** The series detail page's Play
button resolved its target as `$libraryItems[0]` — the first *season* child,
ordered by `SortName` — and navigated to `/player/<seasonId>`. The player route
classifies `season` as a container kind and bounces it back to
`/library/<seasonId>`. So Play on a series played nothing; it navigated you to
the season-1 page. Opening a series without pressing Play rendered every season
stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past
everything they had already watched.
The backend has been able to answer "where is this viewer in this show" the
whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired
end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass
`undefined` for `series_id`** — the per-series capability existed and was never
used.
**2. Seasons are an accidental page.** There is no season route. `/library/
<seasonId>` falls through the detail page's `kind` chain into the generic
"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season
must render as a row list). Worse, clicking an episode from that grid opens a
*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the
episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and
the broken Play button above.
**3. Too many video library routes.** Seven routes serve two media types, and the
naming does not even agree with itself: `/library/tv` + `/library/tv/shows` +
`/library/shows/genres` versus `/library/movies` + `/library/movies/all` +
`/library/movies/genres`. The genre routes do not share a prefix, which
`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are
27-line config wrappers over the same `GenericMediaListPage`.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. |
| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. |
| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. |
| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. |
| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. |
| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. |
| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. |
| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). |
| Which route Play navigates to | Frontend | Navigation is presentation. |
| Redirecting `/library/<seasonId>` to the series anchor | Frontend | Route topology. |
| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. |
| Library page tabs and the `?view=` param | Frontend | View preference and route topology. |
Borderline row — **the strip's cross-season *ordering*** is Rust (it is series
order, above), but the *window* taken from that ordered list is frontend. The
tie-breaker: the list handed to the frontend is already correct and complete;
choosing how much of it fits on screen is layout.
## Design
### Rust: the current-episode policy
Two new pieces, split so the policy is unit-testable without a repository.
**Pure policy** — `src-tauri/src/repository/series_progress.rs`:
```rust
/// Series order: season index asc, then episode index asc. Specials (season 0)
/// sort after every numbered season rather than before season 1.
pub fn sort_series_order(episodes: &mut [MediaItem]);
/// The episode a viewer should land on, given everything already fetched.
/// Order: in-progress episode → Next Up → first unwatched → first episode.
pub fn pick_current_episode(
episodes: &[MediaItem], // series order
next_up: &[MediaItem],
resume: &[MediaItem],
) -> Option<MediaItem>;
```
Why that order:
- **In-progress wins** because a partially-watched episode is literally where
the viewer stopped; Next Up would skip past it. Ties break toward the earliest
in series order, so a viewer who dipped into a later episode still resumes the
one they are actually working through.
- **Next Up second** because it is the server's own answer, and it accounts for
history we do not cache.
- **First unwatched third** — the offline repository returns an empty vec for
Next Up (`offline.rs:1247`), so without this fallback the whole feature would
be online-only. This is the offline path, not dead code.
- **First episode last** so a never-watched series lands on S1E1 rather than
nothing.
A `resume`/`next_up` entry that is not among `episodes` is still honoured — it
comes from the same server and may carry an id the season fan-out missed — but
it must belong to this series.
**Fetch + command** — `src-tauri/src/commands/repository.rs`:
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Vec<MediaItem>, String>
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Option<MediaItem>, String>
```
Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule.
`repository_get_series_episodes` performs the fan-out the frontend used to do:
`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the
flat-series fallback (a series whose children are episodes, not seasons), then
`sort_series_order`. `repository_get_series_current_episode` calls it, adds
`get_next_up_episodes(Some(series_id), Some(1))` and
`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`.
Both tolerate a failing Next Up (offline) by treating it as empty rather than
failing the whole call.
### Frontend: series page
- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out
over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor.
Season *headers* still come from `get_items(seriesId)`; the page groups the
returned episodes under them by `parentIndexNumber`.
- No `?episode=` param → series view, `SeasonSection` receives
`currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into
view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism,
now distinguishing *focused* from *current*).
- Seasons are collapsible and **only the current season is expanded**
(`initialExpandedSeasons`). Without this a ten-season show renders every
episode of every season at once and buries the one the viewer came for. A
collapsed season still shows its episode count and watched count, so progress
is legible without expanding. Toggle state is local and not persisted — it is
a reading position, not a preference.
- Hero Play → `goto(/library/<seriesId>?episode=<currentId>)`, i.e. the Episode
Focus View, where an explicit Play/Resume starts playback. This follows
ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is
navigation; Play on a *leaf* (the focus view, a movie) commits.
- Clicking an episode in a season section → `?episode=` swap, not
`/player/<id>`. §5B.1.
### Frontend: seasons are not a destination
`/library/<seasonId>` resolves the season's `seriesId` and redirects to
`/library/<seriesId>#season-<indexNumber>`; `SeasonSection` renders that anchor
id. A season with no `seriesId` (deep link into a stale cache) keeps the old
generic rendering as a fallback so the user is never stranded. Inbound links
updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing
page's `case "Season"`, and `DownloadedBrowse`.
### Erasing watch history
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String>
```
`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}`
Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume
position, and which the server applies recursively to a folder. One call
therefore handles a whole series or a single season; no per-episode fan-out.
`OfflineRepository` returns `RepoError::Offline` rather than clearing locally,
because divergent local history is undone by the next sync.
`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each
`SeasonSection` header (`scope="season"`). It confirms first — there is no undo —
disables itself while the server is unreachable, and reloads the page on success
so the recomputed current episode is what the viewer sees. Clearing a whole
series therefore returns it to S1E1, which is the same path a never-watched
series takes through `pick_current_episode`.
### Frontend: one route per video library
`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs,
rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components
inline. `?view=` is omitted for `browse` (the default) to keep URLs clean —
the same convention `searchRouteUrl` uses for the `all` scope.
The four legacy routes become redirect-only `+page.ts` loads:
| Legacy | Redirects to |
|--------|--------------|
| `/library/tv/shows` | `/library/tv?view=all` |
| `/library/shows/genres` | `/library/tv?view=genres` |
| `/library/movies/all` | `/library/movies?view=all` |
| `/library/movies/genres` | `/library/movies?view=genres` |
They are kept (rather than deleted) because `GenreTags` builds links to them and
users may have them in history. `resolveSearchScope` keeps its `/library/shows`
branch for the same reason.
The "Browse" tile grid at the bottom of both landing pages is removed — the tabs
replace it, and the tiles were a second navigation affordance to the same two
destinations the carousels' "Show all" links already reach.
## Out of scope
- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is
still season-bounded, so autoplay stops at a season boundary. Fixing it should
reuse `repository_get_series_episodes`, but it touches the playback state
machine and the Android JNI advance path (see the `AutoplayDecision` deadlock
note in CLAUDE.md) and belongs in its own change.
- **Music library routes.** `/library/music/*` has five sub-routes with the same
shape; the same consolidation applies but is not done here.
- **Marking a series' progress** (mark-watched / mark-unwatched from the series
page).
+1304 -780
View File
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -688,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly.
### 5B.4 Series detail — section order
```
Hero (poster, title, metadata, Play / Download)
Hero (poster, title, metadata, Resume SxEy / Download / Clear history)
→ Crew links
→ Genre tags
→ Seasons + episodes (per-season sections)
→ Seasons (collapsible; only the current season expanded)
→ Cast
→ More Like This
```
@@ -700,6 +700,29 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
The reason a user opens a series page is to pick an episode; discovery content
is secondary and sits underneath.
**Rules for the seasons block** *(UR-062, UR-064)*:
- **The page opens where the viewer is.** The backend resolves the current
episode — in progress, else Next Up, else first unwatched, else the premiere —
and the page scrolls it into view with an `Up next` badge and a highlight ring.
Never season 1 by default, unless season 1 *is* where the viewer is.
- **Seasons collapse; only the current one is expanded.** A ten-season show
otherwise renders hundreds of rows and buries the episode the viewer came for.
A collapsed season still names its episode count and watched count, so
progress is readable without expanding it.
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
*leaf* is the commitment.
- **A season is never its own page.** `/library/<seasonId>` redirects to
`/library/<seriesId>#season-N`. Every affordance that names a season — the
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
the series with that season in view, so the episodes of all seasons stay one
browsable list.
- **Watch history is erasable** per series (hero) and per season (season
header). It confirms first, cannot be undone, and needs the server. Clearing a
whole series returns it to S1E1 by the same path a never-watched show takes.
### 5B.5 Home-card interaction — tap opens, long-press plays
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.2.1",
"version": "0.3.0",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -20,6 +20,8 @@
"check:boundary": "bash scripts/check-frontend-boundary.sh",
"android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release",
"android:build:device": "./scripts/build-android.sh --device",
"android:build:release:device": "./scripts/build-android.sh release --device",
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
"android:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh",
+37 -2
View File
@@ -18,15 +18,50 @@ echo ""
# Parse args: build type (debug/release) and optional --clean flag.
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
#
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
# which is what a distributable universal APK needs — but for an on-device test
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
# only the connected device's architecture; --abi <t> targets one explicitly.
BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}"
ABI="${ABI:-}"
next_is_abi=0
for arg in "$@"; do
if [ "$next_is_abi" = "1" ]; then
ABI="$arg"
next_is_abi=0
continue
fi
case "$arg" in
--clean) CLEAN=1 ;;
--abi) next_is_abi=1 ;;
--device) ABI="device" ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
# Resolve --device to the attached device's Rust target triple.
if [ "$ABI" = "device" ]; then
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
case "$device_abi" in
arm64-v8a) ABI="aarch64" ;;
armeabi-v7a) ABI="armv7" ;;
x86_64) ABI="x86_64" ;;
x86) ABI="i686" ;;
*)
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
ABI=""
;;
esac
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
fi
TARGET_ARGS=()
if [ -n "$ABI" ]; then
TARGET_ARGS=(--target "$ABI")
fi
# Step 0: Optionally clear build caches for a fully fresh build.
if [ "$CLEAN" = "1" ]; then
echo "🧹 Clearing build caches (clean build)..."
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
# after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh
echo "📦 Building release APK..."
bun run tauri android build --apk true
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
else
echo "📦 Building debug APK..."
bun run tauri android build --apk true --debug
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
fi
echo ""
+3 -3
View File
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(61);
expect(defined.UR).toBe(64);
expect(defined.IR).toBe(29);
expect(defined.DR).toBe(91);
expect(defined.DR).toBe(104);
expect(defined.JA).toBe(32);
expect(defined.total).toBe(213);
expect(defined.total).toBe(229);
});
});
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.2.1"
version = "0.3.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
+10 -28
View File
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// Base offset (seconds) for the active background-audio handoff.
///
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
/// position, so the server makes that point the stream's zero. ExoPlayer then
/// reports position RELATIVE to that zero. To convert back to an absolute
/// position on exit (so the video resumes where the audio actually reached), we
/// add this stored base to the native player's reported position.
///
/// TRACES: UR-040 | DR-052
#[derive(Default)]
pub struct BackgroundAudioOffset(pub Mutex<f64>);
/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -586,7 +574,6 @@ pub async fn player_play_item(
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
@@ -636,17 +623,18 @@ pub async fn player_enter_background_audio(
session_mgr.start_audio_session(media_item.clone());
}
// Remember where the video was: the audio stream's zero == this position
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
// this base to the native player's relative position to get the absolute one.
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
let controller = player.0.lock().await;
// Remember where the video was: the audio stream's zero == this position
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
// this base to the native player's relative position to get the absolute one.
// The controller owns it so a backend-driven advance to the next episode
// clears it along with the stream it described.
controller.set_background_audio_base(position_seconds);
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
@@ -677,21 +665,15 @@ pub async fn player_enter_background_audio(
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
) -> Result<f64, String> {
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Read/reset the base first.
let base = {
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
let b = *off;
*off = 0.0;
b
};
// 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.take_background_audio_base();
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position();
+9 -2
View File
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly
///
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
#[tauri::command]
#[specta::specta]
pub async fn player_on_playback_ended(
@@ -242,12 +244,17 @@ pub async fn player_on_playback_ended(
});
}
// Start countdown if auto_advance enabled
// Advance if auto_advance is enabled. This is the path that actually
// runs on Android: the JNI callback's own decision is swallowed by the
// NewTrackLoaded end reason set at load, so it returns Stop, emits
// PlaybackEnded, and the frontend echoes it back into this command —
// which is where the real decision lands.
if auto_advance {
controller_arc
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
}
+67 -1
View File
@@ -15,7 +15,8 @@ use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
OnlineRepository,
};
/// Repository handle manager
@@ -320,6 +321,71 @@ pub async fn repository_get_next_up_episodes(
.map_err(|e| format!("{:?}", e))
}
/// Every episode of a series, across all seasons, in series order.
///
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
/// children are episodes directly. Both shapes are provider vocabulary, so the
/// fan-out and its fallback live in Rust rather than being reimplemented in the
/// frontend (which is what it used to do).
///
/// TRACES: UR-062 | DR-101
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// The episode a viewer should land on when they open a series.
///
/// "Current" is domain policy, not layout: an episode in progress, else the
/// server's Next Up for the series, else the first unwatched episode, else the
/// first. The third rung is what makes this work offline, where Next Up is
/// always empty. Returns `None` only when the series has no episodes at all.
///
/// TRACES: UR-062 | DR-101
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Option<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Erase the viewer's watch history for an item.
///
/// Clears the played flag and the resume position; on a series or season the
/// server applies it to everything inside. A series cleared this way is "never
/// watched" again, so `repository_get_series_current_episode` returns its
/// premiere. Requires the server — offline this fails rather than diverging
/// local state the next sync would overwrite.
///
/// TRACES: UR-064 | DR-106
#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.clear_watch_history(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get recently played audio
#[tauri::command]
#[specta::specta]
+6 -3
View File
@@ -178,6 +178,7 @@ use commands::{
remote_session_set_volume,
remote_session_toggle_mute,
// Repository commands
repository_clear_watch_history,
repository_create,
repository_destroy,
repository_get_audio_only_stream_url_for_video,
@@ -201,6 +202,8 @@ use commands::{
repository_get_rediscover_albums,
repository_get_resume_items,
repository_get_resume_movies,
repository_get_series_current_episode,
repository_get_series_episodes,
repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
@@ -869,6 +872,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_latest_items,
repository_get_resume_items,
repository_get_next_up_episodes,
repository_get_series_episodes,
repository_get_series_current_episode,
repository_clear_watch_history,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_rediscover_albums,
@@ -1196,9 +1202,6 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Background-audio handoff base offset (UR-040).
app.manage(commands::player::BackgroundAudioOffset::default());
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+10 -30
View File
@@ -915,39 +915,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
}
if auto_advance {
// Background audio-only episode: the frontend that normally
// performs the advance (goto /player/<id>) is suspended, so
// the backend must load the next episode's audio-only stream
// itselfotherwise playback just stops at the boundary.
let is_bg_audio_episode =
controller.lock().await.current_is_audio_episode();
if is_bg_audio_episode {
log::info!(
"[Autoplay] Background audio episode — advancing to {} in backend",
next_episode.id
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
log::error!(
"[Autoplay] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
ctrl.emit_queue_changed();
}
} else {
// Foreground: frontend drives the advance off the countdown.
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Err(e) => {
+436 -5
View File
@@ -105,7 +105,7 @@ pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use log::{debug, error, info, warn};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
@@ -152,6 +152,32 @@ pub struct PlayerController {
// Auto-play episode counter (session-based, resets on manual play)
autoplay_episode_count: Arc<Mutex<u32>>,
// Base offset (seconds) of the active background-audio handoff.
//
// The audio-only stream is requested with `StartTimeTicks` = the position the
// video was handed off at, so the server makes that point the stream's zero
// and the native player reports position RELATIVE to it. Adding this base back
// yields the absolute position to resume the video at on the way out.
//
// Lives on the controller (not beside the command) because the queue and this
// offset describe the same stream: whenever the controller loads a different
// one — notably the backend-driven advance to the next episode — the base has
// to move with it.
//
// TRACES: UR-040 | DR-052
background_audio_base: Arc<Mutex<f64>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
// REPORTED state here is what lets transport (play/pause/toggle) be decided
// in Rust for that media instead of the frontend reading `el.paused` off the
// DOM — a value that flips transiently while buffering/seeking and caused
// competing intents to take opposing actions. `None` means no webview media
// is active and the native backend is authoritative. See DR-097.
html5_playing: Arc<Mutex<Option<bool>>>,
}
impl PlayerController {
@@ -174,6 +200,8 @@ impl PlayerController {
position_throttler,
end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)),
background_audio_base: Arc::new(Mutex::new(0.0)),
html5_playing: Arc::new(Mutex::new(None)),
};
// Start background timer thread for sleep timer countdown
@@ -476,21 +504,72 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
pub fn is_html5_active(&self) -> bool {
self.html5_playing.lock_safe().is_some()
}
/// Whether the webview element last reported itself as playing. Meaningless
/// unless [`Self::is_html5_active`] is true.
///
/// TRACES: UR-005 | DR-097
pub fn html5_is_playing(&self) -> bool {
self.html5_playing.lock_safe().unwrap_or(false)
}
/// Send a transport intent to the webview element that is rendering media.
fn emit_html5_control(&self, action: &str) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position: None,
});
}
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
// Webview-rendered media: the native backend isn't playing it, so drive
// the element via a ControlCommand instead (DR-097).
if self.is_html5_active() {
self.emit_html5_control("play");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
self.emit_html5_control("pause");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
let action = if self.html5_is_playing() {
"pause"
} else {
"play"
};
self.emit_html5_control(action);
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
backend.pause()
@@ -890,6 +969,23 @@ impl PlayerController {
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
// Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to
// the native backend — otherwise music playback would keep emitting
// ControlCommands at a element that no longer exists.
{
let mut tracked = self.html5_playing.lock_safe();
*tracked = match state.as_str() {
"playing" => Some(true),
// "loading" counts as active-but-not-playing so a toggle during
// load resolves to "play" rather than falling through to the
// native backend.
"paused" | "loading" => Some(false),
// "stopped"/"idle": element is gone, native backend resumes authority.
_ => None,
};
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
}
@@ -1090,6 +1186,68 @@ impl PlayerController {
}
}
/// Record the base offset of a background-audio handoff (the position the
/// video was handed off at, which is the audio stream's zero).
///
/// TRACES: UR-040 | DR-052
pub fn set_background_audio_base(&self, seconds: f64) {
*self.background_audio_base.lock_safe() = seconds.max(0.0);
}
/// Read and clear the background-audio base offset.
///
/// TRACES: UR-040 | DR-052
pub fn take_background_audio_base(&self) -> f64 {
let mut base = self.background_audio_base.lock_safe();
std::mem::replace(&mut *base, 0.0)
}
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
///
/// Single place both end-of-playback dispatchers agree on: the Android JNI
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
/// (`player_on_playback_ended`). They used to each carry their own copy of
/// this branch, and the command's copy was missing the background-audio case
/// entirely — so an audio-only episode ending while backgrounded only ever
/// started a countdown that nothing could act on.
///
/// TRACES: UR-040, UR-023 | DR-052
pub async fn auto_advance_to_next_episode(
&self,
next_episode: crate::repository::types::MediaItem,
countdown_seconds: u32,
) {
// Background audio-only episode: the countdown only emits ticks — the
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
// start audio while the app is backgrounded. Load the next episode's
// audio-only stream here instead, or playback stalls at the boundary.
if self.current_is_audio_episode() {
info!(
"[PlayerController] Background audio episode — advancing to {} in backend",
next_episode.id
);
match self
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
Ok(()) => self.emit_queue_changed(),
Err(e) => {
error!(
"[PlayerController] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = self.event_emitter() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
return;
}
// Foreground: the frontend drives the advance off the countdown ticks.
self.start_autoplay_countdown(next_episode, countdown_seconds);
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
@@ -1100,10 +1258,10 @@ impl PlayerController {
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
/// Reached through `auto_advance_to_next_episode`, which gates it on
/// `current_is_audio_episode()` — only ever true after a background-audio
/// handoff (Android), but compiled and unit-tested on every platform.
/// TRACES: UR-040, UR-023 | DR-052
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
@@ -1158,6 +1316,13 @@ impl PlayerController {
server_id: Some(next.server_id.clone()),
};
// The previous episode's handoff base described the stream we are leaving.
// This one is built without StartTimeTicks, so its timeline is already
// absolute: clear the base (used to resolve the resume position on the way
// back to the foreground) and the lockscreen scrubber's matching shift.
self.set_background_audio_base(0.0);
let _ = set_lockscreen_position_offset(0.0);
self.play_item(media_item).map_err(|e| e.to_string())
}
@@ -1489,6 +1654,156 @@ mod tests {
}
}
// ===== HTML5 transport authority (DR-097) =====
//
// Webview-rendered video is played by an element the native backend cannot
// reach, so transport for it must be decided from the state the element
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
// flips transiently while buffering/seeking — two intents ~150ms apart read
// different values, took opposing actions, and self-sustained a pause loop.
#[test]
fn test_html5_state_is_tracked_from_reports() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// No HTML5 media reported yet: the native backend stays authoritative.
assert!(!controller.is_html5_active());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(!controller.html5_is_playing());
}
#[test]
fn test_html5_toggle_from_paused_emits_play_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string()]);
}
#[test]
fn test_html5_toggle_from_playing_emits_pause_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string()]);
}
#[test]
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
// The loop signature: two intents in quick succession must NOT both
// resolve the same way, and must not produce opposing actions from a
// stale read. Rust's own tracked state makes the sequence deterministic
// as long as the element reports back between intents.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
// Element confirms the pause it was told to do.
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
}
#[test]
fn test_html5_play_and_pause_emit_control_commands() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.play().unwrap();
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
}
#[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native
// backend (music playback must not keep emitting ControlCommands).
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
controller.report_html5_state("stopped".to_string(), None);
assert!(!controller.is_html5_active());
}
#[test]
fn test_html5_transport_emits_exactly_one_control_per_intent() {
// Guards against a double-drive on platforms where the *backend* is also
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
// must replace the backend call, not run in addition to it.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.pause().unwrap();
let controls = emitter
.events()
.into_iter()
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
.count();
assert_eq!(controls, 1, "one intent must produce exactly one control");
}
#[test]
fn test_controller_volume_default() {
let controller = PlayerController::default();
@@ -2547,6 +2862,9 @@ mod tests {
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(
&self,
_: &str,
@@ -2764,6 +3082,119 @@ mod tests {
assert!(controller.current_is_audio_episode());
}
/// The handoff base offset describes ONE stream: the audio-only URL built
/// with `StartTimeTicks` = the position the video was handed off at, whose
/// timeline therefore starts at that point. The next episode is loaded from
/// its own beginning, so its timeline is already absolute and the base must
/// be cleared — otherwise returning to the foreground resolves the resume
/// position as `old_base + position_in_new_episode` and the video jumps to a
/// point that has nothing to do with what was playing.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Handed off 20 minutes into the previous episode.
controller.set_background_audio_base(1200.0);
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
assert_eq!(
controller.take_background_audio_base(),
0.0,
"the next episode starts at its own zero, so the previous handoff \
base must not survive the advance"
);
}
/// A background audio-only episode must advance IN THE BACKEND when the
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
/// countdown the frontend is supposed to act on.
///
/// The countdown only emits CountdownTick events; the actual advance is a
/// `goto('/player/<id>')` in the webview. While the app is backgrounded that
/// navigation cannot start audio, so playback stalls at the episode boundary
/// with ExoPlayer parked in STATE_ENDED — and any later play intent
/// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
/// start, which is what surfaces to the user as "the episode randomly
/// restarted".
#[tokio::test]
async fn test_auto_advance_background_audio_episode_advances_in_backend() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Currently playing: ep2 handed off to audio-only background playback.
let episode = MediaItem {
id: "ep2".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio,
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.mp3".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
let next = make_repo_episode("ep3", 3);
controller.auto_advance_to_next_episode(next, 10).await;
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should still be loaded");
assert_eq!(
current.id, "ep3",
"background audio-only episode must advance in the backend, not wait \
for a frontend navigation that cannot happen while backgrounded"
);
assert_eq!(current.media_type, MediaType::Audio);
assert!(controller.current_is_audio_episode());
}
/// Foreground video playback keeps the countdown-driven advance: the frontend
/// owns the navigation there, so the backend must NOT load the next episode
/// itself (that would race the page transition and double-start playback).
#[tokio::test]
async fn test_auto_advance_foreground_video_episode_uses_countdown() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
let episode = MediaItem {
id: "ep2".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Video,
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
let next = make_repo_episode("ep3", 3);
controller.auto_advance_to_next_episode(next, 10).await;
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should still be loaded");
assert_eq!(
current.id, "ep2",
"foreground video advance is frontend-driven; the backend must not \
swap the queue item out from under it"
);
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
+13
View File
@@ -750,6 +750,11 @@ impl MediaRepository for HybridRepository {
self.online.unmark_favorite(item_id).await
}
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
// Write operations go directly to server
self.online.clear_watch_history(item_id).await
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -1128,6 +1133,10 @@ mod tests {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
unimplemented!()
}
@@ -1386,6 +1395,10 @@ mod tests {
unimplemented!()
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
unimplemented!()
}
+9
View File
@@ -1,6 +1,7 @@
pub mod hybrid;
pub mod offline;
pub mod online;
pub mod series_progress;
pub mod types;
pub use hybrid::HybridRepository;
@@ -211,6 +212,14 @@ pub trait MediaRepository: Send + Sync {
/// Unmark item as favorite
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
/// Erase the viewer's watch history for an item: clear its played flag and
/// its resume position. On a container (series, season) this applies to
/// everything inside it, so a series is returned to "never watched" and
/// reopens on its premiere.
///
/// TRACES: UR-064 | DR-106
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
/// Get person details
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
+6
View File
@@ -1627,6 +1627,12 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
// Erasing history has to reach the server to be meaningful — clearing
// it only locally would be silently undone by the next sync.
Err(RepoError::Offline)
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let query = Query::with_params(
"SELECT id, name, overview, primary_image_tag
+42
View File
@@ -1691,6 +1691,48 @@ impl MediaRepository for OnlineRepository {
result
}
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
/// unplayed", which also zeroes the resume position. On a folder (series,
/// season) the server applies it recursively to the children.
///
/// TRACES: UR-064 | DR-106, JA-033
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
.await;
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
+433
View File
@@ -0,0 +1,433 @@
//! Where a viewer is in a TV series.
//!
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
//! means ("in progress", "played") and what Jellyfin's season numbering means
//! (season 0 is specials). The frontend asks for *the* current episode and
//! renders it; it does not get to decide what "current" means.
//!
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
//! can be unit-tested without standing up a repository.
//!
//! TRACES: UR-062 | DR-101
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
/// Jellyfin files specials under season 0.
const SPECIALS_SEASON: i32 = 0;
/// Below this fraction watched, a position is a false start rather than
/// progress — the same threshold the resume dialog uses.
const MIN_PROGRESS_FRACTION: f64 = 0.01;
/// Above this fraction watched, an episode is effectively finished; resuming it
/// would drop the viewer into the closing credits.
const MAX_PROGRESS_FRACTION: f64 = 0.95;
/// Sort key for a season number. Specials sort *after* every numbered season:
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
/// not lead just because `0 < 1`.
fn season_rank(season: Option<i32>) -> i64 {
match season {
Some(SPECIALS_SEASON) => i64::MAX,
Some(n) => n as i64,
None => i64::MAX - 1,
}
}
/// Order episodes as the series is watched: season ascending, then episode,
/// specials last.
pub fn sort_series_order(episodes: &mut [MediaItem]) {
episodes.sort_by(|a, b| {
season_rank(a.parent_index_number)
.cmp(&season_rank(b.parent_index_number))
.then(
a.index_number
.unwrap_or(0)
.cmp(&b.index_number.unwrap_or(0)),
)
});
}
/// Is this episode genuinely part-watched (not a false start, not finished)?
fn is_in_progress(item: &MediaItem) -> bool {
let Some(user_data) = item.user_data.as_ref() else {
return false;
};
if user_data.is_played.unwrap_or(false) {
return false;
}
let position_ms = user_data
.playback_position_ms
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
.unwrap_or(0);
if position_ms <= 0 {
return false;
}
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
// so any recorded position counts as progress.
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
return true;
};
let fraction = position_ms as f64 / duration_ms as f64;
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
}
fn is_played(item: &MediaItem) -> bool {
item.user_data
.as_ref()
.and_then(|u| u.is_played)
.unwrap_or(false)
}
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
item.series_id.as_deref() == Some(series_id)
}
/// The episode a viewer should land on when they open `series_id`.
///
/// Order of preference, and why:
///
/// 1. **An episode in progress.** That is literally where playback stopped;
/// Next Up would skip past it. On a tie the earliest in series order wins, so
/// a viewer who dipped into a later episode still returns to the one they are
/// working through.
/// 2. **The server's Next Up** for this series — it accounts for watch history
/// we do not cache locally.
/// 3. **The first unwatched episode** in series order. This is the offline path:
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
/// this rung the whole feature would be online-only.
/// 4. **The first episode**, so a never-watched series opens on its premiere
/// rather than on nothing.
///
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
/// (the season fan-out can miss an id the server returns), but only when they
/// belong to this series.
pub fn pick_current_episode(
series_id: &str,
episodes: &[MediaItem],
next_up: &[MediaItem],
resume: &[MediaItem],
) -> Option<MediaItem> {
// 1. In progress — prefer a match inside the ordered episode list so the
// "earliest in series order" tie-break is meaningful; fall back to the
// resume feed for an episode the fan-out missed.
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
return Some(found.clone());
}
if let Some(found) = resume
.iter()
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
{
return Some(found.clone());
}
// 2. Next Up for this series.
if let Some(found) = next_up
.iter()
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
{
// Prefer the copy from `episodes` when we have one: it carries the
// user-data and images the list already fetched.
let matched = episodes.iter().find(|e| e.id == found.id);
return Some(matched.unwrap_or(found).clone());
}
// 3. First unwatched in series order.
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
return Some(found.clone());
}
// 4. First episode — a fully-watched series reopens at the start.
episodes.first().cloned()
}
/// Every episode of a series, in series order.
///
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
/// children are episodes directly. Both shapes are provider vocabulary, so the
/// fan-out and the fallback live here rather than in the frontend.
pub async fn fetch_series_episodes(
repo: &dyn MediaRepository,
series_id: &str,
) -> Result<Vec<MediaItem>, RepoError> {
let children = repo.get_items(series_id, list_options()).await?;
let mut episodes: Vec<MediaItem> = Vec::new();
for season in children.items.iter().filter(|i| is_season(i)) {
// One failing season must not blank the whole show.
match repo.get_items(&season.id, list_options()).await {
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
Err(e) => {
log::warn!(
"[series] season {} of {} failed to load: {:?}",
season.id,
series_id,
e
);
}
}
}
// Flat series: the children *are* the episodes.
if episodes.is_empty() {
episodes.extend(children.items.into_iter().filter(is_episode));
}
sort_series_order(&mut episodes);
Ok(episodes)
}
/// Resolve the current episode, fetching everything the policy needs.
///
/// Next Up and resume are best-effort: offline they fail or come back empty, and
/// `pick_current_episode` has fallbacks for exactly that.
pub async fn resolve_current_episode(
repo: &dyn MediaRepository,
series_id: &str,
) -> Result<Option<MediaItem>, RepoError> {
let episodes = fetch_series_episodes(repo, series_id).await?;
let next_up = repo
.get_next_up_episodes(Some(series_id), Some(1))
.await
.unwrap_or_default();
let resume = repo
.get_resume_items(Some(series_id), Some(10))
.await
.unwrap_or_default();
Ok(pick_current_episode(
series_id, &episodes, &next_up, &resume,
))
}
fn list_options() -> Option<GetItemsOptions> {
Some(GetItemsOptions {
limit: Some(500),
..Default::default()
})
}
fn is_season(item: &MediaItem) -> bool {
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
}
fn is_episode(item: &MediaItem) -> bool {
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::UserData;
const SERIES: &str = "series-1";
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
MediaItem {
id: id.to_string(),
name: format!("S{season}E{number}"),
item_type: "Episode".to_string(),
series_id: Some(SERIES.to_string()),
parent_index_number: Some(season),
index_number: Some(number),
duration_ms: Some(1_000_000),
..Default::default()
}
}
fn watched(mut item: MediaItem) -> MediaItem {
item.user_data = Some(UserData {
is_played: Some(true),
..Default::default()
});
item
}
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
item.user_data = Some(UserData {
is_played: Some(false),
playback_position_ms: Some((duration * fraction) as i64),
..Default::default()
});
item
}
fn season(n: i32, count: i32) -> Vec<MediaItem> {
(1..=count)
.map(|i| episode(&format!("s{n}e{i}"), n, i))
.collect()
}
#[test]
fn sorts_by_season_then_episode() {
let mut eps = vec![
episode("b", 2, 1),
episode("d", 1, 10),
episode("a", 1, 2),
episode("c", 2, 2),
];
sort_series_order(&mut eps);
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["a", "d", "b", "c"]);
}
#[test]
fn sorts_specials_after_numbered_seasons() {
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
sort_series_order(&mut eps);
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["premiere", "special"]);
}
#[test]
fn picks_the_in_progress_episode_over_next_up() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
eps[1] = in_progress(eps[1].clone(), 0.4);
// The server would send us past it; the half-watched episode wins.
let next_up = vec![episode("s1e3", 1, 3)];
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
#[test]
fn picks_the_earliest_in_progress_episode() {
let mut eps = season(1, 5);
eps[1] = in_progress(eps[1].clone(), 0.3);
eps[3] = in_progress(eps[3].clone(), 0.5);
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
#[test]
fn ignores_a_false_start_and_a_finished_episode() {
let mut eps = season(1, 5);
eps[0] = watched(eps[0].clone());
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
// Neither counts as progress, so Next Up decides.
let next_up = vec![episode("s1e4", 1, 4)];
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
assert_eq!(current.id, "s1e4");
}
#[test]
fn falls_back_to_next_up_when_nothing_is_in_progress() {
let eps = season(1, 5);
let next_up = vec![episode("s1e3", 1, 3)];
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
assert_eq!(current.id, "s1e3");
}
#[test]
fn next_up_from_another_series_is_ignored() {
let eps = season(1, 3);
let mut foreign = episode("other-show-ep", 1, 1);
foreign.series_id = Some("series-2".to_string());
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
assert_eq!(current.id, "s1e1");
}
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
/// empty vec, so the first unwatched episode has to carry the feature.
#[test]
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
let mut eps = [season(1, 3), season(2, 3)].concat();
for ep in eps.iter_mut().take(4) {
*ep = watched(ep.clone());
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s2e2");
}
#[test]
fn crosses_a_season_boundary_when_a_season_is_finished() {
let mut eps = [season(1, 3), season(2, 3)].concat();
for ep in eps.iter_mut().take(3) {
*ep = watched(ep.clone());
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s2e1");
}
#[test]
fn a_never_watched_series_opens_on_its_premiere() {
let eps = [season(2, 3), season(1, 3)].concat();
let mut ordered = eps.clone();
sort_series_order(&mut ordered);
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
assert_eq!(current.id, "s1e1");
}
#[test]
fn a_fully_watched_series_reopens_at_the_start() {
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e1");
}
#[test]
fn honours_a_resume_entry_missing_from_the_episode_list() {
// Season fan-out returned nothing usable, but the resume feed knows.
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
assert_eq!(current.id, "s3e7");
}
#[test]
fn resume_entries_from_other_series_are_ignored() {
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
foreign.series_id = Some("series-2".to_string());
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
}
#[test]
fn a_series_with_no_episodes_has_no_current_episode() {
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
}
#[test]
fn an_episode_without_a_duration_still_counts_as_in_progress() {
let mut ep = episode("s1e2", 1, 2);
ep.duration_ms = None;
ep.user_data = Some(UserData {
is_played: Some(false),
playback_position_ms: Some(120_000),
..Default::default()
});
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
#[test]
fn legacy_tick_positions_still_register_as_progress() {
let mut ep = episode("s1e2", 1, 2);
ep.user_data = Some(UserData {
is_played: Some(false),
// 400_000 ms expressed in Jellyfin ticks, no ms field.
playback_position_ticks: Some(400_000 * 10_000),
..Default::default()
});
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub struct Library {
}
/// User-specific data for an item (playback state, favorites, etc.)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserData {
/// Legacy Jellyfin resume position in ticks. Being replaced by
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.2.1",
"version": "0.3.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+42
View File
@@ -237,6 +237,8 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
* TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
*/
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
@@ -1265,6 +1267,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
},
/**
* Every episode of a series, across all seasons, in series order.
*
* Jellyfin hangs episodes off season folders except for "flat" series whose
* children are episodes directly. Both shapes are provider vocabulary, so the
* fan-out and its fallback live in Rust rather than being reimplemented in the
* frontend (which is what it used to do).
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId });
},
/**
* The episode a viewer should land on when they open a series.
*
* "Current" is domain policy, not layout: an episode in progress, else the
* server's Next Up for the series, else the first unwatched episode, else the
* first. The third rung is what makes this work offline, where Next Up is
* always empty. Returns `None` only when the series has no episodes at all.
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
},
/**
* Erase the viewer's watch history for an item.
*
* Clears the played flag and the resume position; on a series or season the
* server applies it to everything inside. A series cleared this way is "never
* watched" again, so `repository_get_series_current_episode` returns its
* premiere. Requires the server offline this fails rather than diverging
* local state the next sync would overwrite.
*
* TRACES: UR-064 | DR-106
*/
async repositoryClearWatchHistory(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId });
},
/**
* Get recently played audio
*/
+31
View File
@@ -137,6 +137,37 @@ export class RepositoryClient {
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
}
/**
* Every episode of a series, across all seasons, already in series order.
* The backend owns the season fan-out and the flat-series fallback.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
}
/**
* The episode the viewer should land on when opening this series. `null` only
* when the series has no episodes.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
}
/**
* Erase watch history for an item. On a series or season the server applies
* it to everything inside, so the container returns to "never watched".
* Requires the server this fails offline rather than diverging local state.
*
* TRACES: UR-064 | DR-106
*/
async clearWatchHistory(itemId: string): Promise<void> {
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
}
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
}
@@ -14,6 +14,7 @@
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
import { formatBytes } from "$lib/utils/formatBytes";
import {
downloadedCatalog,
@@ -62,6 +63,16 @@
void openLibrary(item as Library);
return;
}
// Seasons and episodes resolve inside their series (DR-103): a season has
// no page of its own and an episode is never browsed bare.
if (item.kind === "season") {
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
return;
}
if (item.kind === "episode") {
goto(episodeFocusHref(item));
return;
}
goto(`/library/${item.id}`);
}
@@ -0,0 +1,94 @@
<!--
Erase watch history for a series or a season.
The backend does the work (`repository_clear_watch_history` → Jellyfin's
mark-unplayed, which is recursive over a container and also zeroes resume
positions); this only confirms the intent and reports the outcome. Clearing a
series returns it to "never watched", so it reopens on S1E1.
TRACES: UR-064 | DR-106
-->
<script lang="ts">
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
interface Props {
/** Series or season id to clear. */
itemId: string;
/** Name shown in the confirm prompt. */
itemName: string;
/** What is being cleared, for the prompt wording. */
scope: "series" | "season";
size?: "sm" | "lg";
/** Called after a successful clear so the caller can reload. */
onCleared?: () => void;
}
let { itemId, itemName, scope, size = "lg", onCleared }: Props = $props();
let busy = $state(false);
const label = $derived(scope === "series" ? "Clear history" : "Clear season history");
async function handleClick() {
if (busy) return;
const subject = scope === "series" ? `all of “${itemName}”` : `“${itemName}”`;
// Destructive and not undoable — always ask, even though the server keeps
// no undo of its own.
if (
!confirm(
`Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone."
)
) {
return;
}
busy = true;
try {
await auth.getRepository().clearWatchHistory(itemId);
onCleared?.();
} catch (e) {
console.error("Failed to clear watch history:", e);
alert(
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
} finally {
busy = false;
}
}
</script>
<button
onclick={handleClick}
disabled={busy || !$isServerReachable}
title={$isServerReachable
? "Mark everything unwatched and clear resume positions"
: "Needs a connection to the server"}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]
disabled:opacity-40 disabled:cursor-not-allowed
{size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm'}"
>
{#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}
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
/>
</svg>
{/if}
{busy ? "Clearing…" : label}
</button>
@@ -4,7 +4,11 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
stripCardLabel,
} from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -245,8 +249,8 @@
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
{ep.indexNumber || 0}.
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
+20 -3
View File
@@ -10,15 +10,21 @@
interface Props {
episode: MediaItem;
focused?: boolean;
/**
* This is the episode the viewer is up to. Marked and scrolled to when the
* series page opens, so a viewer four seasons deep lands on their place
* instead of the top of season 1. TRACES: UR-062 | DR-102
*/
current?: boolean;
onclick?: () => void;
}
let { episode, focused = false, onclick }: Props = $props();
let { episode, focused = false, current = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
onMount(() => {
if (focused && buttonRef) {
if ((focused || current) && buttonRef) {
// Scroll into view with some offset from top
setTimeout(() => {
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -51,7 +57,11 @@
<button
bind:this={buttonRef}
type="button"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: current
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
: ''}"
{onclick}
>
<!-- Thumbnail -->
@@ -137,6 +147,13 @@
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
@@ -34,9 +34,16 @@
interface Props {
config: GenreConfig;
/**
* Suppress the back button + title when this renders as a *tab* of a
* library page that already has a header. Drilling into a single genre
* still shows the header — there the back button is the way out.
* TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let genres = $state<Genre[]>([]);
let filteredGenres = $state<Genre[]>([]);
@@ -153,7 +160,9 @@
</script>
<div class="space-y-6">
<!-- Header -->
<!-- Header. Inside a genre the back button is the only way out, so it shows
even when the host page suppresses the top-level header. -->
{#if showHeader || selectedGenre}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
@@ -164,6 +173,7 @@
{/if}
</h1>
</div>
{/if}
{#if !selectedGenre}
<!-- Genre Browser -->
@@ -41,9 +41,15 @@
interface Props {
config: MediaListConfig;
/**
* Suppress the back button + title. Set when this renders as a *tab* of a
* library page, which already has its own header — two stacked headers and
* two back buttons read as two pages. TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let items = $state<MediaItem[]>([]);
let loading = $state(true);
@@ -246,10 +252,12 @@
<div class="space-y-6">
<!-- Header -->
{#if showHeader}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
</div>
{/if}
<!-- Search and Sort Bar -->
<div class="flex flex-col sm:flex-row gap-4">
+7 -4
View File
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
import { libraryViewUrl } from "$lib/utils/libraryView";
interface Props {
genres: string[];
@@ -17,7 +18,9 @@
itemKind
}: Props = $props();
// Map the item kind to its genre-browse route
// Map the item kind to its genre-browse surface. Video genres are a tab of
// the library page now, not a route of their own (DR-105); linking straight
// to the tab avoids a redirect hop through the legacy paths.
function genreBasePath(kind: MediaKind | undefined): string {
switch (kind) {
case "album":
@@ -28,11 +31,11 @@
case "series":
case "season":
case "episode":
return "/library/shows/genres";
return libraryViewUrl("/library/tv", "genres");
case "movie":
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
default:
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
}
}
@@ -0,0 +1,44 @@
<!--
Browse / All / Genres for a video library.
These were three routes per library with names that did not agree across the
two libraries; they are now tabs on one route, driven by `?view=` so a tab is
linkable and survives a back navigation.
TRACES: UR-063 | DR-105
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { LIBRARY_VIEWS, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
interface Props {
/** Route the tabs live on, e.g. `/library/tv`. */
basePath: string;
active: LibraryView;
/** Per-view labels — "All Shows" vs "All Movies". */
labels: Record<LibraryView, string>;
}
let { basePath, active, labels }: Props = $props();
function select(view: LibraryView) {
if (view === active) return;
// replaceState: switching tabs is not a navigation step worth a back press.
goto(libraryViewUrl(basePath, view), { replaceState: true, noScroll: true });
}
</script>
<nav class="flex items-center gap-1 px-4" aria-label="Library sections">
{#each LIBRARY_VIEWS as view (view)}
<button
onclick={() => select(view)}
aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{labels[view]}
</button>
{/each}
</nav>
+86 -14
View File
@@ -1,26 +1,56 @@
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation";
interface Props {
season: MediaItem;
episodes: MediaItem[];
focusedEpisodeId?: string;
/** The episode the viewer is up to — highlighted and scrolled into view. */
currentEpisodeId?: string;
/**
* Whether this season's episode list is open. Only the current season
* starts expanded, so a ten-season show does not render every episode at
* once. TRACES: UR-062 | DR-107
*/
expanded?: boolean;
onToggle?: () => void;
onEpisodeClick?: (episode: MediaItem) => void;
onHistoryCleared?: () => void;
}
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
let {
season,
episodes,
focusedEpisodeId,
currentEpisodeId,
expanded = false,
onToggle,
onEpisodeClick,
onHistoryCleared,
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
const anchor = $derived(seasonAnchorId(seasonNumber));
</script>
<section class="space-y-4">
<section class="space-y-4 scroll-mt-4" id={anchor}>
<!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster -->
@@ -38,28 +68,60 @@
<!-- Season info -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-white">
{seasonName}
<!-- 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"
>
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{seasonName}</span>
{#if holdsCurrentEpisode}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
</h2>
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
<!-- Collapsed, this is the only progress signal the season shows. -->
{#if watchedCount > 0}
<span></span>
<span>
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
</span>
{/if}
{#if season.productionYear}
<span></span>
<span>{season.productionYear}</span>
{/if}
</div>
{#if season.overview}
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
{#if season.overview && expanded}
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
{season.overview}
</p>
{/if}
</div>
</button>
<!-- Download Season Button -->
<div class="flex-shrink-0">
<!-- Per-season actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
@@ -68,19 +130,29 @@
{episodeCount}
size="sm"
/>
<ClearHistoryButton
itemId={season.id}
itemName={seasonName}
scope="season"
size="sm"
onCleared={onHistoryCleared}
/>
</div>
</div>
</div>
</div>
<!-- Episode list -->
<div class="space-y-1 pl-2">
{#if expanded}
<div class="space-y-1 pl-2" id="{anchor}-episodes">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
/>
{/each}
</div>
{/if}
</section>
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
// series in episode order, so it runs past a season boundary rather than
// dead-ending at the end of a season.
it("runs past the end of a season into the next one", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
]);
});
it("reaches back into the previous season from a season opener", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[5]; // S2E1
const strip = adjacentEpisodes(current, eps);
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
expect(strip[3].id).toBe("s2e1");
});
it("orders by season then episode, never interleaving seasons", () => {
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
});
it("sorts specials (season 0) after the numbered seasons", () => {
const eps = [...season(0, 2), ...season(1, 2)];
const current = eps[2]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
@@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => {
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
// Anchored at its episode number, not dumped at one end of the list.
expect(strip.indexOf(current)).toBeGreaterThan(0);
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
});
});
describe("compareSeriesOrder", () => {
it("orders by season, then episode", () => {
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
});
it("puts specials last", () => {
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
});
it("falls back to episode number when a season is unknown", () => {
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
});
});
describe("stripCardLabel", () => {
const current = ep("cur", 2, 4);
it("shows a bare episode number within the current season", () => {
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
});
it("shows SxEy once the card crosses a season boundary", () => {
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
});
it("degrades to the episode number when the season is unknown", () => {
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
});
});
+67 -18
View File
@@ -1,12 +1,20 @@
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
//
// Extracted from the component so it can be unit-tested: the strip must never
// collapse to just the current episode while real siblings exist, and it must
// not mistake number-less episodes for the current one.
// collapse to just the current episode while real siblings exist, must not
// mistake number-less episodes for the current one, and must run past a season
// boundary rather than dead-ending at the end of a season (ux-flows §5B.2).
//
// TRACES: UR-048 | DR-062
// TRACES: UR-048, UR-062 | DR-062, DR-104
import type { MediaItem } from "$lib/api/types";
/** Episodes shown before / after the current one in the strip window. */
const BEFORE = 3;
const AFTER = 6;
/** Jellyfin puts specials in season 0; they air outside the numbered run. */
const SPECIALS_SEASON = 0;
/**
* Does `ep` refer to the same episode as `current`?
*
@@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
);
}
/**
* Sort key for a season: specials (season 0) come *after* every numbered
* season, matching how a viewer works through a show S1, S2, , then the
* extras rather than opening on a special because 0 < 1.
*/
function seasonRank(seasonNumber: number | null | undefined): number {
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!;
}
/**
* Broadcast order across a whole series: season ascending, then episode.
*
* When *either* side's season is unknown there is no season axis to compare on,
* so it falls through to episode number. That makes the comparator technically
* non-transitive across such a mix, which is safe here because only the
* directly-fetched `current` episode can lack a season and it is never part of
* the array being sorted it is only positioned against it (see
* `adjacentEpisodes`).
*/
export function compareSeriesOrder(a: MediaItem, b: MediaItem): number {
if (a.parentIndexNumber != null && b.parentIndexNumber != null) {
const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber);
if (bySeason !== 0) return bySeason;
}
return (a.indexNumber ?? 0) - (b.indexNumber ?? 0);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, so it still anchors the window;
* current episode, in series order across *all* seasons.
*
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
* finale should offer the next season's premiere, not an empty strip. Degrades
* gracefully:
* - splices the current episode into the pool at its ordered position when it
* isn't present (an API id mismatch on a directly-fetched episode), so it
* still anchors the window;
* - returns just `[current]` only when there genuinely are no other episodes.
*/
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
const seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
const pool = allEpisodes.slice().sort(compareSeriesOrder);
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
const start = Math.max(0, idx - BEFORE);
const end = Math.min(pool.length, idx + AFTER + 1);
return pool.slice(start, end);
}
/**
* Label for a strip card, relative to the episode in focus.
*
* Within the current season a bare number reads cleanly ("6."). Once the window
* crosses into another season that number is ambiguous, so the card names the
* season too ("S3E1") otherwise the premiere after a finale just reads "1."
*/
export function stripCardLabel(ep: MediaItem, current: MediaItem): string {
const crossesSeason =
ep.parentIndexNumber != null &&
current.parentIndexNumber != null &&
ep.parentIndexNumber !== current.parentIndexNumber;
if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`;
return `${ep.indexNumber ?? 0}.`;
}
@@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import {
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
groupEpisodesBySeason,
initialExpandedSeasons,
} from "./seriesNavigation";
const SERIES = "series-1";
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
seriesId: SERIES,
parentIndexNumber: season,
indexNumber: number,
durationMs: 1_000_000,
} as unknown as MediaItem;
}
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
return {
id,
name: `Season ${number}`,
kind: "season",
seriesId: SERIES,
indexNumber: number,
} as unknown as MediaItem;
}
function withProgress(episode: MediaItem, fraction: number): MediaItem {
return {
...episode,
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
} as MediaItem;
}
describe("seriesPlayHref", () => {
// The reported bug: Play resolved the first *season* child and navigated to
// /player/<seasonId>, which bounced back to the season-1 page.
it("opens the current episode's focus view, never a season or the player", () => {
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
expect(href).toBe("/library/series-1?episode=s2e4");
expect(href).not.toContain("/player/");
});
it("returns null for a series with no episodes so the button can hide", () => {
expect(seriesPlayHref(SERIES, null)).toBeNull();
});
});
describe("seriesPlayLabel", () => {
it("names the episode it will open", () => {
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
});
it("says Resume for a part-watched episode", () => {
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
});
it("says Play for a barely-started or nearly-finished episode", () => {
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
});
it("degrades to a bare verb when the numbering is unknown", () => {
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
expect(seriesPlayLabel(null)).toBe("Play");
});
});
describe("seasonRedirectTarget", () => {
it("sends a season to its series, anchored at that season", () => {
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
});
it("returns null when the series is unknown, so the caller can fall back", () => {
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
expect(seasonRedirectTarget(orphan)).toBeNull();
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
});
});
describe("episodeFocusHref", () => {
it("opens an episode inside its series (never a bare episode page)", () => {
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
});
it("falls back to the bare item page when the series is unknown", () => {
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
expect(episodeFocusHref(orphan)).toBe("/library/lone");
});
});
describe("groupEpisodesBySeason", () => {
it("groups episodes under their season headers, in season order", () => {
const seasons = [seasonHeader(2), seasonHeader(1)];
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
const grouped = groupEpisodesBySeason(seasons, episodes);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
});
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
// A flat series: episodes hang off the series, no season folders exist.
it("synthesizes headers when the server returned no seasons", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
});
it("names a synthesized season 0 'Specials'", () => {
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
expect(grouped[0].season.name).toBe("Specials");
});
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
const ids = grouped.map((g) => g.season.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
it("buckets season-less episodes into season 1 rather than losing them", () => {
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
expect(grouped).toHaveLength(1);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
});
});
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
);
it("expands only the season holding the current episode", () => {
const expanded = initialExpandedSeasons(seasons, "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("also expands the season of a ?episode= deep link", () => {
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
expect(expanded.has("season-1")).toBe(true);
expect(expanded.has("season-3")).toBe(true);
expect(expanded.has("season-2")).toBe(false);
});
it("collapses nothing extra when current and focused share a season", () => {
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("falls back to the first season when there is no current episode", () => {
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
});
it("falls back to the first season when the current episode is unknown here", () => {
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
});
it("returns nothing for a series with no seasons", () => {
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
});
});
@@ -0,0 +1,167 @@
// Pure navigation/grouping logic for the series detail page.
//
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
// SortName — and navigate to `/player/<seasonId>`, which the player route
// bounced back to `/library/<seasonId>`. Play on a series therefore played
// nothing and landed on the season-1 page.
//
// Note what is NOT here: *which* episode is current. That is domain policy and
// lives in Rust (`repository_get_series_current_episode`); this module only
// renders and routes around the answer.
//
// TRACES: UR-062 | DR-102, DR-103
import type { MediaItem } from "$lib/api/types";
export interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
/** Jellyfin files specials under season 0. */
const SPECIALS_SEASON = 0;
/** Sort key for a season number: specials come after every numbered season. */
function seasonRank(seasonNumber: number | null | undefined): number {
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
}
/**
* The in-page anchor for a season, so a season link scrolls the series' single
* continuous episode list instead of opening a page of its own.
*/
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
return `season-${seasonNumber ?? 0}`;
}
/**
* Where a link naming a season should actually go: the series, anchored at that
* season. Returns `null` when the season carries no `seriesId` (a deep link into
* a stale cache), in which case the caller must keep rendering something rather
* than strand the user.
*/
export function seasonRedirectTarget(season: MediaItem): string | null {
if (!season.seriesId) return null;
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
}
/**
* Where an episode link should go: the episode in the context of its series
* (ux-flows §5B.1 an episode is never browsed as a bare Episode page).
* Falls back to the bare item page only when the series is unknown.
*/
export function episodeFocusHref(episode: MediaItem): string {
if (!episode.seriesId) return `/library/${episode.id}`;
return `/library/${episode.seriesId}?episode=${episode.id}`;
}
/**
* Where the series hero button goes.
*
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
* when there is no current episode (an empty series), so the caller can hide
* the button rather than link nowhere.
*/
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
if (!current) return null;
return `/library/${seriesId}?episode=${current.id}`;
}
/** Fraction of an episode already watched, 0 when unknown. */
function progressFraction(episode: MediaItem): number {
const position = episode.userData?.playbackPositionMs ?? 0;
if (!episode.durationMs || position <= 0) return 0;
return position / episode.durationMs;
}
/**
* Label for the series hero button it names the episode it will open, so the
* viewer knows where the button leads before pressing it.
*/
export function seriesPlayLabel(current: MediaItem | null): string {
if (!current) return "Play";
const fraction = progressFraction(current);
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
}
/**
* Group a series' episodes under its season headers.
*
* The episodes arrive from Rust already in series order; this only decides which
* header each one renders beneath, and synthesizes a header for any season the
* server did not return one for (a flat series, or a season fetch that failed).
* Seasons with no episodes are dropped an empty accordion row is noise.
*/
export function groupEpisodesBySeason(
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
const headerFor = new Map<number, MediaItem>();
for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber;
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
}
const grouped = new Map<number, MediaItem[]>();
for (const episode of episodes) {
const number = episode.parentIndexNumber ?? 1;
const bucket = grouped.get(number);
if (bucket) bucket.push(episode);
else grouped.set(number, [episode]);
}
return [...grouped.entries()]
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
.map(([number, seasonEpisodes]) => ({
season:
headerFor.get(number) ??
({
...seasonEpisodes[0],
id: `synthetic-season-${number}`,
kind: "season",
indexNumber: number,
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
overview: null,
} as MediaItem),
episodes: seasonEpisodes,
}));
}
/**
* Which seasons start expanded.
*
* Only the one the viewer is in. A ten-season show otherwise renders every
* episode of every season at once, burying the one episode they came for. A
* `?episode=` deep link expands that episode's season as well, and a show with
* no resolved current episode falls back to its first season so the page is
* never entirely collapsed.
*
* Returns season ids (not numbers) so the caller can key state per section,
* including the synthesized headers.
*/
export function initialExpandedSeasons(
seasons: SeasonData[],
currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null
): Set<string> {
if (seasons.length === 0) return new Set();
const expanded = new Set<string>();
for (const id of [currentEpisodeId, focusedEpisodeId]) {
if (!id) continue;
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
if (owner) expanded.add(owner.season.id);
}
// Nothing matched — open the first season rather than nothing at all.
if (expanded.size === 0) expanded.add(seasons[0].season.id);
return expanded;
}
+155 -59
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts">
import { onMount, onDestroy, untrack } from "svelte";
import { goto } from "$app/navigation";
@@ -24,6 +24,9 @@
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
isSynthesizedTouchClick,
isControlSurfaceTouch,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
@@ -111,7 +114,9 @@
let touchStartY = $state(0);
let touchStartTime = $state(0);
let tapGestures = createTapGestureState();
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
// When a touch tap last ran the gesture handler, so the compatibility click
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
let lastTouchTapAt = 0;
let brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -119,6 +124,14 @@
// so back-to-back double taps chain instead of stacking on a stale position.
let pendingSeekTarget: number | null = null;
let swipeGestureActive = $state(false);
// Whether the in-flight touch belongs to the player surface (and so may be
// read as a tap/swipe gesture) rather than to a control. Set on touchstart,
// cleared on touchend — see handleTouchMove for why a per-gesture flag and not
// just a per-event target check.
let playerGestureActive = false;
// Raised when the user changes the seek bar's value, cleared by whichever
// release signal commits the seek. See handleSeekBarRelease.
let seekCommitArmed = false;
// Backend info from Rust (Rust decides which backend to use based on platform)
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
@@ -685,15 +698,19 @@
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
}
console.log("[VideoPlayer Debug]", {
currentTime: videoElement.currentTime.toFixed(2),
displayTime: currentTime.toFixed(2),
buffered: bufferedRanges.join(", "),
readyState: videoElement.readyState,
paused: videoElement.paused,
seeking: videoElement.seeking,
playbackRate: videoElement.playbackRate,
});
// Flattened to a single string on purpose: the Android WebView console
// bridge stringifies objects as "[object Object]" in logcat, which made
// this whole payload useless when diagnosing over adb.
console.log(
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
` display=${currentTime.toFixed(2)}` +
` readyState=${videoElement.readyState}` +
` networkState=${videoElement.networkState}` +
` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}`
);
}
}, 1000);
});
@@ -714,11 +731,6 @@
if (debugLogInterval) {
clearInterval(debugLogInterval);
}
// A deferred single tap must not fire play/pause after teardown.
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
tapGestures.cancel();
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
@@ -1100,6 +1112,21 @@
}
function handlePause() {
// The element pausing is normally user intent, but a stall, a source change,
// or a competing controller can also do it — and the pause itself carries no
// reason. Log the element state so an unexplained pause/resume loop can be
// attributed from an adb capture instead of guessed at.
const el = videoElement;
console.log(
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
` readyState=${el?.readyState}` +
` networkState=${el?.networkState}` +
` seeking=${el?.seeking}` +
` ended=${el?.ended}` +
` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}`
);
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
html5Adapter.reportState("paused", reportMediaId ?? null);
@@ -1143,11 +1170,33 @@
const targetTime = parseFloat(input.value);
// Update the displayed time immediately for smooth visual feedback
currentTime = targetTime;
// The user has moved the value; the next release must commit it.
seekCommitArmed = true;
}
async function handleSeekBarChange(e: Event) {
const input = e.target as HTMLInputElement;
const targetTime = parseFloat(input.value);
/**
* Seek-bar released — commit the value the user landed on, at most once.
*
* Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
* dependable: Android's WebView does not reliably fire it for a touch
* interaction on a range input, so the thumb moved to the tapped position but
* the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
* `change` deliver both signals, hence the arm/disarm — whichever arrives
* first commits and the other is a no-op.
*/
function handleSeekBarRelease(e: Event) {
isDraggingSeekBar = false;
if (!seekCommitArmed) return;
seekCommitArmed = false;
const input = (e.currentTarget ?? e.target) as HTMLInputElement;
void commitSeek(parseFloat(input.value));
}
async function commitSeek(rawTarget: number) {
// Clamp strictly inside the media: the range input's max IS the duration, so
// dragging fully right would otherwise request a segment past the media end,
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
const targetTime = clampSeekTarget(rawTarget, duration);
// Set isSeeking immediately to prevent timeupdate from interfering
isSeeking = true;
@@ -1384,16 +1433,9 @@
to: newTime.toFixed(2),
});
// Call the unified handleSeekBarChange logic with the new time
// Create a synthetic event to reuse the existing logic
const syntheticEvent = {
target: {
value: newTime.toString()
}
} as unknown as Event;
// Same commit path as the seek bar — one place decides how a seek is issued.
try {
await handleSeekBarChange(syntheticEvent);
await commitSeek(newTime);
} finally {
// The player is authoritative again from here on.
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
@@ -1421,8 +1463,47 @@
}
}
/**
* Walk up from the touch target collecting the tag/attribute pairs
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
*/
function ancestorChain(target: EventTarget | null) {
const chain: Array<{
tag: string;
isPlayerControls?: boolean;
isPlayerSurface?: boolean;
}> = [];
let node = target as HTMLElement | null;
// Bounded walk: controls live a few levels below the player root, and
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
while (node && node.tagName !== "BODY") {
chain.push({
tag: node.tagName ?? "",
isPlayerControls: node.dataset?.playerControls !== undefined,
isPlayerSurface: node.dataset?.playerSurface !== undefined,
});
node = node.parentElement;
}
return chain;
}
// Touch gesture handlers
function handleTouchStart(e: TouchEvent) {
// Taps on the controls belong to those controls. This listener is on the
// container and touch events bubble, so without this a tap on the bottom
// play button would toggle here AND again via the button's own click — the
// two cancelling out and leaving the control apparently dead (DR-098).
if (isControlSurfaceTouch(ancestorChain(e.target))) {
// The move handler must stay out of it too. It reads touchStartX/Y, which
// this early return leaves at the PREVIOUS gesture's values, so a seek-bar
// drag came out as a huge vertical delta: it was mis-read as a brightness
// swipe, which dimmed the screen and fired a spurious play/pause
// "correction" mid-drag (DR-098).
playerGestureActive = false;
return;
}
playerGestureActive = true;
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
@@ -1434,28 +1515,29 @@
now: Date.now(),
});
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
// Suppress the compatibility click this touch will synthesize.
lastTouchTapAt = Date.now();
if (outcome.action === "seek") {
e.preventDefault();
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
// leaves the play state as it was (playing keeps playing, paused stays
// paused).
if (outcome.togglePlayPause) togglePlayPause();
return;
}
// Single tap so far: defer play/pause until the double-tap window closes,
// so a double tap seeks without also toggling pause.
tapTimeout = setTimeout(() => {
tapTimeout = null;
if (tapGestures.resolvePending(Date.now())) {
// First tap: act now. Nothing is deferred, so there is no timer to race the
// compatibility click Android synthesizes after a touch tap (see DR-098).
togglePlayPause();
}
}, outcome.pendingAfterMs);
}
function handleTouchMove(e: TouchEvent) {
// Only a gesture that began on the bare video surface is ours. Re-checking
// the target here would not be enough: the touch that started on a control
// never recorded a start point, so any delta computed here is meaningless.
if (!playerGestureActive) return;
if (!e.touches[0]) return;
const touch = e.touches[0];
@@ -1465,14 +1547,16 @@
// Minimum movement to register as swipe (50px)
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
swipeGestureActive = true;
// This is a swipe, not a tap — drop the deferred play/pause.
// Only on the frame the gesture is first recognised as a swipe this runs
// on every touchmove, and the correction below must happen exactly once.
if (!swipeGestureActive) {
// The touchstart already toggled play/pause (taps act immediately now),
// so undo it: a swipe must not change the play state. Forget the tap too,
// so it cannot pair with a later tap into a spurious seek.
togglePlayPause();
tapGestures.cancel();
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
swipeGestureActive = true;
// Brightness control on vertical swipe
swipeType = "brightness";
@@ -1486,19 +1570,22 @@
}
function handleTouchEnd(e: TouchEvent) {
playerGestureActive = false;
swipeGestureActive = false;
swipeType = null;
}
/**
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
* by `handleTouchStart` (which defers play/pause past the double-tap window),
* so the compatibility click that follows a tap must be ignored here —
* otherwise it pauses on the first tap of a double tap.
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* `handleTouchStart`, so the compatibility click the browser synthesizes after
* a tap must be ignored or every tap toggles twice.
*
* Used by EVERY click target layered over the video, not just the <video>:
* pausing renders the full-screen play overlay, so the synthesized click lands
* on that button instead and would re-toggle straight back to playing.
*/
function handleVideoClick(e: MouseEvent) {
// A click synthesized from a touch reports no pointer movement detail.
if (e.detail === 0 || tapTimeout !== null) return;
function handleSurfaceClick(e: MouseEvent) {
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
togglePlayPause();
}
@@ -1666,7 +1753,7 @@
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleVideoClick}
onclick={handleSurfaceClick}
>
<!-- Temporarily disabled to debug playback issues
{#each subtitleTracks() as track}
@@ -1759,10 +1846,17 @@
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if !isPlaying}
<!-- Play/Pause overlay -->
<!-- Play overlay. Visually this IS the video surface, so it is marked
`data-player-surface`: it must keep participating in tap gestures even
though it is a <button>, or the second tap of a double tap (which lands
here, because the first tap paused and raised this overlay) is
discarded as "a tap on a control" and seeking dies. It still shares the
synthesized-click guard, since it appears exactly when a tap pauses.
See DR-098. -->
<button
data-player-surface
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={togglePlayPause}
onclick={handleSurfaceClick}
aria-label="Play"
>
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
@@ -1810,8 +1904,10 @@
{/if}
</div>
<!-- Controls -->
<!-- Controls. `data-player-controls` marks this subtree as interactive so
container-level tap gestures ignore touches here (see DR-098). -->
<div
data-player-controls
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
@@ -1838,11 +1934,11 @@
max={duration || 100}
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarChange}
onchange={handleSeekBarRelease}
onmousedown={() => isDraggingSeekBar = true}
onmouseup={() => isDraggingSeekBar = false}
onmouseup={handleSeekBarRelease}
ontouchstart={() => isDraggingSeekBar = true}
ontouchend={() => isDraggingSeekBar = false}
ontouchend={handleSeekBarRelease}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
@@ -0,0 +1,211 @@
/**
* Behavioural regression tests for the video tap surface rendered against the
* REAL component, not a hand-modelled DOM.
*
* TRACES: UR-005, UR-061 | DR-098 | UT-092
*
* Why this file exists:
*
* `tapGestures.test.ts` tests `registerTap` / `isControlSurfaceTouch` /
* `isSynthesizedTouchClick` as isolated pure functions. Every one of those tests
* passed while, on the device, in sequence: the player pause-looped, then
* pausing became impossible, then the bottom controls went dead, then
* double-tap-to-seek stopped working. The helpers were each behaving exactly as
* specified the bugs were all in the *composition*: which element actually
* receives a tap once Svelte has re-rendered.
*
* Testing my own helpers could not catch that, and modelling the DOM by hand in
* a test just re-encodes the same wrong assumption. So these tests render
* VideoPlayer and dispatch real touch/click events at whatever element is
* genuinely on top, asserting user-visible outcomes ("a double tap seeks")
* rather than internals.
*
* The specific traps encoded here, each a bug that shipped:
* - pausing renders a full-screen <button> play overlay OVER the video, so the
* second tap of a double tap lands on a button, not the video;
* - the browser synthesizes a `click` after a touch tap, which must not toggle
* a second time, on ANY layered target;
* - the bottom controls bar must drive its own buttons and NOT the container's
* tap gestures.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
const toggleSpy = vi.fn();
const seekVideoSpy = vi.fn();
const seekSpy = vi.fn();
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: (...a: unknown[]) => {
toggleSpy(...a);
return Promise.resolve();
},
seekVideo: (...a: unknown[]) => {
seekVideoSpy(...a);
return Promise.resolve();
},
seek: (...a: unknown[]) => {
seekSpy(...a);
return Promise.resolve();
},
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
},
}));
vi.mock("$lib/player/adapters/rustReportHost", () => ({
createRustReportHost: () => ({
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000, // 600s
} as any;
/** Dispatch a touch at (x, y) on whatever element is topmost there. */
function touchAt(el: Element, x: number) {
const touch = { clientX: x, clientY: 300 } as Touch;
el.dispatchEvent(
new TouchEvent("touchstart", {
bubbles: true,
cancelable: true,
touches: [touch] as unknown as Touch[],
})
);
}
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
});
}
describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("a single tap on the video toggles play/pause exactly once", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video");
expect(video).toBeTruthy();
touchAt(video!, 900);
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("the synthesized click after a tap does not toggle a second time", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
touchAt(video, 900);
// The compatibility click the browser fires after a touch tap. detail=0 is
// how engines mark it; a late real-detail click is covered by the recency
// guard, which this exercises too since it lands immediately.
video.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("a double tap seeks even though the first tap raised the play overlay", async () => {
// THE regression this file exists for. On device the first tap pauses, which
// makes Svelte render a full-screen <button> play overlay over the video —
// so the SECOND tap lands on a button, not the video. A control-surface
// guard that does not know about that overlay discards it and seeking dies.
//
// Reproducing it requires the overlay to actually render, which means
// driving `isPlaying` the way the real element does: via its `pause` event.
vi.useFakeTimers();
try {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
// Tap 1 on the video.
touchAt(video, 900);
// The element reports it paused → isPlaying=false → overlay renders.
video.dispatchEvent(new Event("pause"));
await Promise.resolve();
await tick();
const overlay = container.querySelector("[data-player-surface]");
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
// Tap 2 lands on the OVERLAY, exactly as on device.
touchAt(overlay!, 900);
// Either seek route is acceptable — which one runs depends on whether a
// video adapter is registered. What must hold is that a seek happened, to
// roughly the forward-skip target.
const calls = [...seekVideoSpy.mock.calls, ...seekSpy.mock.calls];
expect(calls.length).toBe(1);
const [position] = calls[0];
expect(position).toBeGreaterThan(0);
expect(position).toBeLessThanOrEqual(SEEK_FORWARD_SECONDS);
} finally {
vi.useRealTimers();
}
});
it("tapping the bottom play/pause button toggles once, not twice", async () => {
const { container } = renderPlayer();
const controls = container.querySelector("[data-player-controls]");
expect(controls).toBeTruthy();
const playBtn = controls!.querySelector("button");
expect(playBtn).toBeTruthy();
// A real press: touchstart bubbles to the container's gesture handler, then
// the button's own click fires. Only ONE toggle may result.
touchAt(playBtn!, 40);
playBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,218 @@
/**
* VideoPlayer seek-bar TOUCH scrub regression tests (Android).
*
* Reported bug: on Android, dragging the progress bar does not change the
* playback location.
*
* The gesture listener lives on the outer container and touch events bubble.
* `handleTouchStart` ignores touches that land on a control (the seek bar is an
* <input>, inside `data-player-controls`) but `handleTouchMove` does not, so a
* seek-bar drag is still interpreted as a container swipe. That mis-read swipe
* fires `togglePlayPause()` (undoing a first-tap toggle that never happened) and
* hijacks the drag into brightness control.
*
* The existing scrub regression tests only drive the slider with MOUSE events,
* which never reach the touch handlers which is why this survived.
*
* The seek was also committed only from `change`, which Android's WebView does
* not reliably fire for a touch interaction on a range input so a tap moved
* the thumb and no seek ever ran. Release now commits from touchend/mouseup too.
*
* TRACES: UR-005, UR-061 | DR-098, DR-099 | UT-089, UT-090
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
strategy: "native",
position,
}));
const playerStop = vi.fn(async () => ({}));
const playerToggle = vi.fn(async () => ({ state: "playing" }));
vi.mock("$lib/api/bindings", () => ({
commands: {
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
playerStop: (...a: any[]) => playerStop(...(a as [])),
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
playerPlay: vi.fn(async () => ({})),
playerPause: vi.fn(async () => ({})),
playerSetSleepTimer: vi.fn(async () => ({})),
playerCancelSleepTimer: vi.fn(async () => ({})),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({
getHandle: () => "repo-1",
getSubtitleUrl: async () => "",
jrayActorsAt: async () => [],
}),
},
}));
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
import { render, fireEvent, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import type { MediaItem } from "$lib/api/types";
function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
kind: "episode",
durationMs: 24 * 60 * 1000, // 24 min
} as MediaItem;
}
async function mountAndroidPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector(
'input[type="range"]'
) as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
return { ...utils, slider, video };
}
function touch(x: number, y: number) {
return { clientX: x, clientY: y } as Touch;
}
/**
* Drag the seek bar with TOUCH events, the way a finger does on Android.
*
* A real drag along the bar moves the finger far enough that the container's
* swipe detector (50px) would trigger if it were still listening.
*/
async function touchScrubTo(
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// Finger travels across the bar. Small vertical wander is normal for a thumb
// drag; the horizontal travel is what matters.
await fireEvent.touchMove(slider, { touches: [touch(400, 690)] });
slider.value = String(target);
await fireEvent.input(slider);
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
await fireEvent.change(slider);
await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick();
}
describe("VideoPlayer seek bar — touch drag (Android)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
});
it("a touch drag on the seek bar seeks to the dragged position", async () => {
const { slider, video } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
);
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("a touch drag on the seek bar never toggles play/pause", async () => {
const { slider, video } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
// The container gesture layer must stay out of a control drag entirely:
// no swipe mis-read, so no play/pause correction.
expect(playerToggle).not.toHaveBeenCalled();
});
it("commits the seek on touchend even when the engine never fires `change`", async () => {
const { slider, video } = await mountAndroidPlayer();
// Android's WebView does not reliably fire `change` for a touch interaction
// on a range input. A tap on the track still moves the thumb and fires
// `input` — the seek must be committed on release regardless.
await fireEvent.touchStart(slider, { touches: [touch(400, 700)] });
slider.value = "600";
await fireEvent.input(slider);
await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick();
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true)
);
});
it("commits the seek exactly once when both touchend and change fire", async () => {
const { slider, video } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
});
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
const { slider, video, container } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
// Brightness is applied as a CSS filter on the <video>; a control drag must
// leave it untouched.
const el = container.querySelector("video") as HTMLVideoElement | null;
if (el) {
expect(el.style.filter).toBe("brightness(1)");
}
});
});
@@ -0,0 +1,107 @@
/**
* Regression tests for the `/player/[id]` surface decision.
*
* The bug these pin down: a video that was left and re-entered rendered in the
* AUDIO player. Exiting a webview-rendered video does not stop the Rust
* controller (`onReportStop` deliberately emits no `stopped` state, so the
* autoplay handoff survives), so the backend still reports that episode/movie as
* the loaded media. Re-entering the route therefore took the "already playing,
* just show the UI" shortcut, which returns *before* a stream URL is fetched
* and the render then fell through to `<AudioPlayer>` because it treated
* "video without a stream URL" as audio.
*
* TRACES: UR-005 | DR-100 | UT-092, UT-093
*/
import { describe, it, expect } from "vitest";
import { shouldReuseActivePlayback, resolvePlayerSurface } from "./playerSurface";
describe("shouldReuseActivePlayback", () => {
it("reuses playback when the same audio track is already loaded", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
).toBe(true);
});
it("does NOT reuse playback for video, even when the backend reports it loaded", () => {
// Video needs a full load: the shortcut skips fetching the stream URL, and
// <VideoPlayer> cannot render without one.
expect(
shouldReuseActivePlayback({
requestedId: "episode-1",
activeMediaId: "episode-1",
isVideo: true,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback for a different item", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-2",
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when nothing is loaded", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: null,
isVideo: false,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when an explicit start position is requested", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: "track-1",
isVideo: false,
startPosition: 42,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when restarting (next-episode advance)", () => {
expect(
shouldReuseActivePlayback({
requestedId: "episode-2",
activeMediaId: "episode-2",
isVideo: true,
forceRestart: true,
})
).toBe(false);
});
});
describe("resolvePlayerSurface", () => {
it("renders the video surface for video with a stream URL", () => {
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
"video"
);
});
it("renders the audio surface for audio content", () => {
expect(resolvePlayerSurface({ isVideo: false, streamUrl: null })).toBe("audio");
});
it("never renders video content in the audio surface when the stream URL is missing", () => {
// A video whose stream URL has not resolved yet is pending, not audio —
// otherwise the movie/episode shows up in the audio player.
expect(resolvePlayerSurface({ isVideo: true, streamUrl: null })).toBe("pending");
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "" })).toBe("pending");
});
});
@@ -0,0 +1,63 @@
/**
* Pure decisions for the `/player/[id]` route: which player surface to render,
* and whether a load can be skipped because the backend is already playing the
* requested item.
*
* Kept free of Svelte so both can be unit-tested without mounting the route.
*
* TRACES: UR-005 | DR-100 | UT-092, UT-093
*/
/** Which player component the route should render. */
export type PlayerSurface = "video" | "audio" | "pending";
export interface ReuseActivePlaybackInput {
/** Item id the route was asked to play. */
requestedId: string;
/** Id of the media the backend currently reports as loaded, if any. */
activeMediaId: string | null | undefined;
/** Whether the requested item is video content. */
isVideo: boolean;
/** Explicit start position, if the caller asked for one. */
startPosition?: number;
/** Advancing to a next episode always restarts from the beginning. */
forceRestart: boolean;
}
/**
* Whether the route can show its UI over the backend's existing playback
* instead of reloading the item (e.g. expanding the audio mini player).
*
* Never for video. The shortcut returns before a stream URL is fetched, which
* is fine for audio (the backend owns the stream and the UI only mirrors it)
* but leaves `<VideoPlayer>` with nothing to render. Leaving a webview-rendered
* video does not clear the Rust controller's media closing the route emits no
* `stopped` state by design so re-entering the same movie/episode hit this
* shortcut and rendered the audio player instead.
*/
export function shouldReuseActivePlayback(input: ReuseActivePlaybackInput): boolean {
return (
!input.isVideo &&
input.activeMediaId === input.requestedId &&
!input.startPosition &&
!input.forceRestart
);
}
export interface PlayerSurfaceInput {
isVideo: boolean;
streamUrl: string | null;
}
/**
* Which surface to render for the loaded item.
*
* Video without a stream URL is `pending`, never `audio` falling through to
* the audio player is how a movie/episode ended up in it.
*/
export function resolvePlayerSurface(input: PlayerSurfaceInput): PlayerSurface {
if (input.isVideo) {
return input.streamUrl ? "video" : "pending";
}
return "audio";
}
+147 -29
View File
@@ -6,6 +6,11 @@ import {
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
END_SEEK_MARGIN_SECONDS,
isSynthesizedTouchClick,
isControlSurfaceTouch,
TOUCH_CLICK_SUPPRESS_MS,
} from "./tapGestures";
const SCREEN_WIDTH = 1000;
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
}
describe("tap gesture resolution", () => {
it("defers the single-tap action until the double-tap window has elapsed", () => {
const state = createTapGestureState();
const first = tap(state, RIGHT, 1000);
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
//
// 1st tap: toggle play/pause
// 2nd tap: seek, then toggle play/pause AGAIN
//
// The second toggle undoes the first, so a double tap seeks while leaving the
// play state exactly as it was: playing -> jump and keep playing; paused ->
// jump and stay paused. The old design deferred the first tap behind a 300ms
// timer, which raced the synthesized click and produced a pause/unpause loop.
// The first tap must NOT immediately toggle play/pause — it may still
// become a double tap.
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
it("toggles play/pause immediately on the first tap", () => {
const state = createTapGestureState();
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
});
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
expect(resolved).toEqual({ action: "togglePlayPause" });
});
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const second = asSeek(tap(state, RIGHT, 1150));
@@ -50,12 +53,11 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
expect(second.seekSeconds).toBe(30);
expect(second.feedback).toBe("right");
// The deferred single-tap pause must have been cancelled.
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
// The re-toggle is what preserves the play state across a double tap.
expect(second.togglePlayPause).toBe(true);
});
it("seeks back 10s on a double tap on the left half", () => {
it("seeks back 10s AND toggles again on a second left-side tap", () => {
const state = createTapGestureState();
tap(state, LEFT, 1000);
const second = asSeek(tap(state, LEFT, 1100));
@@ -63,24 +65,44 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
expect(second.seekSeconds).toBe(-10);
expect(second.feedback).toBe("left");
expect(second.togglePlayPause).toBe(true);
});
it("treats a second tap after the window as a new pending single tap", () => {
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
const state = createTapGestureState();
let playing = true;
const apply = (outcome: ReturnType<typeof tap>) => {
if (outcome.action === "togglePlayPause") playing = !playing;
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
};
apply(tap(state, RIGHT, 1000)); // toggle -> paused
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
expect(playing).toBe(true);
// And from paused, a double tap leaves it paused.
playing = false;
apply(tap(state, RIGHT, 2000));
apply(tap(state, RIGHT, 2100));
expect(playing).toBe(false);
});
it("treats a tap after the window as a fresh first tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
expect(late.action).toBe("pending");
expect(late.action).toBe("togglePlayPause");
});
it("does not treat a third tap as another double tap", () => {
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
expect(tap(state, RIGHT, 1100).action).toBe("seek");
// Triple tap: the third tap starts a fresh pending tap rather than
// seeking again off the consumed second tap.
expect(tap(state, RIGHT, 1200).action).toBe("pending");
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
// play/pause — there is no "third tap" concept.
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
});
it("accumulates repeated double taps on the same side", () => {
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
expect(second.feedback).toBe("right");
});
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
state.cancel();
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
// Without cancel() this would have been the seeking second tap.
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
});
});
@@ -123,8 +146,28 @@ describe("seek target resolution", () => {
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
});
it("clamps to the duration when skipping past the end", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
it("clamps short of the duration when skipping past the end", () => {
// Never land exactly on `duration`: hls.js would then request the segment
// that starts at/after the media end, which the server never produces —
// the fetch times out and the gap-controller stalls in a pause loop.
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
DURATION - END_SEEK_MARGIN_SECONDS
);
});
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
// Regression: seeking near the end of a ~105min transcoded item clamped to
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
// starts at 6336.33s — past the end. That segment 404s/times out forever.
const runtime = 6330.324;
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
expect(target).toBeLessThan(runtime);
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
});
it("does not clamp below zero for media shorter than the end margin", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
});
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
@@ -156,3 +199,78 @@ describe("seek target resolution", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
});
});
describe("control-surface touches are not gestures", () => {
// Regression: the gesture listener is on the outer container and touch events
// bubble, so tapping the bottom play/pause button ran the gesture handler
// (toggle #1) AND the button's own click handler (toggle #2). The two
// cancelled out and the control appeared dead.
it("treats a tap on a button as a control, not a gesture", () => {
expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
});
it("treats the seek bar input as a control", () => {
expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
});
it("treats anything inside the controls bar as a control", () => {
expect(
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
).toBe(true);
});
it("lets a tap on the bare video surface through as a gesture", () => {
expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
});
it("is case-insensitive about tag names", () => {
expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
});
});
describe("synthesized touch-click suppression", () => {
// Regression: pausing renders a full-screen play-overlay button over the
// video, so the compatibility click Android synthesizes from the tap lands on
// the OVERLAY, not the <video>. With no guard there it re-toggled and undid
// the pause — pausing looked impossible while unpausing worked fine (the
// overlay is removed when playing, so nothing intercepted that direction).
it("suppresses a click with detail 0 (clearly synthesized)", () => {
expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
});
it("suppresses a real-detail click that closely follows a touch tap", () => {
const tapAt = 10_000;
expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
});
it("allows a genuine mouse click well after any touch", () => {
const tapAt = 10_000;
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
});
it("allows a genuine mouse click when no touch has ever happened", () => {
expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
});
});
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
it("keeps a mid-stream target untouched", () => {
expect(clampSeekTarget(100, 600)).toBe(100);
});
it("pulls a drag to the very end back inside the media", () => {
// The seek bar's max IS the duration, so dragging fully right yields
// exactly `duration` — the value that triggers the dead-segment stall.
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
});
it("clamps negative and non-finite targets to zero", () => {
expect(clampSeekTarget(-5, 600)).toBe(0);
expect(clampSeekTarget(NaN, 600)).toBe(0);
});
it("leaves the target alone when the duration is unknown", () => {
expect(clampSeekTarget(500, 0)).toBe(500);
});
});
+141 -35
View File
@@ -1,18 +1,86 @@
/**
* Tap-gesture interpretation for the video player surface.
*
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
* a tap cannot be classified at the moment it lands, because it may still turn
* out to be the first half of a double tap. Play/pause is therefore *deferred*
* until the double-tap window closes, and cancelled outright if a second tap
* arrives otherwise a double tap both toggles pause and seeks.
* Every tap acts IMMEDIATELY there are only first and second taps, and no
* deferral:
*
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
* 1st tap: toggle play/pause
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
*
* The second toggle undoes the first, so a double tap seeks while leaving the
* play state exactly as it started playing stays playing, paused stays paused.
*
* This replaced a design that deferred the first tap behind a 300ms timer so it
* could be cancelled if a second tap arrived. That deferral raced the
* compatibility `click` Android's WebView synthesizes after a touch tap: the
* timer cleared its own handle *before* running the toggle, reopening the guard
* that was meant to suppress the late click, which then toggled a second time.
* The result was a play/pause loop about a second apart. Acting immediately
* removes the timer, the window race, and the loop.
*
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
*/
/** A second tap within this window makes a double tap. */
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300;
/**
* How long after a touch tap a mouse `click` is assumed to be the compatibility
* event the browser synthesizes from that touch. Android's WebView can deliver it
* noticeably late, so this is generous.
*/
export const TOUCH_CLICK_SUPPRESS_MS = 700;
/**
* Whether a touch landed on an interactive control rather than the bare video
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
*
* The gesture listener sits on the outer container, and touch events bubble, so
* without this a tap on the bottom control bar runs the gesture handler (toggle
* #1) *and* the button's own click handler (toggle #2) the two cancel out and
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
* inside an element marked `data-player-controls` are treated as controls.
*
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
* testable without a DOM.
*/
export function isControlSurfaceTouch(
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
): boolean {
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
for (const node of ancestors) {
// `data-player-surface` wins over the tag check: the full-screen play overlay
// is a <button> but is visually the video itself, and must keep taking tap
// gestures — otherwise the second tap of a double tap (which lands on it,
// because the first tap paused and raised it) is discarded and seeking dies.
if (node.isPlayerSurface === true) return false;
if (node.isPlayerControls === true) return true;
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
}
return false;
}
/**
* Whether a `click` should be ignored because a touch tap already handled it.
*
* EVERY click target layered over the video must consult this not just the
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
* synthesized click lands on *that* button rather than the video, and an
* unguarded handler there re-toggles and undoes the pause (pause appeared
* impossible while unpause worked, because unpausing removes the overlay).
*
* `detail === 0` catches the synthesized click on engines that report it; the
* recency check covers engines that report a real `detail`.
*/
export function isSynthesizedTouchClick(
detail: number,
now: number,
lastTouchTapAt: number
): boolean {
if (detail === 0) return true;
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
}
/** Double tap on the right half: skip forward. */
export const SEEK_FORWARD_SECONDS = 30;
@@ -22,9 +90,18 @@ export const SEEK_BACKWARD_SECONDS = -10;
export type TapFeedback = "left" | "right";
export type TapOutcome =
/** Deferred: play/pause fires only if no second tap lands within the window. */
| { action: "pending"; pendingAfterMs: number }
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
/** First tap: toggle play/pause right now. */
| { action: "togglePlayPause" }
/**
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
* undone and the play state survives the double tap unchanged.
*/
| {
action: "seek";
seekSeconds: number;
feedback: TapFeedback;
togglePlayPause: true;
};
export interface TapInput {
/** Tap x position, viewport pixels. */
@@ -35,32 +112,20 @@ export interface TapInput {
export interface TapGestureState {
/**
* Resolve a still-pending single tap. Returns the play/pause action once the
* double-tap window has elapsed, or null if there is nothing pending (the tap
* became a double tap, or was cancelled).
* Forget the previous tap, so the next one is treated as a first tap. Used
* when the gesture turns out to be a swipe.
*/
resolvePending(now: number): { action: "togglePlayPause" } | null;
/** Drop any pending tap — used when the gesture turns into a swipe. */
cancel(): void;
}
interface InternalState extends TapGestureState {
lastTapTime: number;
pendingSince: number | null;
}
export function createTapGestureState(): TapGestureState {
const state: InternalState = {
lastTapTime: 0,
pendingSince: null,
resolvePending(now: number) {
if (state.pendingSince === null) return null;
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
state.pendingSince = null;
return { action: "togglePlayPause" };
},
cancel() {
state.pendingSince = null;
state.lastTapTime = 0;
},
};
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
}
/**
* Classify a tap. The first tap of a potential pair returns `pending` the
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
* the window returns the seek and clears the pending play/pause.
* Classify a tap and return the action to perform *now*.
*
* A tap that closely follows another is the second of a pair: it seeks and
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
* first tap and simply toggles. Nothing is deferred, so there is no window to
* race and no third-tap case a consumed pair resets the state.
*/
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
const s = state as InternalState;
const sinceLastTap = input.now - s.lastTapTime;
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
// Second tap: cancel the deferred play/pause and seek instead.
s.pendingSince = null;
s.lastTapTime = 0; // consumed, so a third tap starts fresh
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
const isLeftSide = input.x < input.screenWidth / 2;
return isLeftSide
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
? {
action: "seek",
seekSeconds: SEEK_BACKWARD_SECONDS,
feedback: "left",
togglePlayPause: true,
}
: {
action: "seek",
seekSeconds: SEEK_FORWARD_SECONDS,
feedback: "right",
togglePlayPause: true,
};
}
s.lastTapTime = input.now;
s.pendingSince = input.now;
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
return { action: "togglePlayPause" };
}
/**
* Safety margin (seconds) kept between a clamped seek target and the media end.
*
* Landing *exactly* on `duration` makes hls.js request the segment whose start
* time is at/after the end of the media. The server never produces that segment,
* so the fetch times out and hls.js' gap-controller stalls forever at the last
* buffered position surfacing as "unpausing bounces straight back to paused".
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
* the final segment boundary.
*/
export const END_SEEK_MARGIN_SECONDS = 6;
/**
* Clamp an absolute seek target into the safely-playable range.
*
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
* drag path, which can otherwise land exactly on `duration` because the range
* input's `max` is the duration itself.
*/
export function clampSeekTarget(target: number, duration: number): number {
if (!Number.isFinite(target) || target < 0) return 0;
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}
export interface SeekTargetInput {
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
const target = base + delta;
if (target < 0) return 0;
if (duration > 0 && target > duration) return duration;
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
// going negative on media shorter than the margin itself.
if (duration > 0 && target > duration) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
expect(video.play).toHaveBeenCalledTimes(1);
});
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
// aborts an in-flight play(). That AbortError is transient — the element is
// still trying to play — so it must not be surfaced as a player error, or the
// UI reports failure ~once a second for the whole stall.
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
const abort = new DOMException(
"The play() request was interrupted by a call to pause().",
"AbortError"
);
video.play = vi.fn(async () => {
throw abort;
});
await adapter.play();
expect(host.onError).not.toHaveBeenCalled();
});
it("play() still reports a genuine failure", async () => {
video.play = vi.fn(async () => {
throw new DOMException("no supported source", "NotSupportedError");
});
await adapter.play();
expect(host.onError).toHaveBeenCalledTimes(1);
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
});
it("play() coalesces concurrent attempts into one element.play() call", async () => {
// During a stall the UI and recovery paths can both ask to play. Stacking
// element.play() calls is what generates the AbortError storm.
let resolvePlay: () => void = () => {};
video.play = vi.fn(
() =>
new Promise<void>((r) => {
resolvePlay = () => {
video.paused = false;
r();
};
})
);
const first = adapter.play();
const second = adapter.play();
resolvePlay();
await Promise.all([first, second]);
expect(video.play).toHaveBeenCalledTimes(1);
});
it("play() works again after a previous attempt settled", async () => {
await adapter.play();
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(2);
});
it("pause() calls element.pause()", async () => {
video.paused = false;
await adapter.pause();
+34 -1
View File
@@ -16,7 +16,7 @@
* intents flowing through the PlayerAdapter interface while preserving the
* hard-won element behavior verbatim.
*
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
getMediaSourceId(): string | null;
}
/**
* True for the `AbortError` the browser raises when a pending `play()` promise is
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
* play attempt was superseded", not "playback failed" hls.js' stall recovery
* produces it routinely, so it must not reach the player's error channel.
*/
function isPlayInterruptedError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const { name, message } = err as { name?: string; message?: string };
return name === "AbortError" || (message ?? "").includes("interrupted");
}
export class Html5PlayerAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private attachedElement: HTMLVideoElement | null = null;
/** In-flight play() attempt, so concurrent callers share one element.play(). */
private pendingPlay: Promise<void> | null = null;
private host: AdapterHost;
private bridge: Html5ElementBridge;
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
async play(): Promise<void> {
const el = this.element;
if (!el) return;
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
// gap-controller recovery path can both ask to play; stacking element.play()
// calls is what turns one stall into an AbortError storm.
if (this.pendingPlay) return this.pendingPlay;
this.pendingPlay = (async () => {
try {
await el.play();
// handlePlay on the element reports "playing"; no double-report here.
} catch (err) {
// A play() aborted by a pause() is transient, not a failure: hls.js
// nudges the element to recover from a stall, which cancels the pending
// play promise while the element keeps trying. Surfacing it would report
// an error roughly once a second for the duration of the stall.
if (isPlayInterruptedError(err)) {
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
} else {
this.host.onError(`play() failed: ${err}`);
}
} finally {
this.pendingPlay = null;
}
})();
return this.pendingPlay;
}
async pause(): Promise<void> {
+15 -4
View File
@@ -12,7 +12,7 @@
* derived + merged (remote-session-aware) stores so UI can import state and
* actions from one place, in both local and remote modes.
*
* TRACES: UR-005 | DR-001, DR-009
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-091
*/
import { get } from "svelte/store";
@@ -83,18 +83,29 @@ function requireHandle(): string {
// Transport controls (no repository handle required)
// ---------------------------------------------------------------------------
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
//
// These used to short-circuit into the active video adapter, which made the
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
// flipped the element, so Rust never saw the intent. `el.paused` flips
// transiently while an element buffers or settles a seek, so two intents
// ~150ms apart could read different values and take opposing actions — a
// self-sustaining play/pause loop.
//
// Now Rust decides from PlayerController state and drives the element back
// through a `ControlCommand` event (handled in playerEvents.ts), the same
// "backend decides, adapter executes the primitive" split used by
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
async function play() {
if (activeAdapter) return void (await activeAdapter.play());
await commands.playerPlay();
}
async function pause() {
if (activeAdapter) return void (await activeAdapter.pause());
await commands.playerPause();
}
async function toggle() {
if (activeAdapter) return void (await activeAdapter.toggle());
await commands.playerToggle();
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
*
* TRACES: UR-005 | DR-097 | UT-091
*
* The frontend used to short-circuit transport controls whenever a video adapter
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
* directly, so the Rust `PlayerController` never saw the intent and could not
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
* element buffers or settles a seek, two intents arriving ~150ms apart could read
* *different* values and perform *opposing* actions one playing, one pausing
* which is the self-sustaining play/pause loop observed on Android.
*
* The rule these tests pin: a transport intent always reaches the backend. Rust
* decides play-vs-pause from controller state and drives the webview element back
* through a ControlCommand event (the same "backend decides, adapter executes"
* split `player_seek_video` already uses).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCommands = {
playerPlay: vi.fn(async () => ({})),
playerPause: vi.fn(async () => ({})),
playerToggle: vi.fn(async () => ({})),
playerStop: vi.fn(async () => ({})),
};
vi.mock("$lib/api/bindings", () => ({
commands: mockCommands,
// Stores pulled in transitively subscribe to typed events at module load.
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
downloadEvent: { listen: vi.fn(async () => () => {}) },
searchEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
getRepository: () => ({ getHandle: () => "handle-1" }),
},
}));
/** A video adapter that records whether the facade reached into it directly. */
function makeAdapter() {
return {
kind: "html5" as const,
play: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
toggle: vi.fn(async () => true),
seekElement: vi.fn(async () => {}),
reloadSource: vi.fn(async () => {}),
attach: vi.fn(),
dispose: vi.fn(async () => {}),
setVolume: vi.fn(),
setMuted: vi.fn(),
selectSubtitle: vi.fn(async () => {}),
getPosition: vi.fn(() => 0),
load: vi.fn(async () => {}),
};
}
describe("transport authority lives in Rust", () => {
let playerController: any;
let adapter: ReturnType<typeof makeAdapter>;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
({ playerController } = await import("./index"));
adapter = makeAdapter();
playerController.setActiveAdapter(adapter);
});
it("routes toggle to the backend even when a video adapter is active", async () => {
await playerController.toggle();
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
// The webview must NOT decide play-vs-pause from the DOM.
expect(adapter.toggle).not.toHaveBeenCalled();
});
it("routes play to the backend even when a video adapter is active", async () => {
await playerController.play();
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
expect(adapter.play).not.toHaveBeenCalled();
});
it("routes pause to the backend even when a video adapter is active", async () => {
await playerController.pause();
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
expect(adapter.pause).not.toHaveBeenCalled();
});
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
playerController.clearActiveAdapter();
await playerController.toggle();
await playerController.play();
await playerController.pause();
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
});
});
+10 -4
View File
@@ -5,7 +5,7 @@
* frontend stores accordingly. This enables push-based updates instead
* of polling.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
/**
* Route a backend-originated control command to the active player adapter, so a
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
* that Rust cannot reach directly. No-op when no video adapter is active (audio
* playback is already fully backend-driven).
* backend intent can drive the webview <video>/<audio> element that Rust cannot
* reach directly. No-op when no adapter is active (native playback is already
* fully backend-driven).
*
* This is the EXECUTION half of transport authority: for webview-rendered media
* the Rust controller decides play-vs-pause from the state the element reported
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
* facade in $lib/player) and come back through this path never short-circuited
* in the webview, which is what caused the DR-097 pause loop.
*/
function handleControlCommand(action: string, position: number | null): void {
const adapter = playerController.getActiveAdapter();
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import {
resolveLibraryView,
libraryViewUrl,
LIBRARY_VIEWS,
DEFAULT_LIBRARY_VIEW,
} from "./libraryView";
describe("resolveLibraryView", () => {
it("resolves each known view", () => {
for (const view of LIBRARY_VIEWS) {
expect(resolveLibraryView(view)).toBe(view);
}
});
it("defaults to browse when the param is absent", () => {
expect(resolveLibraryView(null)).toBe("browse");
expect(resolveLibraryView(undefined)).toBe("browse");
});
it("falls back to the default rather than rendering nothing for junk", () => {
expect(resolveLibraryView("shows")).toBe(DEFAULT_LIBRARY_VIEW);
expect(resolveLibraryView("")).toBe(DEFAULT_LIBRARY_VIEW);
});
it("tolerates case and surrounding whitespace", () => {
expect(resolveLibraryView("Genres")).toBe("genres");
expect(resolveLibraryView(" all ")).toBe("all");
});
});
describe("libraryViewUrl", () => {
it("omits the param for the default view so the landing URL stays clean", () => {
expect(libraryViewUrl("/library/tv", "browse")).toBe("/library/tv");
});
it("names the non-default views", () => {
expect(libraryViewUrl("/library/tv", "all")).toBe("/library/tv?view=all");
expect(libraryViewUrl("/library/movies", "genres")).toBe("/library/movies?view=genres");
});
it("round-trips through resolveLibraryView", () => {
for (const view of LIBRARY_VIEWS) {
const url = libraryViewUrl("/library/tv", view);
const param = new URL(url, "http://x").searchParams.get("view");
expect(resolveLibraryView(param)).toBe(view);
}
});
});
+36
View File
@@ -0,0 +1,36 @@
// Which section of a video library page is showing.
//
// Browse / All / Genres used to be three routes per library, named
// inconsistently across the two libraries (`/library/tv/shows` vs
// `/library/movies/all`; `/library/shows/genres` vs `/library/movies/genres`).
// They are now one route with tabs, and this is the pure `?view=` ↔ tab
// mapping.
//
// TRACES: UR-063 | DR-105
export type LibraryView = "browse" | "all" | "genres";
/** Tab order, left to right. `browse` leads because it is the landing view. */
export const LIBRARY_VIEWS: readonly LibraryView[] = ["browse", "all", "genres"];
/** The view a page shows when `?view=` is absent or unrecognised. */
export const DEFAULT_LIBRARY_VIEW: LibraryView = "browse";
/**
* Read a `?view=` value. Anything unknown a typo, a stale bookmark, a
* removed tab lands on the default rather than rendering nothing.
*/
export function resolveLibraryView(value: string | null | undefined): LibraryView {
if (value == null) return DEFAULT_LIBRARY_VIEW;
const normalized = value.trim().toLowerCase();
return (LIBRARY_VIEWS as readonly string[]).includes(normalized)
? (normalized as LibraryView)
: DEFAULT_LIBRARY_VIEW;
}
/**
* URL for a tab. The default view omits the param, so the landing URL stays
* `/library/tv` the same convention `searchRouteUrl` uses for the `all` scope.
*/
export function libraryViewUrl(basePath: string, view: LibraryView): string {
return view === DEFAULT_LIBRARY_VIEW ? basePath : `${basePath}?view=${view}`;
}
+3 -1
View File
@@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope {
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
// `/library/shows/genres` is the TV genre route despite the differing segment.
// `/library/shows/*` is a legacy TV route that now redirects into
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
// before the redirect lands still scopes to TV.
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
return "all";
+107 -59
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
@@ -17,6 +17,7 @@
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
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 VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
@@ -28,17 +29,27 @@
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
import {
groupEpisodesBySeason,
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
initialExpandedSeasons,
type SeasonData,
} from "$lib/components/library/seriesNavigation";
let item = $state<MediaItem | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let seasonData = $state<SeasonData[]>([]);
let directFetchedEpisode = $state<MediaItem | null>(null);
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
let currentEpisode = $state<MediaItem | null>(null);
// Season ids whose episode list is open. A reading position, not a saved
// preference, so it resets with each load (DR-107).
let expandedSeasons = $state<Set<string>>(new Set());
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
@@ -81,10 +92,25 @@
error = null;
seasonData = [];
directFetchedEpisode = null;
currentEpisode = null;
expandedSeasons = new Set();
}
try {
item = await library.loadItem(itemId);
// A season is not a destination — send it to its series, anchored at that
// season, so the episodes of every season stay one continuous list.
// TRACES: UR-062 | DR-103
if (item?.kind === "season") {
const target = seasonRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// No seriesId (stale cache / deep link) — fall through to the generic
// rendering below rather than stranding the user.
}
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
@@ -129,59 +155,38 @@
}
}
// For Series, load seasons and their episodes
// For Series, load every episode across all seasons plus the episode the
// viewer is up to. Both come from Rust: the season fan-out (and the
// flat-series fallback for shows whose children are episodes rather than
// season folders) is Jellyfin's shape, and "which episode is current" is
// domain policy — neither belongs in the presentation layer.
// TRACES: UR-062 | DR-101, DR-102
if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository();
const seasons = $libraryItems.filter((i) => i.kind === "season");
// Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items
.filter((i) => i.kind === "episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes };
});
const [episodes, current] = await Promise.all([
repo.getSeriesEpisodes(itemId),
// Best-effort: a series still renders if the anchor cannot be resolved.
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
console.warn("Could not resolve the current episode:", e);
return null;
}),
]);
seasonData = await Promise.all(seasonDataPromises);
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
seasonData = groupEpisodesBySeason(seasons, episodes);
currentEpisode = current;
// Open only the season the viewer is in (DR-107).
expandedSeasons = initialExpandedSeasons(
seasonData,
current?.id,
$page.url.searchParams.get("episode")
);
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
if (episodeIdParam) {
const allEps = seasonData.flatMap((s) => s.episodes);
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
if (!foundInSeasons) {
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
@@ -189,7 +194,6 @@
}
}
}
}
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load item";
} finally {
@@ -224,14 +228,21 @@
return;
}
switch (clickedItem.kind) {
case "series":
// A season link lands on its series, anchored at that season — seasons
// have no page of their own (DR-103).
case "season":
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
break;
// An episode always opens in the context of its series (ux-flows §5B.1).
case "episode":
goto(episodeFocusHref(clickedItem));
break;
case "series":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "episode":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
@@ -244,15 +255,29 @@
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function toggleSeason(seasonId: string) {
// Reassign rather than mutate — a Set mutation is invisible to $state.
const next = new Set(expandedSeasons);
if (!next.delete(seasonId)) next.add(seasonId);
expandedSeasons = next;
}
function handleEpisodeClick(episode: MediaItem) {
// Play the episode with the series queued for next episode
goto(`/player/${episode.id}`);
// Swap focus to the episode in place; playback starts from the focus view's
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
goto(episodeFocusHref(episode));
}
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.kind === "episode" || item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.kind === "series" && itemId) {
// Open the episode the viewer is up to, where an explicit Play/Resume
// commits. Play on a container navigates; Play on a leaf plays.
// TRACES: UR-062 | DR-102
const target = seriesPlayHref(itemId, currentEpisode);
if (target) goto(target);
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
@@ -293,6 +318,11 @@
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if (item?.kind === "series" && allEpisodes.length > 0) {
// Shuffle a *series* means a random episode, not a random season — the
// player has nothing to do with a season id.
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
goto(`/player/${random.id}?restart=true`);
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
@@ -304,6 +334,10 @@
seasonData.flatMap((s) => s.episodes)
);
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
// An empty series has nowhere for the hero button to lead.
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
@@ -422,9 +456,11 @@
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
{#if item.seasonId && item.parentIndexNumber}
<!-- Links to the season's place in the series list, not to a
season page — seasons have none (DR-103). -->
{#if item.seriesId && item.parentIndexNumber}
<a
href={`/library/${item.seasonId}`}
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
@@ -466,6 +502,7 @@
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
{#if canPlay}
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
@@ -473,8 +510,9 @@
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
{playLabel}
</button>
{/if}
{#if item.kind !== "episode" && item.kind !== "movie"}
<button
onclick={handleShufflePlay}
@@ -498,6 +536,12 @@
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
<ClearHistoryButton
itemId={item.id}
itemName={item.name}
scope="series"
onCleared={loadItem}
/>
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
@@ -627,7 +671,11 @@
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
currentEpisodeId={currentEpisode?.id}
expanded={expandedSeasons.has(season.id)}
onToggle={() => toggleSeason(season.id)}
onEpisodeClick={handleEpisodeClick}
onHistoryCleared={loadItem}
/>
{/each}
{/if}
+71 -60
View File
@@ -1,6 +1,15 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<!--
The Movies library — one page, three tabs.
Was three routes (`/library/movies`, `/library/movies/all`,
`/library/movies/genres`). They are now `?view=browse|all|genres` here; the
old routes redirect.
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-105
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { library, currentLibrary } from "$lib/stores/library";
@@ -9,32 +18,47 @@
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const BASE_PATH = "/library/movies";
const categories: Category[] = [
{
id: "all",
name: "All Movies",
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
description: "Browse all movies",
route: "/library/movies/all",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/movies/genres",
},
];
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
const tabLabels: Record<LibraryView, string> = {
browse: "Browse",
all: "All Movies",
genres: "Genres",
};
const allMoviesConfig = {
itemType: "Movie" as const,
title: "Movies",
backPath: BASE_PATH,
searchPlaceholder: "Search movies...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
const genresConfig = {
itemTypes: ["Movie" as const],
title: "Movie Genres",
backPath: BASE_PATH,
genreIcon:
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No movies found in this genre",
};
async function load() {
if (!$currentLibrary) {
@@ -70,19 +94,12 @@
const genreRows = $derived($movies.genreRows);
const isLoading = $derived($movies.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
recentlyAdded.length > 0
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="space-y-6 pb-8">
<!-- Header — one per page, shared by every tab -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button
@@ -97,6 +114,22 @@
</button>
</div>
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
{#if view === "all"}
<div class="px-4">
<GenericMediaListPage config={allMoviesConfig} showHeader={false} />
</div>
{:else if view === "genres"}
<div class="px-4">
<GenericGenreBrowser config={genresConfig} showHeader={false} />
</div>
{:else if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
@@ -117,7 +150,7 @@
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/movies/all")}
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
/>
{/if}
@@ -127,35 +160,13 @@
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/movies/genres`)}
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
{/if}
</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* Movie browser (all movies)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Movie" as const,
title: "Movies",
backPath: "/library/movies",
searchPlaceholder: "Search movies...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the Movies library's All Movies tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/movies?view=all");
};
@@ -1,23 +0,0 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* Movie genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Movie" as const],
title: "Movie Genres",
backPath: "/library",
genreIcon:
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No movies found in this genre",
};
</script>
<GenericGenreBrowser {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the Movies library's Genres tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/movies?view=genres");
};
@@ -1,23 +0,0 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* TV show genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Series" as const],
title: "TV Genres",
backPath: "/library",
genreIcon:
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No shows found in this genre",
};
</script>
<GenericGenreBrowser {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the TV library's Genres tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/tv?view=genres");
};
+81 -64
View File
@@ -1,6 +1,15 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<!--
The TV library — one page, three tabs.
Was three routes (`/library/tv`, `/library/tv/shows`, `/library/shows/genres`,
the last of which did not even share a prefix with the others). They are now
`?view=browse|all|genres` here; the old routes redirect.
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-103, DR-105
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { library, currentLibrary } from "$lib/stores/library";
@@ -9,32 +18,48 @@
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const BASE_PATH = "/library/tv";
const categories: Category[] = [
{
id: "shows",
name: "All Shows",
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
description: "Browse all series",
route: "/library/tv/shows",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/shows/genres",
},
];
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
const tabLabels: Record<LibraryView, string> = {
browse: "Browse",
all: "All Shows",
genres: "Genres",
};
const allShowsConfig = {
itemType: "Series" as const,
title: "TV Shows",
backPath: BASE_PATH,
searchPlaceholder: "Search shows...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
const genresConfig = {
itemTypes: ["Series" as const],
title: "TV Genres",
backPath: BASE_PATH,
genreIcon:
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No shows found in this genre",
};
async function load() {
if (!$currentLibrary) {
@@ -57,13 +82,20 @@
function handleItemClick(item: MediaItem) {
switch (item.type) {
case "Series":
// A season lands on its series, anchored at that season (DR-103).
case "Season":
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
break;
// An episode opens inside its series, never as a bare episode page and
// never straight into the player (ux-flows §5B.1, §5B.5).
case "Episode":
goto(episodeFocusHref(item));
break;
case "Series":
case "Folder":
goto(`/library/${item.id}`);
break;
default:
// Episodes and movies play directly.
goto(`/player/${item.id}`);
break;
}
@@ -83,13 +115,8 @@
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="space-y-6 pb-8">
<!-- Header — one per page, shared by every tab -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
@@ -104,6 +131,22 @@
</button>
</div>
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
{#if view === "all"}
<div class="px-4">
<GenericMediaListPage config={allShowsConfig} showHeader={false} />
</div>
{:else if view === "genres"}
<div class="px-4">
<GenericGenreBrowser config={genresConfig} showHeader={false} />
</div>
{:else if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
@@ -120,11 +163,7 @@
<!-- Next Up -->
{#if nextUp.length > 0}
<Carousel
title="Next Up"
items={nextUp}
onItemClick={handleItemClick}
/>
<Carousel title="Next Up" items={nextUp} onItemClick={handleItemClick} />
{/if}
<!-- Recently Added -->
@@ -133,7 +172,7 @@
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/tv/shows")}
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
/>
{/if}
@@ -143,35 +182,13 @@
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/shows/genres`)}
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
{/if}
</div>
-27
View File
@@ -1,27 +0,0 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* TV show browser (all series)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Series" as const,
title: "TV Shows",
backPath: "/library/tv",
searchPlaceholder: "Search shows...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the TV library's All Shows tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/tv?view=all");
};
+34 -24
View File
@@ -14,6 +14,7 @@
import { get } from "svelte/store";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
import { shouldReuseActivePlayback, resolvePlayerSurface } from "$lib/components/player/playerSurface";
import NextEpisodePopup from "$lib/components/player/NextEpisodePopup.svelte";
import {
reportPlaybackStart,
@@ -72,6 +73,10 @@
let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadedItemId: string | null = null;
// Which player component to render. Video without a stream URL is "pending"
// (still resolving), never audio — see playerSurface.ts.
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl }));
onMount(() => {
// Start position polling (only for audio via MPV backend)
pollInterval = setInterval(updateStatus, 1000);
@@ -137,30 +142,33 @@
return;
}
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer).
// forceRestart bypasses this so advancing to the next episode always
// restarts from the beginning even if it were already loaded.
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isLive = item.kind === "liveChannel";
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
// Fetch next episode for video skip button
if (isVideo) {
fetchNextEpisode(item);
}
return;
}
// Determine if this is video content (Movie, Episode, live TV channels, and
// channel leaf items that carry a video stream).
isLive = item.kind === "liveChannel";
isVideo = item.kind === "movie" || item.kind === "episode" || isLive || isVideoChannelItem(item);
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer).
// Audio only, and forceRestart bypasses it so advancing to the next
// episode always restarts from the beginning — see playerSurface.ts for
// why video must never take this shortcut.
const alreadyPlayingMedia = get(storeCurrentMedia);
if (
shouldReuseActivePlayback({
requestedId: id,
activeMediaId: alreadyPlayingMedia?.id,
isVideo,
startPosition,
forceRestart,
})
) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
return;
}
// When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state
if (isVideo) {
@@ -650,10 +658,6 @@
</div>
</div>
</div>
{:else if loading}
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if error}
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50 p-4">
<div class="text-center max-w-lg">
@@ -667,7 +671,13 @@
</button>
</div>
</div>
{:else if isVideo && streamUrl}
{:else if loading || surface === "pending"}
<!-- "pending" = video whose stream URL has not resolved yet. Showing the
spinner keeps it out of the audio player. -->
<div class="fixed inset-0 bg-[var(--color-background)] flex items-center justify-center z-50">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if surface === "video" && streamUrl}
<VideoPlayer
media={currentMedia}
{streamUrl}