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.
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.
`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.
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
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.
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
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.
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
sites with _safe variants across 14 files, eliminating the player
crash-cascade class. Tokio async mutexes are unchanged.
Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
it falls back to NullBackend and emits a backend-init-failed event so the UI
can show "playback unavailable" instead of the app crashing. Fatal DB-setup
panics are kept.
Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
reflect reality (~20.5k non-test frontend) and document the events+polling
hybrid plus the new locking/backend-init behavior.