Commit Graph
71 Commits
Author SHA1 Message Date
dtourolle d952a2ae55 fix(player): ExoPlayer can seek a transcode in place; mpv cannot
A regression I introduced in DR-246 and did not catch, because the capability
was declared once for "native engines" as though being native were the
property that mattered.

It is not. Speaking HLS is. ExoPlayer is a full HLS client: like hls.js it
seeks within the VOD playlist it was handed and lets the server catch up. mpv's
HLS demuxer will not make the server produce segments from a new offset, so it
has to re-open the stream. Grouping them together declared false for both, so
on Android a transcoded seek began re-opening the stream where it previously
seeked in place — the same class of defect DR-238 was about, reintroduced on
the platform I had not exercised.

Capabilities::native() is gone, replaced by mpv() and exoplayer(), and the
composition root chooses per platform through engine_capabilities(). Treating a
category as a proxy for an ability is precisely the inference this design
removes; a helper named after the category invited it straight back in.

Not yet verified on a device. The conformance cases run against JellyTauPlayer
in isolation and do not cover a transcoded seek, PiP, background audio or the
media session — none of which have been exercised since the controller port.
2026-08-23 08:33:23 +02:00
dtourolle 6b3d853442 feat(player): seek strategy follows what the engine says it can do
DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a
command handler on behalf of engines it does not own. That is how "who
renders" came to mean "how do I seek", and why a transcoded seek silently did
nothing the moment native video changed the renderer (DR-238).

Engines now declare `Capabilities::seeks_transcoded_in_place` — true for
hls.js, which seeks within the VOD playlist it was handed and lets the server
catch up; false for mpv, whose HLS demuxer cannot make the server transcode
from a new offset. The command asks whichever engine is rendering. Adding an
engine no longer means editing a shared truth table.

The item's transport is not read at the seek site any more; the compiler
flagged it unused, which is the URL-shape input finally disappearing.

A deviation from the spec, recorded deliberately: it called for the engine to
own the decision outright. It cannot. Re-negotiating a stream needs the
repository, which sits above the engine, so the engine states the ability and
the caller acts on it. That still removes the defect — nobody guesses on
another component's behalf — without pretending an engine can reach upward.

Also fixes a latent race in the conformance suite, found by running it: the
seek case asserted immediately, which passes on an engine that records the
target when it accepts a seek and races on one that waits for the decoder to
move. `Harness::await_seek` polls instead, the way the Android suite already
did. It failed with machine load rather than with the code, which is the kind
of test that teaches people to re-run until green.

  MpvPlayer     9/9
  LegacyPlayer  8/9 - still only the mute/rate gap in the old trait

789 tests, clippy -D warnings clean with and without the feature.
2026-08-22 22:21:42 +02:00
dtourolle 5fcf58fa78 feat(player): the controller talks to one contract
DR-245. PlayerController now holds a MediaPlayer instead of a PlayerBackend,
and every engine reaches it through that contract.

Deliberately a seam swap, not four rewrites: the existing backends are carried
across by LegacyPlayer, so MPV keeps its EQ and normalisation, ExoPlayer keeps
its media session, and nothing loses a feature to the migration. MpvPlayer
stays available for conformance until it grows the audio-settings half.

The substantive change is at the load site. Where the controller used to call
load() and then play(), it now issues one open() carrying the item and where
to begin — so the window a start position could be lost in is gone from the
controller as well as from the engines.

`state()` maps the engine's Phase back onto PlayerState using the queue, which
is what knows the item. External behaviour is unchanged.

Supporting pieces:

  - The contract gains set_audio_settings/audio_settings as *provided*
    methods. Engines that cannot honour them say so through Capabilities and
    inherit a no-op, rather than every implementation carrying an Ok(()) it
    does not mean.
  - PlayerBackend is implemented for Box<dyn PlayerBackend>, without which the
    boxed engine built at the composition root cannot be handed to anything
    generic over the trait.
  - StreamSelection::for_queued_item rebuilds a selection for an item already
    in the queue, without re-negotiating. The transport falls back rather than
    being sniffed out of the URL — that substring check is what DR-230 removed
    — and needs_transcoding is an exact stand-in because every transcode this
    app requests is HLS (DR-140).
  - default-run = "jellytau". The conformance binary made a bare `cargo run`
    ambiguous, which broke `tauri dev` outright. Caught by running the app
    rather than by any suite, which is the argument for doing both.

789 tests, clippy -D warnings clean with and without the feature.
2026-08-22 22:12:51 +02:00
dtourolle 8904acb5f7 feat(player): run the old backend through the new contract
DR-245, first half. `LegacyPlayer` implements `MediaPlayer` over the existing
`PlayerBackend`, so engines not yet ported — ExoPlayer, the webview element,
the null backend — keep working while `PlayerController` moves across. Without
it the port would have to land all four engines at once.

It also makes the two designs comparable on one engine and one file. `open`
reproduces the old sequence faithfully: load, play, then seek for a start
position, with the seek's failure ignored exactly as callers used to ignore
it. Making it pass would defeat the point.

Running both engines over the same media is more informative than expected:

  MpvPlayer     9/9
  LegacyPlayer  8/9 - transport_settings_round_trip fails

Two things fall out of that. The start-position case now passes on *both*,
because DR-241 was fixed inside MpvBackend rather than only in the new engine
— so the suite confirms that fix independently, on a path it was not written
against. And the one genuine failure is a capability gap rather than a bug:
the old trait has no mute and no playback rate, so `LegacyPlayer` reports them
unsupported instead of folding mute into volume and losing the user's level.

That is the abstraction earning its keep on the first run: a missing
capability that was previously invisible is now a named, failing case.

The runner takes an engine argument:

    player-conformance <media-file> [mpv|legacy]
2026-08-22 21:23:39 +02:00
dtourolle a3190cd52b feat(player): MpvPlayer, and a runner that verifies it without the app
DR-244. The first real engine on the contract, and the tooling to interrogate
it in isolation.

The point of difference from MpvBackend is `open`: the start position is
applied at load time via mpv's own `start` option, instead of being seeked to
afterwards. loadfile is asynchronous, so a seek issued after it targets a
player with nothing loaded, fails, and was discarded. A seek that does arrive
during Opening is held and applied on FileLoaded, so no caller has to know
where that window begins or ends.

`close` clears state before issuing the stop, so an open still in flight
checks it on FileLoaded and cannot proceed to play after the caller has
stopped it. It is idempotent: callers legitimately close twice on teardown.

Every property the event loop matches is observed, per DR-239.

The runner is a separate binary that links libmpv and nothing else, so a
wrapper can be verified without building or launching the app — which is what
made the previous round of playback debugging so slow. Audio and video go to
null, so it is safe on a headless runner and does not claim the speakers. It
lives behind a `conformance` feature and exposes one entry point rather than
making the player module tree public.

    cargo run --features conformance --bin player-conformance -- <media-file>

All nine cases pass against real libmpv. Verified the suite can fail: reverting
`open` to the old load-then-seek behaviour makes opens_at_a_start_position fail
and restoring it makes it pass, so DR-241 is now a test rather than an
anecdote.
2026-08-22 21:18:10 +02:00
dtourolle f4892f4cb2 feat(player): FakePlayer and the conformance suite
DR-243. One set of behaviours every engine must satisfy, written before the
second engine exists so it cannot encode whatever the first happens to do —
which is how three playback implementations drifted apart in the first place.

FakePlayer models the one behaviour that matters most: opening is not
instantaneous. `open` parks in Phase::Opening until complete_open() is called,
so a test can put a seek into that window deliberately. That window is where
DR-241 lived, and it was previously unreachable from any test.

The suite drives readiness through a Harness rather than sleeping — the fake
completes on demand, a real engine waits for its own readiness event. A
timing-dependent suite is worse than none, because it teaches people to
re-run until green.

Nine cases, each naming the defect it prevents:

  opens_at_a_start_position          DR-241 - starts there, never at zero
  seek_while_opening_is_honoured     DR-241 - held, not discarded
  seek_while_opening_overrides_start         later intent wins
  pause_and_play_are_observable      DR-239 - state an engine cannot hide
  close_is_silent_and_idempotent             stopped must mean silent
  close_during_open_never_plays              an open cancelled by close
                                             must not come back to life

`audible()` may return None for engines that cannot answer, which skips the
silence assertions rather than passing them vacuously — an assertion that
cannot fail is worse than an absent one.

Also adds MediaItem::sample: the struct has twenty-odd fields, almost none of
which a given test cares about, and repeating the literal per test is how a
new field ends up added in thirty places.
2026-08-22 21:18:10 +02:00
dtourolle 3b91922cca feat(player): the MediaPlayer contract
DR-242. Intent, not device operations.

`open` carries the start position, so no caller sequences load-then-seek and
none can race an engine's asynchronous load — the engine is the only layer
that knows when its pipeline can accept a position, and it absorbs that
internally by deferring or re-opening.

`seek` states a destination and nothing else. Whether that is an in-place seek
or a re-opened stream is the engine's business: hls.js seeks within a VOD
playlist, mpv's HLS demuxer cannot make a server transcode from a new offset.
Callers stop guessing on behalf of engines they do not own.

`snapshot` is one coherent read rather than a dozen getters, because reading
position and duration separately is how a player reported <position> / 0.0
when a file unloaded between the two calls.

`Phase::Opening` names the state the previous design could not express, and is
the direct cause of DR-241: a seek arriving with nothing loaded had no phase
to be queued against, so it was discarded.

`Capabilities` exists so callers adapt without naming engines. If a caller
ever branches on which engine it holds, this struct is missing something —
engine identity leaking into callers is the coupling DR-238 came from.

Nothing consumes it yet; PlayerController is ported in DR-245. Carries an
explicit allow(dead_code) tied to that step rather than being hidden behind
cfg(test), because it is production code being built in shippable pieces.
2026-08-22 21:17:50 +02:00
dtourolle 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.
2026-08-22 21:17:29 +02:00
dtourolle d3ecd8ee91 feat(video): mpv plays video on Linux, composited under the webview
DR-231 works. Video and audio, drawn by mpv into a framebuffer we own and
blitted into the default vbox's draw handler with `gdk_cairo_draw_from_gl()`.
The widget tree Tauri built is untouched, so nothing here can be invalidated by
a Tauri upgrade that assumes its own layout.

That settles finding 2 of playback-backend-unification.md on Linux by
demonstration rather than argument, and completes the half of G1 the spike could
not test.

Three pieces had to land together, none of which existed before:

  - mpv was configured with `video: no` and no video output, so it had never
    decoded a frame in this app. Now `vo=libmpv` when native video is on.
  - `player_play_item` skipped loading into the native backend on Linux behind a
    `#[cfg(not(target_os = "linux"))]`, because the webview always played video
    there. With the webview no longer loading it, that guard meant *nothing*
    played — no picture and no audio, which reads as a broken stream rather than
    as a file nobody was given.
  - The webview paints its own opaque background. Android clears it through a
    Kotlin bridge from `enableNativeVideoCompositing()`; the CSS half of that
    already ran on Linux, so only `transparent: true` on the window was missing.
    Until it was, the frame was rendered correctly and covered by white.

Frame pacing is polled, not pushed. The tick callback asks mpv `has_frame()` and
draws only when the answer is yes. Both neighbouring designs were tried and both
fail, in ways that point at the wrong culprit:

  - Waiting on mpv's update callback before rendering *deadlocks*: mpv does not
    progress until the client renders, so if the client waits to be told, the
    two hold each other. The file loads, one frame appears, and everything
    stops.
  - Rendering every frame-clock tick and reporting a swap each time claims a
    presentation far more often than one happened. It plays, and judders badly —
    which reads as a GPU or compositing limit, exactly as the spike warned.

The update callback survives as a hint and does the least it safely can from an
mpv thread: set an `AtomicBool`. It must not touch GTK — `idle_add_local*`
requires the caller to own the main context and panics from there — and it must
not hold the `Rc<RefCell<..>>` state, which is not `Send`.

Two memory-safety fixes in this file's own short history, both worth recording
because neither announced itself:

  - The callback context was handed over with `Rc::into_raw` (a pointer to the
    Rc's *contents*) and read back as `*const Rc<..>`, reinterpreting a RefCell
    as an Rc and corrupting its refcount on the first clone. mpv invokes the
    callback immediately, so this happened before anything drew. The symptom was
    the process ending quietly with status 0.
  - The surface was attached before the player backend was constructed, so the
    mpv handle it needs had not been registered yet and it found null every
    time.

Teardown (DR-232) is confirmed working on a real run: callback unregistered,
render context freed, GL objects released with the context still current, boxed
callback state reclaimed only after mpv can no longer reach it — no crash.

Still behind JELLYTAU_NATIVE_VIDEO=1 and off by default. Known open: whether
exiting the player stops mpv (reported, evidence ambiguous, needs re-checking
now the picture works), hardware decode (DR-236), and deleting the webview video
path (DR-235).
2026-08-22 14:22:58 +02:00
dtourolle 2f637d4775 feat(video): let mpv decode video at all, behind one shared flag
mpv has never decoded a video frame in this app: the backend sets `video: no`
unconditionally, because Linux video has always been the webview's job and
decoding it twice would burn a core for a picture nobody sees. The render path
built in the previous commit therefore had nothing to draw.

With native video on, mpv is configured for video *and* `vo=libmpv` — the render
API only works through that output, and the default would try to open a window
of its own. Set at construction, because mpv resolves its video output when it
initialises and flipping the property later does not re-open one.

The flag lives in `player::native_video`, read by all three things that must
agree: the backend (configured before anything plays), the surface (nothing to
draw otherwise), and `get_player_status` (which tells the frontend whether to
use a `<video>` element — two decoders on one stream would fight over the
audio). A function rather than three `env::var` checks, because a capability
answered in several places is a capability whose answers drift: four separate
bugs this cycle came from exactly that shape.

Also fixes an ordering bug the first run exposed. The surface was attached in
`setup` before the player backend was constructed, and the mpv handle is
registered *during* that construction — so it found nothing every time and
logged "no mpv handle". Attaching after the backend exists is the whole fix.

Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on
Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the
libepoxy data-symbol handling is right, since a wrong `get_proc_address` would
have taken SIGSEGV on the first GL call rather than returning cleanly.

No frame has reached the screen yet. The webview is still opaque, so it will
paint over anything drawn beneath it until transparency is set up.

Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on
duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace
allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this
would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls
quick-xml 0.41. Licences, bans and sources still pass.

UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything
unrecognised all mean off. A half-set variable that half-enabled the renderer
would configure mpv for video with nothing drawing it — audio over a black
rectangle.

Also removes a wall-clock timer from the waitForRepository late-arrival test,
which failed once under load. The assertion is about ordering, so it now
publishes on a microtask and cannot race.
2026-08-22 13:45:04 +02:00
dtourolle 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.
2026-08-22 13:45:04 +02:00
dtourolle 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.
2026-08-22 13:45:04 +02:00
dtourolle 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.
2026-08-22 13:45:03 +02:00
dtourolle 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.
2026-08-22 13:45:03 +02:00
dtourolle 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.
2026-08-22 10:09:46 +02:00
dtourolle 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.
2026-08-21 18:15:58 +02:00
dtourolle c18d79c656 fix(android): stop background audio rewinding to where it started
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".

ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.

A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.

Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:

  before  13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
          (C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
          base, 3.5 minutes after the outage with nothing logged between
  after   14:05:08 "declining the player's retry", playback undisturbed off the
          buffer for 69s (a fatal load error is only raised when the renderer
          next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
          re-opening at 785.6s -> READY, and no rewind in the following 7 min

Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.

TRACES: UR-040, UR-004 | DR-203 | UT-200
2026-08-19 17:29:31 +02:00
dtourolle 6dfc6b259a fix(player): lockscreen skip scrubs instead of advancing in background audio
onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which
always advanced the queue. Correct for music, wrong for a video whose audio is
running through a background-audio handoff (UR-040): pressing skip to re-hear a
line jumped to the next episode instead of scrubbing.

resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and
is_background_audio_active() is the whole test — the handoff exists only for
video, and an episode played through it reports MediaType::Audio, so media type
cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration]
so a skip near either end cannot seek negative or read as EOF and advance.

Routed through the same spawn-then-seek_absolute path as the scrubber, because a
handoff seek re-opens the stream and must not run under the blocking lock
(DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/
REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a
control that scrubs. The remote-volume action block is deliberately untouched:
the handoff never applies to cast sessions, where skip really does mean advance.

Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust
tests pass, clippy 0, coverage 90%.
2026-08-16 23:54:43 +02:00
dtourolle 889289286b merge: clear the clippy backlog and unify lock helpers (D1-warnings, D3)
51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire
types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to
the poison-tolerant helpers - all of them test code; production was already
clean.

Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in
credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME,
which would have cfg'd the constant out of every non-Linux build. Compiles clean
on Linux, so only Windows/macOS CI would have caught it.
2026-08-16 23:06:33 +02:00
dtourolle 8500da1a42 chore(rust): clear the clippy backlog and finish the poison-tolerant lock sweep
`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero.
Most were mechanical — needless borrows, `assert_eq!` against a bool literal,
`vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only
to index — and were applied with `clippy --fix`, then reviewed line by line.
That review caught one auto-fix that was *not* semantically neutral: dropping
the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned
directly above `SERVICE_NAME`, which would have silently cfg'd the constant out
of every non-Linux build. Removed the stray attribute with the import.

Where a lint asked for a risky change rather than a better one, it is suppressed
with a comment saying why:

- `too_many_arguments` on five `#[tauri::command]` handlers and
  `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>`
  injection, and a parameter struct would change the IPC contract and the
  generated TypeScript for no readability gain.
- `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are
  serde + specta wire types emitted a handful of times a second, never bulk
  allocated; boxing would have to stay invisible to the generated bindings while
  every match arm gained a deref.
- `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a
  test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE`
  flag, and the await it spans *is* the critical section. Each `#[tokio::test]`
  gets its own single-threaded runtime, so this is not the production deadlock
  class the lint targets; restructuring would reintroduce the flag race.

Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it
is now `into_media_item`; the five-tuple episode row in the download commands
has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches
`name: "pause"` instead of guarding on it.

Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`,
completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be
in test modules — production code was already clean — so this is consistency
rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose:
those tests deliberately poison a mutex to prove the helpers recover from it.

Pure refactoring: all 698 tests still pass.
2026-08-16 23:05:13 +02:00
dtourolle 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.
2026-08-16 22:58:55 +02:00
dtourolle 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.
2026-08-16 18:03:22 +02:00
dtourolle de1c13e72f fix(player,reporting): report real positions, and count an audio-only episode as watched
Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.

The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.

- absolute_position(): the maximum of the backend's reading, the last position
  webview media reported, and the handoff base. Exact rather than heuristic —
  at most one term is ever meaningful, and the base is a floor the stream
  cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
  Jellyfin stores the reported position as the resume point, so sending one
  only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
  throttler it already shared with the native audio path.
  /Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
  so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
  suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
  seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
  downloaded handoff's absolute seek through the stream rebuild, which refuses
  a non-remote source outright.

Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.

TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
        UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
2026-08-16 10:23:00 +02:00
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00
dtourolle 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.
2026-08-15 16:26:31 +02:00
dtourolle 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
2026-08-11 20:03:19 +02:00
dtourolle 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.
2026-08-09 16:38:07 +02:00
dtourolle a53042fe80 fix(playback): bound the device profile by the audio route's channels (DR-141)
MediaCodecList answers "can this device decode 5.1", which is not the
question that decides whether the user hears anything: a phone decodes an
AC-3 5.1 track happily and still has two channels to play it out of. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.

Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so a device with genuine
surround output keeps direct-playing it. A missing or zero reading means
"route not yet established", not "no audio", and falls back to stereo.
2026-08-09 15:01:57 +02:00
dtourolle 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
2026-08-05 12:26:25 +02:00
dtourolle 30dc3ba7f6 fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error
handler stopped the player unconditionally, so a wifi blip killed the
track. Android already decides in its JNI callback, but MpvBackend is
constructed before PlayerController exists, so its event thread has no
controller to ask.

So MPV reports the failure and the frontend echoes it into the new
player_recover_stream command — the same shape as PlaybackEnded ->
player_on_playback_ended, keeping the decision in Rust. The command
re-opens the stream where it stopped, with the existing attempt budget
and backoff, and returns whether it handled it; only a false answer
falls through to the old stop path.

Android now reports the errors it has already declined as
*unrecoverable*, so the echo never asks the same question twice.

TRACES: UR-004, UR-040 | DR-130 | UT-117
2026-08-04 20:15:28 +02:00
dtourolle 32f8de5c91 fix(player): survive a flaky stream, and don't read 0:00 at EOF
Two MPV-side fixes for the same failure story — a wifi blip during
playback.

The demuxer gave up the moment a read failed and MPV raised
EndFile(ERROR), so a momentary outage killed the track outright. Enabling
ffmpeg's reconnect options handles the common case entirely below our
level, so most outages never reach the recovery path at all. Set
non-fatally: their availability varies with the libmpv/ffmpeg build, and
losing resilience is not a reason to refuse to play anything.

Separately, `time-pos` and `duration` are live properties of the *loaded*
file: at EOF MPV unloads it and both stop resolving. Reading them straight
through returned 0.0/unknown at exactly the moment end-of-file handling
needed to know where playback had reached, so the player appeared to
rewind to 0:00 as a track ended. `ObservedTime` records the last reading
seen while media was loaded and the accessors fall back to it.
2026-08-04 19:39:14 +02:00
dtourolle 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.
2026-08-04 17:35:17 +02:00
dtourolle 58f2506966 feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

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

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

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

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

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00
dtourolle a26a853f01 fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s
An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, and ExoPlayer parked in STATE_ENDED —
where any later play intent (lockscreen, headset, Bluetooth reconnect) replays
the ended item, surfacing as the episode randomly restarting.

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

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

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

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

Bump to 0.2.9.
2026-08-02 18:10:18 +02:00
dtourolle 75cd07a5c0 fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.

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

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

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

Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
2026-07-30 13:54:41 +02:00
dtourolle cb79a376b3 feat(android): implement audio settings (EQ, normalization, gapless)
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
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) Has been cancelled
ExoPlayerBackend was the only backend not overriding the PlayerBackend trait's
set_audio_settings/audio_settings defaults, so the Settings > Audio controls
rendered on Android and silently did nothing — the default returns Ok(()) while
applying nothing, so the failure was invisible.

Rust owns what the values are (canonical 10-band ISO layout, preset curves,
normalization presets); Kotlin owns when the AudioEffect objects exist, since
that needs the live audio session id.

- settings.rs: audio_settings_jni_payload() sanitises (crossfade clamped, band
  vector normalised) before serialising, so a malformed vector cannot reach the
  Kotlin parser. JSON rather than a wide JNI signature, matching how load()
  already passes subtitles — adding a field will not change the signature.
- ExoPlayerBackend: set_audio_settings/audio_settings over JNI; ExoPlayerState
  gains the first command-side field (settings are pushed out, never reported).
- JellyTauPlayer.kt: Equalizer, LoudnessEnhancer, and gapless via
  pauseAtEndOfMediaItems.

Three details that are easy to get wrong:
- Effects re-attach on onAudioSessionIdChanged. ExoPlayer rebuilds its audio
  sink on a format change, which invalidates effects bound to the old session;
  without this the EQ silently stops applying mid-queue.
- All effect work is posted to mainHandler rather than run inline. AudioEffect
  construction from a player callback can re-enter the player and deadlock —
  the same shape as the AutoplayDecision lock-scrutinee bug.
- Device equalizers expose a device-dependent band count (commonly 5) at fixed
  centres, so the canonical 10 bands are resampled by nearest centre frequency.
  resampleBands() is a pure @JvmStatic function so that mapping is testable
  without a device.

Normalization is approximate, not parity: LoudnessEnhancer is a gain stage, not
a true EBU R128 normalizer like MPV's dynaudnorm. Recorded as such rather than
claimed as equivalent.

Crossfade is deliberately excluded — unimplemented on every platform and
blocked on mpv, so building it on Android alone would invert the parity gap.

Tests written first and observed failing (cannot find function
audio_settings_jni_payload) before the implementation: the payload contract is
pinned by tests because a serde rename would otherwise silently break the
Kotlin parser.

Not yet verified on a physical device — AudioEffect availability and band
layouts are device-specific. Requirements matrix marks these rows accordingly,
and flipping the trait default to Err(not_implemented()) is deferred until that
verification lands.
2026-07-28 23:03:31 +02:00
dtourolle 13e0860401 fix(player): expired sleep timer stops without triggering autoplay
Stopping the backend makes the native player fire its ended callback,
which lands in on_playback_ended. The timer thread cancels the timer
first, so by the time the callback inspects it the mode reads Off — the
sleep-timer branch is skipped and the episode path runs, showing a
next-episode popup (or advancing outright) right after the user's sleep
timer expired.

Record EndReason::UserStop before the stop reaches the backend. That is
the honest label: the stop was user-initiated, just via the timer they
set rather than the stop button.

TRACES: UR-023, UR-026 | DR-029
2026-07-28 01:33:10 +02:00
dtourolle ee584aced2 fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.

- Carry episode identity (item_type, series_id) through the
  background-audio handoff so the backend queue item still knows it's an
  episode; is_episode_item now trusts item_type over the media_type
  heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
  /player/<id>, which is unavailable while the WebView is suspended.
  advance_to_next_episode_audio_only drives it entirely in the backend:
  fetch the next episode, build its audio-only stream URL, and load it
  into the native audio player, preserving episode identity so the
  following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
  backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
  (online delegates to the existing builder, offline errors) so the
  controller can reach it without a frontend round-trip.

TRACES: UR-040, UR-023 | DR-052 | JA-032
2026-07-25 09:21:08 +02:00
dtourolle 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
2026-07-24 23:49:23 +02:00
dtourolle c543f90ad3 feat(audio): graphic equalizer with presets and custom bands
Adds a 10-band graphic equalizer to AudioSettings (enabled flag +
per-band dB gains, normalised to 10 entries and clamped to range).
Presets return gain curves; the settings page gains EQ UI. libmpv
applies the filter on Linux (Android parity pending). Old persisted
settings without EQ fields load as disabled + flat.

Also includes the requirements/traceability/ux-flows doc updates for
this feature and the home long-press routing (UR-058/DR-087).

TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
2026-07-24 23:49:13 +02:00
dtourolle 93d198ce21 domain: primaryImageTag -> imageId end-to-end (phase 4a/4b)
Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry),
populated from primary_image_tag at every construction/conversion site.
Regenerated bindings.

Frontend: all catalog + player + merged readers now use imageId. The
NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote
session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the
neutral kind/durationMs/imageId. Types that are genuinely out of scope
(Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag.

Rust 456, frontend 644, check clean.
2026-07-23 21:40:58 +02:00
dtourolle 55fa26377a domain: introduce provider-neutral media model (phase 1)
Establish src-tauri/src/domain/ as the single source of truth for the
media model, with all Jellyfin translation isolated in from_jellyfin.rs.
Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem
as additive, defaulted dual-carry alongside the legacy Jellyfin-named
fields, so nothing breaks while the frontend migrates off them.

- domain/media.rs: canonical MediaKind (closed enum, replaces stringly
  item_type), Default = Other so unknown/defaulted items are inert.
- domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind
  classification (all audited types + person subroles) and ticks->ms.
- MediaItem gains kind/duration_ms/image_id, populated at both mapping
  seams (online to_media_item, offline cached_item_to_media_item) and
  the synthesized-album/person sites.
- Regenerated bindings.ts: frontend now HAS the neutral model available.

Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour
change yet; wire shape is a superset of before.

Rust 456 tests, frontend 644 tests, check + check:boundary all green.
2026-07-23 20:53:47 +02:00
dtourolle acf1bb200d fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled
2026-07-22 22:28:07 +02:00
dtourolle 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.
2026-07-22 21:52:07 +02:00
dtourolle 2a1f1689b4 Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-07-11 19:55:55 +02:00
dtourolleandClaude Opus 4.8 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>
2026-07-02 19:56:20 +02:00
dtourolle 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
2026-07-02 18:13:55 +02:00
dtourolle 75014ee00f Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
2026-07-01 23:49:51 +02:00
dtourolleandClaude Opus 4.8 342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
  controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
  auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
  progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
  mirroring the MPV backend.

Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
  track over the runTimeTicks estimate, so pausing no longer clobbers the
  slider's max to 0 when runTimeTicks is missing.

Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
  discriminator, so a video started via player_play_item (no Jellyfin `type`,
  mediaType "video") no longer surfaces in the audio mini player.

Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
  distinguishing tails (episode numbers, suffixes) stay visible.

Adds regression tests for the duration and mini-player fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:52:27 +02:00
dtourolle 8eae4ae253 layout improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 9m2s
Traceability Validation / Check Requirement Traces (push) Successful in 2m30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:14:17 +02:00