8904acb5f79c052fa72974835640bb0aad82c9fd
26
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
32043a2152 |
docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
cc7f1cece0 |
fix(player): play downloaded video offline (DR-133, DR-134)
Offline video never started: the <video> element reported NETWORK_NO_SOURCE one millisecond after loadstart, which the UI mislabelled as "may need transcoding" even though nothing had been fetched. Two independent causes, both required for playback. The path was doubled. `downloads.file_path` is stored relative to the storage root while a download is queued, but the worker rewrites it to the absolute path it actually wrote once the transfer completes — so a completed row is already rooted. The player's offline branch rooted it a second time, producing /data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it resolves the same column through Rust's resolve_local_media_path, which does not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC) so rows written before completion still resolve. The asset protocol was never enabled. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in *and* app.security.assetProtocol.enable is set — neither was, so even a correct path resolved to nothing. This also silently defeated the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. Scoped to $APPDATA/** — the storage root holding the database, downloads/ and the thumbnail cache — rather than an unrestricted grant. Diagnosed from logcat on device; UT-124 reproduces the doubled path. |
||
|
|
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. |
||
|
|
b565c4ae6f |
fix(player): tap gestures act immediately, no deferral timer (DR-098)
Tapping the video surface pause-looped: it would unpause and bounce straight back to paused about a second later. Long-press unpaused fine, which is what pinned it to the tap path rather than the media pipeline. The gesture handler deferred the first tap's play/pause behind a 300ms timer so a second tap could cancel it and seek instead. But the timer callback cleared its own handle *before* invoking the toggle, and handleVideoClick used exactly that handle (`tapTimeout !== null`) to suppress the compatibility click Android's WebView synthesizes after a touch. So the guard was already open when the late click arrived, and it toggled a second time. Replace the deferral with immediate action — there are only first and second taps: 1st tap: toggle play/pause 2nd tap: seek, then toggle play/pause again The second toggle undoes the first, so a double tap seeks while leaving the play state exactly as it was: playing jumps and keeps playing, paused jumps and stays paused. No timer, no window race, no loop. Click suppression no longer depends on the timer: ignore detail === 0 and any click within 700ms of a touch tap, since Android can deliver the synthesized click late and with a real detail value. A swipe now undoes the touchstart toggle (latched on swipeGestureActive so it happens once, not per touchmove frame), keeping brightness swipes from changing the play state. UT-085..087 described the old deferred behaviour and are updated to the new contract. UT-091 is used for the DR-097 facade tests, since UT-089 and UT-090 were already claimed by extract-traces.test.ts. |
||
|
|
75cd07a5c0 |
fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the frontend facade short-circuited play/pause/toggle straight into the adapter whenever one was registered. Html5PlayerAdapter.toggle() then decided play-vs-pause by reading el.paused off the DOM, so the Rust controller never saw the intent and could not serialise competing ones. el.paused flips transiently while an element buffers or settles a seek. Two intents ~150ms apart therefore read *different* values and performed *opposing* actions — one playing, one pausing — which self-sustained a play/pause loop that needed no further input. On device this showed up as a fully healthy element (readyState=4, networkState=1, not seeking, not buffering, not ended) pausing itself roughly once a second, so unpausing or skipping ahead bounced straight back to paused. The root cause was that Rust held NO state for webview-rendered media: report_html5_state only re-emitted its argument, despite the comment above it claiming the controller was the single source of truth. It had nothing to decide a toggle from. Now report_html5_state tracks the reported state, and play/pause/toggle consult it and drive the element by emitting a ControlCommand — the same "backend decides, adapter executes the primitive" split player_seek_video already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer regain authority for music playback. Tests cover the loop signature directly (repeated toggles must alternate, never repeat or oppose) plus a guard that one intent yields exactly one ControlCommand — which matters on Windows, where the backend is itself webview-based and could otherwise be driven twice. |
||
|
|
1ae213ff39 |
fix(player): stop AbortError storm from HLS stall recovery (DR-096)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.
Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.
This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.
Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
|
||
|
|
b11188e9dd |
docs(player): backend unification findings + correct false parity claims
Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto one engine with hardware acceleration. Conclusion: video cannot be unified onto a native engine; audio can. The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each draw into their own compositor surface, so a native video surface sits either entirely above or entirely below the webview and cannot interleave with HTML. GStreamer and libVLC fail identically. mpv would additionally regress streaming: it has no adaptive bitrate, while the current hls.js path does. Six specs added: - playback-backend-unification: the analysis and decision, with evidence - android-audio-settings-parity: set_audio_settings on ExoPlayerBackend - android-native-video-spike: timeboxed test of SurfaceView compositing - windows-native-audio-backend: replace the webview <audio> shim with libmpv - libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file - playback-docs-corrections: the requirement-status fixes applied here Corrections to requirements.md, all verified against source: - UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on mpv, whose single-stream audio chain cannot feed acrossfade's two inputs. - Parity matrix listed crossfade as a Linux/Android gap; it is neither. - The matrix omitted the equalizer, which has the same Linux-only shape. - The suggested ConcatenatingMediaSource is deprecated in current Media3. nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android video. That issue is a stale feature request, dead since 2024-07-01; the capability shipped in tauri 27d01834 (2024-09-02), and the related black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely unproven is SurfaceView-behind-WebView compositing, which the spike now tracks. |
||
|
|
d4e2cd120c |
feat(player): webview audio backend for platforms without a native one
Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g. Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it emits a WebviewAudioLoad event with the stream URL; a frontend <audio> element (WebviewAudioAdapter + webviewAudio service) plays it and reports state/position back through the existing player_report_* round-trip, so the Rust PlayerController stays the single source of truth. Play/pause/ seek reach the element via the existing ControlCommand event. All video already renders in the webview on every platform, so this completes audio-only playback for Windows (video via WebView2, audio via <audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux. Regenerates bindings.ts (adds webview_audio_load; also carries the equalizer EQ bindings). TRACES: UR-003, UR-004, UR-005 | DR-004 |
||
|
|
3fbf6afdbc |
Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix. |
||
|
|
a64e1b1fb4 |
Introduce PlayerAdapter contract; decision logic shared in Rust backend
Establish a decoupled player boundary so UI and backend interact with video through one contract, with the HTML5 (Linux/interim-Android) and native (ExoPlayer) providers as interchangeable primitive-executor adapters. - PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource, play/pause, setVolume, selectSubtitle); it never branches on strategy. - Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track return a strategy); the facade dispatches the chosen primitive to the active adapter. Both providers share the one decision path — logic lives once, in Rust. - Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets backend control (lockscreen/remote/sleep) drive the webview <video> element. - Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause silently no-opping when the element was re-bound). - Do not emit a "stopped" player state on natural end-of-video: it flipped the player/mode to idle mid-handoff and suppressed next-episode auto-advance under a sleep timer. Jellyfin progress reporting is preserved; the backend's on_video_playback_ended owns the transition. - VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter). - Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1f6977cd01 |
Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
|