878ac5fa59f4024ef2e47a59c3d663eb24179500
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
30dc3ba7f6 |
fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117 |
||
|
|
62873cab3d |
feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently. |
||
|
|
c55ff45692 |
fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.
None of the app's safe-area handling was ever active, for two independent
reasons:
1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
resolved to 0px — the padding in app.css and BottomUi was a no-op.
2. Android WebView maps only the *display cutout* into `env()`; the status bar
and navigation bar are never reported. With enableEdgeToEdge() and
targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
spans them, so CSS could not learn about them by any route.
WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.
Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.
The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.
Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
0da0a9f16c |
fix(ci): derive traceability denominators from requirements.md (DR-093)
The coverage gate divided traced counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, TOTAL_REQS=114) that had fallen out of date as requirements grew to 211. It reported 158% coverage — JA alone printed 800% — so the 50% threshold was mathematically unreachable and the job could not fail. Coverage could have collapsed to 30% and CI would still have printed a green tick. Real coverage is 86%. The number was fine; the gate was dead. extract-traces.ts now owns both sides of the fraction: - countDefinedRequirements() counts an ID only where it leads a markdown table row, ignoring the "Traces To" column and prose. IDs are deduplicated because requirements.md lists every UR twice (§1 definition + §3 matrix), which would otherwise report UR as 121/61. - computeCoverage() uses the intersection of traced and defined IDs, so a TRACES comment naming a deleted or typo'd requirement is reported as `orphaned` rather than inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. - CI reads .coverage.percent and fails on <50% or >100%; a >100% reading is now a hard error rather than the condition that hid this bug. - New `bun run traces:coverage` runs the same computation locally. - scripts/ added to the scan roots — the coverage tool was invisible to the matrix it generates. Tests written first (15, over fixtures so they don't drift as requirements are added). vitest include widened to scripts/** so build tooling is covered by the normal suite. Verified empirically rather than by inspection: forcing the threshold to 99% fails; adding a requirement lowers coverage 86%→85%; a TRACES: DR-999 lands in `orphaned` without changing `covered`. traceability-ci.md documented the same stale numbers and would have let the broken arithmetic be reconstructed — replaced with a pointer to the live command. |