d952a2ae5530e4ab8ac963632b9d1345c0626c82
112
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
14b6a8609d |
fix(player): three defects native video exposed, and the logs to see them
Each of these was invisible while Linux video played in the webview, and each became reachable the moment mpv started rendering. DR-238 — a transcoded seek re-negotiates the stream on every renderer, not just the webview. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer: it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make Jellyfin transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing. One cell of the truth table changes; all four webview cells are byte-identical. DR-239 — properties the mpv event loop handles are now observed. libmpv delivers PropertyChange only for properties registered with observe_property, so the `pause` arm was unreachable code that read as implemented: StateChanged was never emitted and the play/pause control never moved. UT-218 asserts the two lists agree, so the class cannot recur. DR-240 — fullscreen moves whatever owns the pixels. requestFullscreen() fullscreens the *document*, which sufficed while the <video> element lived inside it and WebKit scaled it. A native surface is drawn behind the webview at window size, so a document-only fullscreen expanded the page and left the picture at its old size — on WebKitGTK, a maximised window with decorations still holding a strip of the screen. Measured on a 3440x1440 panel: 1361 tall before, 1440 after. DR-241 — a seek issued before mpv has a file to seek in is honoured rather than dropped. loadfile returns as soon as the command is queued, so `time-pos` does not resolve yet and setting it fails. The two callers that always hit that window are resume and a transcoded seek, both of which re-open the stream and then ask for a position; the failed seek was discarded and playback began at zero. Also adds the instrumentation that made the diagnosis possible rather than speculative: an entry log on player_stop, a render-size log that re-fires on change instead of latching once, and decoded-vs-display video geometry on file load. The last of those retired a wrong theory — a picture that does not fill an ultrawide turned out to be a 16:9 source with its letterbox baked in, not a rendering fault. |
||
|
|
45144cb6b0 |
feat(video): render mpv behind the webview, and collapse duplicated helpers
DR-231 with the design the failed reparent forced. mpv's render API draws into
an FBO we own; the texture is composited by `gdk_cairo_draw_from_gl()` in the
default vbox's own `draw` handler. GTK draws a container before its children, so
the webview lands on top for free — no reparenting, no GtkOverlay, and nothing a
Tauri upgrade can invalidate by assuming its own widget layout.
Split so Windows inherits the useful half: `mpv_render` is the portable side
(render context, framebuffer, GL resolution) and `video_surface` is the GTK side
that consumes it. Nothing in the former is GTK-aware.
Three things the spike paid for, carried over rather than rediscovered:
- libepoxy exports GL entry points as *data* symbols. `dlsym("epoxy_glFoo")`
returns the address *of a function pointer*, not of code — returning it
makes mpv jump into non-executable data and take SIGSEGV on the first GL
call. The value is read out of that location instead.
- Frame pacing goes through mpv's update callback plus `report_swap`. Its
absence looks like a GPU or compositing limit (fine in a window, judders at
fullscreen) and is neither.
- The render context is created on `realize` and destroyed on `unrealize`,
with the update callback unregistered *before* the free, so a callback
cannot land on a freed pointer. That is DR-232 built in from the start
rather than retrofitted: the spike had no teardown at all, which remains the
likeliest explanation for the one SIGSEGV it could not reproduce.
Writing it also caught a bug that would have looked like severe stutter: the
update callback flagged a new frame but never asked GTK to repaint, so decoded
frames would only have reached the screen when something else happened to
invalidate the widget.
Still off by default behind JELLYTAU_NATIVE_VIDEO=1. It compiles and is wired;
no frame has been put on screen yet.
Redundant code, continued. `formatSecondsDuration` had no caller. Three
components had hand-rolled `formatDuration`: Queue's was byte-equivalent to the
shared "mm:ss", while EpisodeFocusView and the library page shared an identical
"1h 23m" shape the util did not offer — so that format joins the other two and
all three components now call one function.
A survey for exported symbols referenced only by tests returns 23 more. They are
deliberately left: spot-checking found `setLogForwarder` is the injection seam
for a lazily-initialised forwarder, and `getCachedImageUrl` is the read path of
a thumbnail cache whose management UI exists in Settings. Neither is dead — one
is test infrastructure and the other is an unwired feature, and deleting either
would remove capability while looking like tidying. The list is worth working
through deliberately, not in a playback branch.
|
||
|
|
7545de6cc7 |
refactor: delete two orphans, and record why the reparent design changed
`resolveVideoSource` chose between a local file and a remote URL for video playback. Backend-owned stream selection took that decision into Rust — `media_local_selection` for a downloaded file, `get_stream_selection` for a streamed one — and its last caller went with it. What remained was the function plus sixty lines of tests exercising nothing that ships. `fittedVideoSize` computed the rendered size of a video letterboxed into its container. Nothing has ever called it: it arrived with the fix that made the video fill its viewport and was superseded by `object-fit: contain` in the same change. There is some irony in a helper that models letterboxing sitting unused beside a container that was not letterboxing at all — the bug fixed in the previous commit was CSS, and this function would not have helped. A survey for exported symbols referenced only by their own tests finds 22 more. Most are legitimate — test mocks, deliberate reset hooks, public utility APIs — and the rest are unrelated to this work, so they are left for a cleanup that can be reviewed on its own terms rather than smuggled into a playback branch. Also records in the spec why DR-231's design changed. Reparenting Tauri's webview into a GtkOverlay aborts the process on the first click: Linux calls `attach_resize_handler` unconditionally (the Windows path guards it with `is_decorated()`), and its handler walks webview -> GtkBox -> GtkWindow with an unwrap that an overlay breaks. So the webview is not moved at all — mpv draws into the default vbox's own `draw` handler via `gdk_cairo_draw_from_gl()`, and GTK's container-before-children order puts the webview on top for free. No reparent, one less widget, and nothing a Tauri upgrade can invalidate by assuming its own layout. |
||
|
|
a8c44145ff |
fix(player): letterbox the picture, and stop racing the session
Two bugs found by resizing the window during playback. Neither was introduced by this branch; both are the kind that only surface when somebody actually drags a window edge. The picture cropped and sat at the top instead of letterboxing. The video's flex wrapper had no `min-h-0`, and a flex item defaults to `min-height: auto` — it refuses to shrink below its content's intrinsic size, and a <video> reports the *media's* natural dimensions. So whenever the picture was larger than the window the wrapper grew past the viewport, the overflow went off the bottom, and what was visible was the top-left of an uncentred, uncropped image. `object-contain` was doing its job the whole time, inside a box that was the wrong size. This is also what put the picture at the bottom in fullscreen, reported earlier and unexplained until now. "Not connected to a server", shown as a *playback* error. The player page asks for the repository on mount, but the session is restored asynchronously at startup, so losing that race turned a perfectly good stream into a fatal error screen. `getRepository()` throwing instantly is right for a click handler, where the user is present; it is wrong for anything that runs on mount. `waitForRepository()` resolves as soon as the session lands and still rejects when there genuinely is not one, so a real logged-out state surfaces — just not as a race. Worth recording how this was found, because it was nearly misdiagnosed: the symptom correlated with window resizes, but the log showed 230 Vite HMR updates against a single app start — the frontend was being remounted under the test by edits made while it ran, and a remount empties the in-memory auth store. The race is real and worth fixing on its own merits, but "resize causes it" was an artifact of how it was being observed, not a property of the bug. UT-215 covers the waiting contract: resolves when already restored, resolves when the session arrives late, still rejects when there is none, unsubscribes once settled, and leaves no armed timer to reject an already-resolved promise. |
||
|
|
fecd6022fe |
chore(traceability): shift this branch's ids clear of master's
Master allocated DR-224 and UT-211 while this branch was in flight — the third collision on this work. Everything here moves up by one: DR-224..236 become DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were still free and are unchanged. Mechanical, and matched on each row's own text rather than on its number, so a row cannot be shifted twice or the wrong one caught. Master's DR-224 (the background-audio toggle) and UT-211 are untouched. |
||
|
|
4f6cf22419 |
fix(player): tell the native backend's caller what it actually did
Two defects found by running on an Android tablet, both invisible on the desktop, and both the same mistake: a rule written for the webview applied to a backend that is not one. The quality picker froze on the first stream. `StreamQualityResponse::Native` carried only a position, so nothing replaced the selection the UI holds after a native quality change. The picker derives the rung in force from that selection's rendition, and a transcode always has a rendition — so the fallback that would have used the requested value was never reached. The stream changed and the menu did not. The native variant now carries the `StreamSelection` the backend opened, like the HTML5 variant already did. This was invisible on the desktop because the webview path replaces the selection as a side effect of reloading its element. It looked correct there for a reason that does not generalise. A quality change restarted playback from zero. The resume position came from `videoElement.currentTime`, which the frontend cannot supply on a native backend — there is no `<video>` element, so it correctly sends null and the backend substituted 0. Reading it from the DOM at all inverts the rule that the player is the authority on playback state; the fallback now asks the controller where it is. Captured before the negotiation round-trip, so it resumes a few hundred milliseconds behind rather than ahead, which is the right direction to err. Also from the tablet, and NOT fixed here because it changes playback behaviour and deserves its own change: `audio_forces_transcode` judges against `WEBVIEW_AUDIO_CODECS` on every platform, and `video_audio_codecs` narrows the advertised direct-play audio set to that same webview list. On Android the decoder is ExoPlayer. The tablet reports dts among its platform codecs, has it stripped from the profile, and then has the webview rule force a transcode for it. That is the third instance of a decode capability tied to the wrong renderer, and it is what DR-233 exists to collapse — evidence now, not a design preference. It also corrects the record on this branch's headline number. The measured 85% direct-play rate used a hypothetical Android profile including ac3/eac3; this tablet's MediaCodecList reports neither, so eac3 content — about a third of the sampled library — correctly transcodes here. 85% was the ceiling of a profile the app does not send, on hardware that could not use it. The negotiation and the contract are sound; the figure was not a measurement of what ships. |
||
|
|
84cf31b929 |
feat(video): build the native video surface, and fix what running it exposed
Three things, all found by actually running the app rather than by reading it.
The surface (DR-230). A GtkGLArea as the main child of a GtkOverlay with
Tauri's own webview reparented on top — the desktop shape of what Android
already does with ExoPlayer. It attaches cleanly and is then **off by
default**, because the reparent fails the gate the spike said it would.
`tauri-runtime-wry`'s undecorated-resizing handler walks a hard-coded path on
every button press in the webview:
webview.parent() // "This one should be GtkBox"
.parent() // ...and this one the GtkWindow
.downcast::<gtk::Window>().unwrap()
Wrapping the webview makes that chain webview -> GtkOverlay -> GtkBox, the
downcast fails, and the panic is non-unwinding so it aborts the process. The
decoration check that would make the handler inert runs *after* the unwrap, so
no window configuration avoids it. The surface attaching successfully is
therefore not the gate — a click is. It lives behind JELLYTAU_NATIVE_VIDEO=1
with the mechanism written down, because the next attempt needs to keep Tauri's
two-hop shape intact and that is the whole design constraint.
Also settles a dependency question the spike left implied: the render API is
reachable from the pinned libmpv revision. Its safe `render` module is an empty
stub, but libmpv-sys carries every render symbol and `Mpv::ctx` is public, so
the context can be built over the handle the audio backend already drives. This
does not need the libmpv2 migration first.
The HLS effect re-ran on object identity. `currentSelection` is a struct, and
every reload replaces it even when the URL and transport are unchanged — so the
effect tore down hls.js and reattached for an unchanged stream, leaving the
element blank until a seek forced another cycle. The pre-DR-224 code read a
plain URL *string*, where re-assigning the same value was a no-op; the codebase
documents relying on that and swapping in a struct broke it silently. The
loader decision now takes a primitive transport tag, so the component cannot
depend on object identity — the bug is unrepresentable rather than merely
fixed.
The device profile contradicted itself. The direct-play profile claimed h264
alone on the webview path while the transcoding profile said "you may transcode
to h264 or hevc" — telling the server "I cannot play hevc, so re-encode it" and
then "re-encoding it to hevc is fine". Streams came back carrying
VideoCodec=h264,hevc with hevc-level/profile/bitdepth set. When the server took
that option the webview got something it could not decode, which presents as
video stuck on its first frame rather than as an error. Transcode targets are
now derived from the same codec list as direct play, capped to the two codecs a
Jellyfin server actually encodes so a wider decode list never asks for an av1
encode.
That is the third defect in one family: a decode capability stated in more than
one place, with the copies disagreeing. DR-233 exists to collapse them into one
renderer-derived source, and this is evidence for it rather than a preference.
Not fixed here, and worth knowing:
- The requested VideoBitrate is sized to the ceiling, not to the source — a
2.2 Mbps source was being re-encoded at 19.8 Mbps, roughly 9x. Pre-existing,
but this branch is the first thing that knows the source bitrate and so the
first that can cap it.
- The `debug` build type produces an APK with the *release* applicationId:
`applicationIdSuffix = ".debug"` is present in the canonical gradle and absent
from the generated copy, though the identical line in the `release` block
survives. Not caused by our sync, which is a plain cp. Independent of this
work; it is why the side-by-side release build is the one that installs.
|
||
|
|
109700b949 |
feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
|
||
|
|
edff6eedc9 |
fix(player): let the background-audio toggle govern backgrounding again
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
Locking the screen kept a video's audio playing whether or not the background-audio button was on. Reported as "audio only mode is always active even if not selected". The button (UR-040) was built for the WebView <video> path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a MediaSessionService -- a foreground media service whose entire purpose is to keep playing while the app is hidden. Nothing stopped it, and nothing in the codebase paused on background. So the button governed a handoff that no longer had a gap to bridge. There was no interruption to paper over, and a user who never touched it got background playback anyway. The gating made it self-concealing: MainActivity.onStop only dispatched 'jellytau-background' when backgroundAudioEnabled was already true. The one notification that the app had gone away was itself conditional on the setting, so with the button OFF nothing could react even in principle. onStop and onStart now fire unconditionally and carry the two facts only the activity knows -- whether the toggle is armed, and whether Android put the window into picture-in-picture. What to do about it is decided in Rust (player/background_policy.rs), because it depends on whether the item has a picture to lose: video + toggle off -> Pause video + toggle on -> HandOffToAudio music, either -> KeepPlaying (no picture to give up) picture-in-picture -> KeepPlaying (the window is still on screen) It takes no renderer parameter on purpose. Two renderers with two behaviours and one toggle reaching only one of them is what produced the defect; a rule that cannot see the renderer cannot reproduce it. Two failure modes are deliberate. A decision call that fails leaves playback alone rather than risking silence mid-listen. An event with no detail -- older Kotlin against newer JS -- reads as "armed, not PiP", degrading to the previous behaviour instead of pausing unexpectedly. Foregrounding resumes only what backgrounding paused: a video the user paused themselves before locking stays paused. Written test-first per CLAUDE.md. The stub encoded today's behaviour (nothing ever pauses) and failed exactly as reported -- `left: KeepPlaying, right: Pause` -- before the rule was implemented. Verified on a device, R8-minified, both directions: [player_background_action] video=true armed=false pip=false -> Pause [player_background_action] video=true armed=true pip=false -> HandOffToAudio UR-040 / DR-224 / UT-211. |
||
|
|
ad48d89dfe |
chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again. |
||
|
|
16658889a2 |
fix(home): restart the hero banner timer on a manual change
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m31s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Failing after 14m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The rotation interval was installed once when the banner mounted and never touched again, so a swipe, arrow or dot tap inherited whatever was left of the running countdown — swiping 5.5s into a 6s interval moved the banner on half a second later. The timer moves into heroRotation.ts as a small restartable object so it can be unit-tested, and every manual navigation path restarts it from that moment. Verified red-first: with restart() reverted to leave a running timer alone, the regression test fails. Release 0.9.1. |
||
|
|
0815445aa7 |
feat(library): exclude chosen folders from music browsing
Replaces a hardcoded filter that dropped anything named "Podcasts" from music results — one user's library layout compiled into the shipped product, keyed on an English literal, applied only at the six call sites someone had remembered. Exclusion is now a user setting stored in Rust and applied at the repository layer's convergence points, so scope is decided once and is the same on every screen. It matches on folder id rather than name: a title is not what an item is, which is why an album legitimately called "Podcasts" used to vanish. Deliberately not filtered: get_item (an id asked for by name was navigated to on purpose, and refusing it would break playback of anything inside a hidden folder), get_downloaded_items (hiding a download would leave the user unable to delete a file whose disk usage they can still see), and the offline cache (an exclusion is a view preference and must be reversible without a re-crawl). Also removes src/lib/utils/validation.ts — six exported validators with no caller outside their own test file, which made the module read as covered input validation while guarding nothing. TRACES: UR-076 | DR-209 | UT-203 |
||
|
|
6b90582e3e |
chore(tooling): add lint/format gates, pin the toolchain, enforce commit checks
Adds the frontend's first linter and formatter — the Rust half has had cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had only svelte-check. ESLint runs clean; 159 findings are recorded as warnings rather than suppressed, so the backlog is visible without painting CI red. Also: `bun run test` no longer drops into watch mode (the "Before Committing" list told people to run a command that never returns), the traceability ratchet moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead of relying on memory, the dead webdriverio e2e suite and its five devDeps are removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and the CI builder image stop being five releases apart. TRACES: | DR-205, DR-206, DR-207 |
||
|
|
ac3cd67164 |
feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround that dropped any item whose name, album, album artist or artist was literally "Podcasts" — with a real user setting applied in Rust. The old filter was wrong twice over: it hardcoded one user's folder layout keyed on an English literal, and it put a domain rule (what a query should return) in the presentation layer. It slipped past `check:boundary` only because it matched on names rather than on an item-type array. - `repository::exclusions` owns the rule and the process-wide id set, the same shape as `online::STREAMING_QUALITY` so it survives a repository being rebuilt on re-login. - `HybridRepository` applies it where the cache and server legs of every cache-first query converge (`parallel_race` / `race_with_refresh`), plus the bespoke `get_items` path and the server-only reads. Filtering before the "has content" check is what makes a cache page of nothing but hidden items fall through to the server. - Exclusion is by stable item id, never by name, and matches an item's own id or any container link it carries (parent, album, library, series, season, artist). - A direct `get_item` lookup and the Downloads surface are deliberately unfiltered: hiding those would break playback and file management of anything inside a hidden folder. - `LibrarySettings` persists to `app_settings` and is restored in the setup hook, alongside the streaming-quality cap. Default is an empty list — nobody inherits the old "Podcasts" behaviour. - New commands `library_get_settings`, `library_set_settings` and `library_get_exclusion_candidates`; the candidates read goes through `get_items_unfiltered` so an already-hidden folder still appears in the picker and the setting can be undone. - Settings page gains a "Hidden Folders" section that renders the backend's candidate list and sends back ticked ids; it decides nothing. TRACES: UR-076 | DR-209 | UT-203 |
||
|
|
95eb16d5ef |
chore(tooling): add eslint + prettier, fix the test watch-mode default
Three gaps in the frontend tooling, all in the package.json script surface.
1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.
Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
`lint`, `lint:fix`, `format`, `format:check` scripts.
The tree is error-clean (`npx eslint .` exits 0). Getting there needed
seven real one-line fixes (braced switch cases that leaked `const` across
arms, a useless regex escape, two `let`s that never change, a thrown Error
that dropped its `cause`, and two `// eslint-disable-next-line` comments
documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
that fires is set to `warn` with the reason written next to it in
eslint.config.js — notably ~94 dead bindings and `any` at the IPC
boundary. Those are real findings to drive to zero, not noise to delete.
`no-console` is OFF for now: a parallel change is moving all ~468 console
calls onto a logger facade, and turning the rule on today would collide
with it. eslint.config.js says so, and says to flip it to `error` once
that lands.
`prettier --write` is deliberately NOT run here — it would rewrite ~200
files and swamp every other diff in flight. The gate is available; the
sweep is a separate commit. Markdown and CI YAML are in .prettierignore
because both are hand-laid-out (and docs/traceability.md is generated).
2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
"Before Committing" list tells people to run it. It is now `vitest run`,
with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
scripts/test-all.sh drops the now-redundant `--run`, and
scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
them to a long-running vitest instead of the single-pass one.
3. The webdriverio e2e suite is deleted. It was last touched in January
("First working POC"), has never run since, and is not in CI — five
devDependencies and two scripts of pure decoration. Removes e2e/,
wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
devDeps, and the WebdriverIO block in .gitignore.
The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
|
||
|
|
d54d8cc7c4 |
refactor(logging): route frontend console calls through the logger
TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself. |
||
|
|
ebf9a99b80 |
docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES anywhere in the tree. The features work — the tags were simply never written — so the matrix over-reported on exactly the requirements a reviewer would most want to verify. Each is now tagged at the code that actually implements it: - JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at their Jellyfin call sites in repository/online.rs (search, get_item's MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE, get_person/get_items_by_person), plus the commands that expose them. - UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and LockscreenMetadata / update_lockscreen_metadata. - IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the manual AudioFocusRequest listener for video — and at the media-type string that chooses between them. - UR-037 (with DR-042, also untraced) on the video-library poster grid: LibraryGrid, MediaCard, and the tv/movies routes. Resolve contradictory statuses across layers, evidence first: - IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv. MpvBackend is the audio-only backend and overrides neither set_subtitle_track nor set_audio_track — the trait's not_implemented() default still stands — so UR-020/UR-021 are met by ExoPlayer and by the HTML5 <video> path instead. Both IRs are re-scoped to those backends and marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs follow. - IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in the project and update_lockscreen_metadata is a no-op off Android. UR-006 is corrected to Done (Android) rather than the IR being marked Done. - A note under the IR table records where a UR is met by a different mechanism than its IR anticipated. Define the two dangling IDs the source already referenced: DR-189 (the control bar never auto-hid on a touchscreen, because its timer was armed only from onmousemove) and UT-188 (its rule test). The live-denominator assertion in extract-traces.test.ts moves 187/330 to 188/331 accordingly. Traced requirements 444 to 459; IR coverage 19/32 to 25/32. |
||
|
|
5e8efa252e |
fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a play overlay pinned at 0:00, a seek bar at zero, and a play button that did nothing. Nothing crashed — the process stayed up and the frontend kept logging — the transition was simply dropped. The two render paths resume by different means, and exitBackgroundAudioHandoff only ever performed one of them. The webview <video> reloads off its stream URL: an $effect watches it, reinitialises HLS or sets element.src, and canplay drives the seek and play. ExoPlayer owns no element and nothing watches the URL on its behalf — native playback is only ever started by an explicit player_play_item plus adapter load, which the component issues once, from onMount. So reassigning the URL restarted precisely nothing, and since player_exit_background_audio had already stopped the handoff's audio player, the backend came back holding no item at all. That is why the play button was inert: there was nothing loaded to play. The return now re-issues that pair on the native path, in the same order as the initial load, carrying the position the audio reached. Subtitle configurations are reused from the ones resolved at mount — ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Which path to take is decided by planHandoffReturn, a pure helper in backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the player. It also folds in shouldResumeOnForeground, so a pause taken on the lockscreen during the handoff still wins over the snapshot captured on the way out. Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54, return restored native video playing at 70:18. Previously the same sequence left the player idle and black. The requirements count pin in extract-traces.test.ts moves with the new DR-196. |
||
|
|
8e98e1c37a |
test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it renders the real component against the real bindings, which bottom out in the globally mocked `invoke`. That mock resolves `undefined` for every command, so any command whose result is *rendered* blows up: the quality picker assigns the result straight to state and the template then reads `streamingQualities.length`, which throws on undefined. It threw asynchronously, outside any test, so the suite reported 4 unhandled errors while every test still passed — the state vitest warns "might cause false positive tests". Answering the two rendered commands removes them. Authored in the main checkout; brought in here and verified: 83 files, 1009 tests, and the unhandled-error count drops from 4 to 0. |
||
|
|
c142568230 |
fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen tap, from the control bar, and from a direct player_toggle invocation — while seek and skip kept working. That asymmetry was the whole clue: seek decides in player_seek_video, transport decides in toggle_playback. DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is active and in this state", and toggle_playback/play/pause all route transport to that element whenever it is set. The player route mirrored element state into it UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress, which VideoPlayer calls on a 10-second interval. So on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. It also explains the flashing: the control bar and the JRay overlay both key off isPlaying, which was being contradicted on every tick. The mirror now lives in mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only place that knows whether an element renders at all. The route cannot tell the paths apart, which is exactly how it came to lie. DR-193 hands transport authority back to the native backend when an item loads into it. Necessary but insufficient alone: the progress interval put the flag straight back, which is why the first device test after it still failed. DR-192 presents native video through a TextureView instead of a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it, and everything drawn above that hole — here, the entire Svelte UI — depends on that composition path. The overlay dropped its incremental damage: the DOM advanced (slider 476 -> 479 across three seconds) behind a screen showing neither, so the progress bar froze, controls would not fade and rotation lost the transport UI, while structural DOM changes got through, which is why the play overlay always appeared to work. It supersedes DR-191, which forced redraws in a loop and treated the symptom. DR-194 hides the video view across a resize and reveals it two frames later. A TextureView retains its last frame, so between a rotation and the re-fit landing that frame is stretched across the old rect and the previous frame flashes in what should be the letterbox bars. Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the live DOM over the devtools socket: surface tap pauses (position frozen across 12 seconds, overlay raised, transport flipped) and resumes; the control bar does both. UT-189 drives the real 10-second interval under fake timers — an earlier version asserted on a freshly mounted player, passed with the guard deleted, and guarded nothing. Still open, and deliberately not claimed: DR-192's effect on the overlay repaint is unverified on device, DR-194's letterbox reset is untested, and the native default (DR-188) stays off pending DR-190, the background-audio return. |
||
|
|
95129d04a3 |
fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no picture, naming the compositing as the suspect. The compositing was fine. Five separate defects sat between ExoPlayer and the screen, each able to produce that exact symptom on its own, and each invisible to the others. DR-185 — the app shell painted over the surface. app.css clears the page's opaque layers through three selectors, one of which targets `[data-app-shell]`, an attribute NO component has ever set, in any commit. The shell paints --color-background across the whole viewport and VideoPlayer stacks above it, so the WebView composited opaque no matter what else was cleared. Invisible three ways over: the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent. DR-182 — nothing could lift the poster card. Every markMediaReady() call site is an HTML5 <video> event, and the native branch renders no element, so the black title card covered the surface for the entire session. The first fix hooked `player://position-update` / `player://state-changed`; those channels are never emitted by the backend, so it passed a test that fired them by hand and did nothing on a device. Driven from the player store now, as the seek bar already was. DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by walking the view tree, while WebView binds injected objects at page-load time, and the identity guard then declined to re-inject forever. setTransparent(true) could never arrive. Installed from WryActivity.onWebViewCreate instead, which wry calls immediately before the first loadUrl. DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers anywhere, mirroring the DR-151 defect: every native video left its surface parented to the content view and the next one stacked another beneath it. DR-191 — the overlay stopped repainting. Incremental damage (the clock's text, the control bar's opacity) never reached the screen while structural changes did, so the progress bar froze, the controls would not fade, and the play overlay appeared to work because it is added and removed from the DOM. Driven from the Activity via postInvalidateOnAnimation while compositing is on. Two UI defects only this path could reveal came with them: isPlaying froze at its initial value, leaving the play overlay dimming and covering the video (DR-186), and the control bar's auto-hide was armed solely by mousemove, which a touchscreen never fires (DR-189). Immersive mode now applies on entering the player rather than only via the fullscreen button (DR-187). Verified on a device (Honor ROD2-W09, Android 16): logcat carries `WebView transparent = true` and `Marking media ready` with video on screen — the pair DR-172 went looking for and could not find — and skip, seek, rotation and subtitle rendering were exercised by hand. The default stays OFF (DR-188). Turning it on surfaced a further unverified sub-path: returning from background audio is HTML5-only, so playback stays dead (DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one. |
||
|
|
c0c6c5023e |
fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
A resumed transcode played nothing at all: every segment came back 400, hls.js exhausted its retries and gave up, while the same episode from the beginning was fine. Jellyfin builds each segment URI by echoing the master playlist's query string into it, and its segment handler opens by rejecting any request carrying StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0` being exactly why starting from the beginning survived. HLS does not need the parameter: a playlist spans the whole item and asking for segment N *is* the seek. It is removed from the URL builder entirely rather than conditionalised — the builder cannot know whether its response will be segmented — and the position becomes a seek issued once the player has loaded. The progressive /Audio/universal builder behind the background-audio handoff has no segments and keeps its StartTimeTicks, which is why audio-only handoffs resumed correctly and video ones did not. Completing that across the boundary, since the URL no longer starts where the caller asked: - reloadSource(url, position) now means "reload and resume AT this absolute position": it seeks the element once the source is playable and clears the transcode offset to zero. It previously set the offset to the position and seeked nothing, which was correct only while the URL itself began there — left in place it would have shown 20:00 on the scrubber while the opening titles played, with no seek ever happening. - The transcoded resume path in the player page collapses into the same "seek after load" branch direct streams already used. - VideoPlayer's background-audio return does the same: no base, seek to the absolute position. - The stale test asserting StartTimeTicks is present is rewritten to keep its other half (an HLS master playlist, never a progressive stream.mp4, carrying the chosen source and audio track). TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183 |
||
|
|
5096c01960 |
fix(player): restore the subtitle sidecar work dropped by the previous commit
The previous commit was assembled from a tree read before
|
||
|
|
2d67b0e4f5 |
fix(player): give every transcode its own play session, and stop the one it replaces
Switching bitrate mid-film stalled playback. The server served the new playlist and then rejected its segments: 400 on hls1/main/0.ts, six times over 25 seconds, never recovering, while the UI logged "Streaming quality changed" as if nothing were wrong. Jellyfin keys a transcode job by device and play session. Every stream URL this app built carried the same hardcoded DeviceId and no PlaySessionId at all, so the second stream for an item was indistinguishable from the first and nothing ever stopped the old ffmpeg. Re-opening a stream is not rare — a quality switch, a transcoded seek and an audio-track switch all do it. Replayed against the server, a second stream opened for a live job's item alternates per attempt between serving bytes and 400ing, which is why it read as flaky rather than broken. begin_video_play_session mints a session id per open and reports the one it supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings, un-retried — a slow stop must not delay playback) before returning. Putting it in the builder rather than in each caller covers every re-open path by construction. adopt_video_play_session takes ownership of the job the server starts itself when PlaybackInfo answers with a TranscodingUrl: without it the first switch on a stream has nothing to stop and collides with what is playing. Two client faults made the same incident worse and go with it: - The fatal-HLS-error handler added the transcode seek offset to a position that already included it. Past roughly the halfway mark of a film the doubled value cleared the "near end" threshold, so any transient network error was reported as end-of-stream and autoplay skipped to the next item — precisely when a quality switch had just made the offset large. The decision now lives in hlsRecovery.ts, against the absolute position. - The HTML5 reload primitive resolved on its own canplay timeout, so a reload the server never served reported success. The picker showed a quality that was not playing and the caller had nothing to revert. TRACES: UR-074, UR-004 | DR-177 | UT-173, UT-174, UT-175 |
||
|
|
13264e225b |
fix(player): never let the server burn a subtitle in, and never offer one we cannot draw
Reported as "subtitles are shown even when off", and no toggle in the app cleared them — because they were not the app's subtitles at all. The server was painting them into the video. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the server then honours the source's own default/forced flag. On the reported episode that default is a PGS track — a bitmap, which cannot go out as a sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it onto every frame. Confirmed against the live server, which answered the same PlaybackInfo request two ways: with the index omitted it returned `SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a `SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried `[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream at all. The cost landed on the video, not the subtitle: burn-in rules out remuxing, so a stream that only needed its audio transcoded was re-encoded frame by frame. Three parts: - The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text format we can render (srt/subrip/ass/ssa/vtt) as `External`. - The stream URL says the same thing, because the negotiation is not what opens most streams: a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. - The picker offers only subtitles the app can actually draw. Each subtitle stream now crosses the boundary carrying `supports_external_delivery`, decided in Rust where the codec vocabulary belongs, and `None` for anything that is not a subtitle so a `false` cannot be misread as a verdict. `subtitleStreamsOf()` drops the rejected ones — and since that one function feeds the menu, the `<track>` children and the native play request alike, a bitmap track disappears from all three without its URL ever being fetched. Only an explicit "no" hides a track; a stream carrying no verdict behaves exactly as before. Nothing is lost by refusing burn-in: the app already fetches the text tracks and draws them itself (UR-020), so the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression — the renderer cannot composite a bitmap, and the old behaviour paid for them by making the whole stream unwatchable. Tests were written first and observed failing: the Rust one would not compile against a field that did not exist, and the frontend one resolved a URL for the PGS track it was supposed to drop. Carries with it the in-flight per-stream `PlaySessionId` work in online.rs, whose hunks sit inside the same request builder and could not be separated from these. TRACES: UR-020, UR-004 | DR-176 | UT-168 |
||
|
|
1a9805f0f3 |
fix(downloads): queue the whole album, and make every queued track findable offline
An album download put a handful of its tracks on the device while the button reported the album as downloaded. Two independent gaps, one shared cause. - `download_album` read its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached from one of those sit in `items` with a NULL `album_id` and are invisible to that query. On the reported database three whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially linked album queued only the linked subset. - The frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position. The ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started. On Android that loop also stopped wherever the webview was suspended. - `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the same missing link seen from the other side. The operation now belongs to Rust end to end: - `HybridRepository::get_album_tracks` asks the server what the album contains. Cache-first `get_items` is right for browsing and wrong for deciding what to download; it errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow. - `queue_album_tracks` writes the album link onto every track it queues, and creates an `items` row for tracks the cache has never seen. - Stream URLs resolve here, through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Only the album id crosses the IPC boundary. - `album_file_names` gives each track its own file. A title repeated inside one album (deluxe edition, two discs) mapped to one path, so those downloads overwrote each other. Re-tapping download on a broken album heals it: missing tracks are queued and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment. DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and check:boundary clean. Note: this tree is shared with a concurrent session. Only the files above are committed; docs/traceability.md is left to be regenerated once that work lands. |
||
|
|
82b6982d68 |
fix(player): use a speedometer icon for the streaming quality selector
The bitrate ceiling button reused a cloud-download glyph, which read as a download action rather than a bandwidth setting. |
||
|
|
3363ff7f08 |
Merge branch 'master' into worktree-mosaic-library
# Conflicts: # scripts/extract-traces.test.ts |
||
|
|
f46d7bf676 |
fix(player): make native Android video opt-in again — it shipped as audio with no picture
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could shrink a real video surface. On a device that shipped sound with a blank screen. The decode path was never at fault. Logcat shows ExoPlayer running and feeding a live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect — `WebView transparent = false` is logged, `= true` never appears. The video was rendering correctly the whole time, behind an opaque page. This is precisely the defect the flag existed to contain; VideoPlayer.scrubRegression.test.ts had already recorded that "the native SurfaceView has never been visible through the webview". Enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives PiP from the WebView <video> — and working video outranks PiP showing a native surface. The flag stays in Settings, now described as incomplete rather than as a performance win, so anyone helping test it still can. Fixing the compositing is the prerequisite for trying this default again (DR-172). |
||
|
|
7e1f0e0547 |
Merge branch 'master' into worktree-mosaic-library
# Conflicts: # docs/traceability.md |
||
|
|
e015c4c9b1 |
Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work that landed on master in parallel: it had already claimed DR-163/DR-164 and UT-162, so the mosaic layout is now DR-172, the library favourites scope DR-173, and its composition test UT-167. Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166, none of which are defined in requirements.md — that branch defined DR-167..171 instead. Those references are orphaned and want a look; nothing here touches them. |
||
|
|
7387f35c7e |
docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still described the pre-flip world and one of them was load-bearing: - `nativeVideo.ts` labelled the store "Default off" directly above a `load()` that returns true when nothing is stored. - The two PiP comments explained themselves as "what makes PiP work in the shipping configuration", which stopped being true when Android started shrinking the real ExoPlayer surface. They still describe the Linux path and the flag-off case, so they say that instead. - `video_audio_codecs` justified its narrow codec list with "video does not play through ExoPlayer", which is no longer so on Android. The narrow list is still right, for a different reason now recorded: the flag is a user setting and a download outlives it, so only the intersection holds on both sides of the switch. DR-171 carries the same caveat. No behaviour change. |
||
|
|
0861523015 |
feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three different shapes — square music covers, 16:9 library backdrops, 2:3 posters — in grids that pick one box and crop everything to it. The home strip said so in a comment: it forced `aspect="video"` on music libraries so the row would line up, which lined it up by cutting the covers down. Both surfaces are now justified mosaics: rows share one height and each tile is as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles until the height needed to fill the container drops to the target, justifies the row by absorbing the rounding remainder into its widest tile, and deliberately leaves the last row unstretched so one leftover tile does not inflate into a banner. The component supplies only what the DOM knows: the measured container width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on CachedImage), committed in one debounced batch so the grid does not reshuffle once per image as artwork lands. Favourites gain a tile per category beside the library it belongs to, alongside the existing cross-library entry. Which collection type maps to which category is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`, stamped onto every `Library` by a new constructor and carried over as an optional `favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak `SearchScope::item_types` was extracted to close. A category shows one tile however many libraries share it, and a library kind favourites do not carve up (Live TV, channels, books) gets none. Also corrects the requirements-count test, which the UR-074 commit left one behind. Spec: docs/specs/library-mosaic.md TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162 |
||
|
|
d49d027020 |
docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had claimed for picture-in-picture in the meantime. Renumbered to DR-162 across the Rust and frontend TRACES comments (the PiP tags in VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160) and regenerated bindings.ts. Adds the requirement rows the tags point at: UR-074 for the user need, and DR-162 covering why the cap has to reach the PlaybackInfo negotiation and not only the transcode URL, why the ceiling is process-wide, and why the Settings default persists while the in-player override does not. Notes that this gives UR-070 its resume-at-the-same-point mechanism while the server-offered rendition list that requirement also asks for stays proposed. UT-156/157 record what the tests pin. docs/specs/streaming-bitrate-cap.md carries the layer assignment — the step definitions, the video/audio split, the resolution pairing and the reload decision are all Rust; the frontend holds a serde token and the labels it was handed. TRACES: UR-074 | DR-162 | UT-156, UT-157 |
||
|
|
dda2ff86a3 |
feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change: MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device profile that let the server direct-play a source of any size. On a metered or slow connection there was no way to spend less. StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/ 4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the audio share of it and the resolution that budget can carry. Those numbers are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant; labels and details come back over IPC from player_get_streaming_qualities, the same arrangement as the EQ presets. The cap has to reach the *negotiation*, not just the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot. So it is applied at all four places that decide bandwidth — the HLS URL builder, PlaybackInfo, the Live TV stream, and the background-audio handoff (which takes the lower of the cap and its own 384 kbps). Video bitrate is the total minus the audio share so the two together honour the ceiling rather than overshooting it. The ceiling is process-wide rather than a repository field: it is a preference about this device's connection, must survive a repository rebuilt on re-login, and every URL builder plus the negotiation have to agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE. Two ways in. Settings holds the durable default, persisted to app_settings and restored at startup — unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to see. The in-player menu is the "this film, this connection" override: a cap is a property of the stream the server is producing, so it cannot apply to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading the native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses. Tests pin the URL parameters at a capped and an uncapped step, the handoff taking the lower of the two, the ladder's internal consistency (video + audio == cap, resolution descending with bitrate) and the persisted token's round trip. The ceiling is process-wide, so the tests that depend on it serialise on a guard that restores the default. TRACES: UR-074 | DR-160 | UT-156, UT-157 |
||
|
|
9f5f57cba4 |
fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3. |
||
|
|
1f32e4040b |
Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from
|
||
|
|
2d50744320 |
fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so "resume from where you left off" always played from the start on Android. Two layers each assumed the other did the seek: - The only code acting on `initialPosition` was handleCanPlay, an HTML5 <video> event handler. The native path has no <video> element, so `canplay` never fires and that seek never ran. - NativePlayerAdapter.load() had an initialPosition branch, but it only recorded the number, claiming "the native backend performs the actual seek internally". It does not: PlayItemRequest carries no start position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0. - VideoPlayer never called adapter.load() at all, so even that branch was unreachable. The frontend therefore believed it had resumed (the seek bar showed the resume point) while ExoPlayer played from the beginning. NativePlayerAdapter.load() now issues the backend seek, excluding live streams (no resume point; seeking knocks the HLS window off its live edge). VideoPlayer calls it on the native branch and marks the initial seek as performed so the existing $effect does not fire a duplicate. The HTML5 path is untouched: seeking before metadata is clamped to 0, which is exactly what handleCanPlay waits for. Verified red->green: the new test failed with "Number of calls: 0" before the fix. Full frontend suite passes (933 tests); svelte-check and check:boundary are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85bd227714 |
fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music library rendered aspect-square (144px tall at w-36) next to video libraries at aspect-video (81px), leaving the home row ragged. Add an optional `aspect` prop that overrides the derived ratio, and pass aspect="video" from the home Libraries strip. Unset, behaviour is unchanged, so the /library overview grid and the media carousels keep their per-type ratios. Artwork already uses object-cover, so square music art crops rather than distorts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e144e62b31 |
feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend overrides threw that answer away, so ExoPlayer's video path had never actually run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off). The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 — Linux cannot composite behind WebKitGTK, and promoting there would be a black screen. Two blockers the spec did not anticipate, both in code assumed to be merely unreachable rather than broken: - `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` bailed. The SurfaceView was created and wired to ExoPlayer but never added to the view hierarchy — video would have decoded to a surface that was never on screen, whatever the webview did. This also revives PiP on the video path, which gated on the same flag. - `createAdapter()` was not the real gate; it is never called in production. The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped the native backend `player_play_item` had just started. Both sites now route through `createAdapter()`. Compositing needs two independent opaque layers cleared, not one. Clearing only the page leaves the WebView widget opaque — audio over a black picture, exactly the symptom the old INTERIM comment described. `videoSurface.ts` toggles both: the widget background and window drawable from Kotlin, the page backgrounds via a `data-native-video` attribute keyed by app.css. Transparency lives in `tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the playback session so the launcher never shows through the rest of the app. Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on rotation. The mini-player transition remains unverified on device. Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a second copy of the Rust cfg gate free to drift from it. `player_get_capabilities` now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates. Tests: adapter selection covers the full matrix, including the regression guard that the flag off beats Rust. Written first and confirmed failing (2 of 7) before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6a712c46cb |
fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.
VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".
PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.
Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.
The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.
The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.
No Kotlin change was needed.
Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.
TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
|
||
|
|
211792947d |
fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)
Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.
The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.
Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.
Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).
Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.
Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.
Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.
Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.
TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||
|
|
1b70926c36 |
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 A completed download's file_path is already absolute (the worker
rewrites it on completion), but the player rooted it a second time and
handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol answers a range-less request by reading the
whole file into memory, and only advertises Accept-Ranges from inside
its range branch, so the first request never learns ranges exist.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
|
||
|
|
878ac5fa59 |
fix(player): make lockscreen transport reach background audio (DR-097)
Pausing from the lockscreen did nothing while a video's audio played in the background. The handoff starts native ExoPlayer audio and only then tears the WebView <video> down, and that teardown fires a DOM `pause` the frontend reports like any other — leaving html5_playing = Some(false). Transport therefore stayed aimed at the element: the lockscreen pause emitted a ControlCommand into a <video> that no longer existed while the native player carried on. The controller now tracks a background-audio handoff explicitly. Entering one hands transport authority to the native backend and drops the dying element's state/position/media-loaded reports, which also stop flipping the UI to paused and dragging the position backwards. Exiting restores the element as the player. A lockscreen pause also has to survive the return to the foreground: the video used to resume from a snapshot taken at handoff time, undoing the pause on the way back in. shouldResumeOnForeground() lets an explicit `paused` from the player override that snapshot. TRACES: UR-040, UR-005 | DR-052, DR-097 |
||
|
|
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. |
||
|
|
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. |