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.
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
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
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
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
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.
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.
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.
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>
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>
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.
- Make MediaSessionCompat the single source of truth; route all transport
commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
session poller (works while the screen is locked, unlike WebView timers) via
a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
prev/seek to the remote Jellyfin session; Stop while casting emits
RemoteDisconnectRequested, which the frontend handles by transferring to local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- music landing: diverse per-genre album sliders (online counts /
offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
- Standardize on bun: remove package-lock.json, add packageManager field,
gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
and slim README down to a project intro + docs index
- Fix internal references to the moved files
Remove unused imports and a dead test helper, and strengthen tests that
held unused bindings/fields so the values are actually exercised:
- drop unused imports (HttpConfig, MediaType, std::io::Write)
- remove unused TestEventEmitter::clear helper
- replace meaningless size_of asserts with real empty-manager checks
- assert on MockTrack.name in sorting tests
- online.rs: on Linux, advertise only WebView-decodable codecs (h264 video;
aac/mp3/opus/vorbis/flac audio) in PlaybackInfo so Jellyfin transcodes
HEVC/AV1/VP9/etc. to h264 HLS for the WebKitGTK <video> element.
- player: on Linux, don't load video into MPV (no embedded window — it would
start a redundant decode the frontend immediately stops). Add
PlayerController::set_current_item to keep queue/UI/remote-transfer state in
sync without loading the item into the playback backend.
Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState
(container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin
PascalCase still deserializes). Bindings and the existing frontend are camelCase
again — casting works with no frontend session changes.
Downloads: migrate DownloadButton.svelte and downloads.ts to the typed
commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request
objects), matching the bundled backend signatures.
Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
in lib.rs (exports bindings.ts in debug builds).
Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
download_video now take a single request struct (params bundled, body unchanged
via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
now serialize PascalCase to the frontend).
cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
Move the pure determine_video_seek_strategy function and its VideoSeekStrategy
enum (plus the 5 seek-strategy unit tests) out of the command layer into
player/seek.rs, where they belong and are testable without the Tauri State
harness. commands/player.rs now imports them from crate::player. No behavior
change.
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.