Commits
The header now carries a "◈ Jellyfin" / "≋ Subsonic" badge for the
active server, and each row in the Settings server list shows its
backend between the name and URL — so it's obvious at a glance which
kind the current/other servers speak.
Also adds a getStarred2 deserialization regression test covering the
real-world response shape (status key after the payload, starred
artists present but dropped).
The README tagline/intro, multi-server section, in-app header line,
and the help modal's server-cycle entry all described fin as
Jellyfin-only; the Subsonic backend has been in since 0.3.0.
New ♥ Favorites screen (key 4; Queue/Search/Devices/Settings shift to
5-8) lists everything the user has liked, backed by both servers:
Jellyfin via /Users/{id}/Items?Filters=IsFavorite + FavoriteItems,
Subsonic via getStarred2 + star/unstar — surfaced through new
MediaClient::favorites() / set_favorite() trait methods.
Shift+L likes and Shift+D dislikes the highlighted item, falling back
to the now-playing track on screens without library rows. Disliking
while the Favorites tab is open reloads the list so the row drops
immediately. Help modal, status-bar hints, and README updated.
Two independent bugs, both surfaced as "Listening now works but the
listen never lands":
Jellyfin (root cause the user hit)
- report_stopped shipped `state.position_secs` at the moment we detected
a track transition — but by then the queue had already advanced to
the next track, so PositionTicks was ~0. Jellyfin's ListenBrainz
plugin uses PositionTicks (vs. RunTimeTicks) to gauge whether the
track was actually played through; a zero position looks like a skip
and the listen is silently dropped.
- Snapshot `state.position_secs` into a new App.scrobble_last_position
on every tick where the reported track is still current. At transition
or end-of-playback, feed the snapshot into report_stopped instead of
the (now-zero) live position. The plugin sees a realistic end-of-track
position and records the listen.
Subsonic (belt-and-braces for Navidrome/Airsonic + ListenBrainz)
- Subsonic scrobble calls now include `time=<ms-since-epoch>`. Servers
like Navidrome forward this straight to ListenBrainz as `listened_at`;
without it the submission is either dropped or timestamped
approximately. For submission=true we compute the listen-start as
now − position_secs so the ListenBrainz entry lands close to when the
song actually started playing.
- scrobble() takes the timestamp as a plain argument; scrobble_now_playing
uses the current wall clock, scrobble_submission accepts an
`Option<u64>` from the caller.
Verified with cargo build --workspace --release + cargo test --workspace
--release (140 tests). Live Jellyfin retest expected to record the
listen in "Recently Listened" now.
Two belt-and-braces tightenings so failed session-report calls can never
crash the process:
- The transition arm in emit_scrobble_events used to unwrap
state.now_playing after the derivation proved it was Some. The proof
was sound but implicit; swap it for an explicit `if let Some(...)` so
the safety is textual and survives a future refactor of `now_playing_id`.
- SubsonicClient held its username/password in std::sync::Mutex, which
panics on `.lock().unwrap()` if a prior holder aborted while holding
the guard. Extremely narrow window, but a real panic path. Switch to
parking_lot::Mutex — no poisoning, no unwrap.
Every network call already ran under tokio::spawn with `warn!(?e, ...)`
on Err, so failed reports were already logged instead of propagating.
These two changes remove the last two theoretical panics on the path.
Release build + all 140 tests pass.
Two new crates plus a backend abstraction. `fin login <url>` probes the
URL to detect Jellyfin vs Subsonic and picks the right client without the
user having to say. Both backends now flow through the same `MediaClient`
trait so the TUI, CLI, and player don't have to branch on server kind.
Crates
- fin-subsonic: SubsonicClient speaking Subsonic REST v1.16.1. Salt +
MD5(password + salt) auth (no plaintext ever hits the wire once logged
in). Endpoints: ping, getAlbumList2, getAlbum, getPlaylists,
getPlaylist, search3, stream, getCoverArt, scrobble. Album / Song /
Playlist responses are mapped into fin_jellyfin::BaseItem so downstream
widgets stay backend-blind. Compatible with Airsonic, Navidrome, Gonic,
Astiga, and every server that speaks the standard vocabulary.
- fin-media: hosts the MediaClient async trait plus a `probe_server(url)`
that races GET /System/Info/Public and GET /rest/ping.view — first to
answer wins, so a healthy server responds in one round-trip. Impls for
both JellyfinClient and SubsonicClient live here (orphan rules — the
trait's crate owns them). `login_any(url, user, pass)` bundles probe +
login into a single call that returns Arc<dyn MediaClient>.
`client_from_stored(...)` reconstructs the right concrete client from a
ServerConfig after loading config.toml on startup.
Config
- fin_config: new ServerKind enum (Jellyfin | Subsonic), and ServerConfig
grows a `server_kind` field with #[serde(default)] = Jellyfin so old
configs load unchanged. Legacy single-server migration slots into
Jellyfin.
fin binary
- cmd_login now uses `fin_media::login_any` for auto-detection and stores
the detected server_kind on the ServerConfig. The "signed in as" line
advertises the backend so it's obvious which one was picked.
- make_client returns Arc<dyn MediaClient>, dispatched off server_kind.
- content_type_for_url etc. still use the JellyfinClient statics — they
don't need the abstraction.
fin-tui
- App::jellyfin is now Arc<Mutex<Arc<dyn MediaClient>>>. Zero-change to
every jf().items(...) call site — trait method signatures match.
- switch_server rebuilds via client_from_stored, so switching from a
Jellyfin server to a Subsonic one (or vice versa) picks the right
concrete client.
Scrobble / session-report hooks
- MediaClient trait gains report_started / report_progress /
report_stopped with no-op defaults.
- JellyfinClient impl delegates to /Sessions/Playing[/Progress|/Stopped].
- SubsonicClient impl fires /rest/scrobble.view — submission=false on
started, submission=true on stopped. progress is a Subsonic no-op
(there's no per-tick endpoint in the standard vocabulary).
- fin-tui event loop tracks the last-reported item id + a 10 s throttle
and emits started / progress / stopped as playback transitions. Only
fires for local audio playback; Chromecast / UPnP receivers manage
their own reporting server-side.
Verified: cargo test --workspace passes 140 tests across 13 crates
(previous baseline was 133 in 12 crates — the +7 are Subsonic-side
MD5/salt/JSON round-trip tests plus the fin-media ServerKind test).
Ships everything on the 0.2.x → 0.3.0 line: symphonia+cpal audio path,
UPnP renderer, queue persistence, shuffle/repeat, ReplayGain, dual-track
crossfade (crossfade + mixed modes), Rockbox EQ + bass/treble, the 10-band
EQ sliders in the TUI, album drill-in (discs + track numbers + year +
sort), and the ? keyboard-shortcuts modal.
- workspace Cargo.toml: 0.2.0 → 0.3.0 (all five crates pick it up via
version.workspace = true).
- flake.nix commonArgs.version bumped to match.
- README install snippets now reference fin_0.3.0_*.deb / fin-0.3.0-*.rpm.
- Debian control files (amd64 + arm64) and the RPM spec updated.
- Cargo.lock refreshed via `cargo build --workspace`.
Ratatui popup over the current screen listing every key fin binds,
grouped by category (Navigation / Playback / Queue / Modes & effects /
Equalizer & tone / Help). ? toggles it; Esc closes it too.
While the modal is open, only ? / Esc / Ctrl+C do anything — every
other key is swallowed so you can't accidentally start music while
reading. The popup is drawn LAST in the frame so it lands on top of
the player bar and status line, and is backed by ratatui::Clear so
the underlying UI doesn't bleed through.
Widget layout is data-driven off a static HELP_SECTIONS table so tests
can lock the shape and typos surface at compile time. Column layout
pads the key side to the widest key across all sections, which keeps
"Enter" and "Shift+↑ / Shift+↓" column-aligned regardless of section.
Sizing: the popup grows to the terminal height (minus a 2-row margin)
capped at 60 rows so every section fits without scrolling — verified
in a 60-row tmux window.
Four unit tests cover HELP_SECTIONS: no empty sections or entries,
? is always documented, and build_lines' first row is a section
header (not a blank spacer).
Bottom-of-screen hint promoted "?: help" to first position and README
keybindings table gained a matching row.
Album drill-in now uses its own renderer instead of the generic list.
Improvements:
- Album title includes the production year in parentheses when
Jellyfin has one (e.g. "17 (2017)").
- Every track row shows its track number, padded to the widest number
in the album so "1." and "12." line up. The subtitle / duration
columns follow the same layout math as the standard list, so they
stay column-aligned with sibling views.
- Multi-disc albums render a "▤ Disc N" header before each disc's
tracks — non-selectable, so Up/Down navigation walks track-index
space and the cursor never lands on a header. Single-disc albums
render as a flat list (no header noise).
- After fetching the tracks, sort_album_tracks() re-orders by
(disc, track number, name) as a safety net. Jellyfin already applies
ParentIndexNumber,IndexNumber,SortName server-side, but a single
mis-tagged track was enough to leak into the disc grouping downstream.
Under the hood the app still tracks a TRACK-index cursor; the album
renderer builds visual rows + a track_to_visual map and drives a local
ListState so the highlight lands on the right visible line even when
disc headers push the visual index above the track index.
Four unit tests cover sort_album_tracks: disc-then-track ordering,
tracks with missing metadata sink to the tail, ties break on name,
and already-sorted input is preserved.
Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.
DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.
Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.
Wires the second Rockbox DSP stage into the audio path — the same singleton
that hosts the EQ. Both stages now apply to the outgoing AND incoming
crossfade tracks (not just current) — the biquad state gets briefly stirred
at the switch each tick, but the transient is sub-millisecond at 48 kHz and
inaudible in practice.
Config
- fin-config: bass / treble (dB, ±24) + bass_cutoff / treble_cutoff (Hz,
0 = Rockbox defaults 200 / 3500) at Config's top level, matching the
Rockbox settings.toml schema so presets round-trip. All default to 0.
Audio path
- New PlayerCommand::SetTone forwarded through the Renderer trait and
LocalRenderer. Applied to the DSP via a new apply_tone_to_dsp helper
that sets cutoffs first (so the prescale in set_tone recomputes filter
coefficients on the right shelf frequencies).
- The DSP is now routed for both current AND next during a crossfade —
same coefficients / same shelves on both sides of the overlap.
- The DSP path is skipped entirely when EQ and both tone shelves are 0,
so no f32↔i16 conversion overhead on unaffected sessions.
- Tone state mirrored on PlaybackState.bass_db / treble_db.
TUI
- New keys: b / Shift+B step bass by −1 / +1 dB, y / Shift+Y for treble
(avoids conflict with `t` = cycle server). Values persist immediately.
- Settings screen grew a new "Tone" row showing bass and treble alongside
the existing ReplayGain / Crossfade rows. The top card grew one line.
- EQ card is now 18 rows (was 11) so the vertical sliders have real
resolution; server list falls back to a Min(3) floor if terminal is short.
- Player bar shows a compact "B+3/T-2" badge whenever either shelf is
non-zero.
- Help hint + README ToC + keybindings + a new "Bass & treble"
sub-section under Playback modes covering keys, cutoffs, and the same
GPL note as EQ.
Verified live: bass toggled to +3 dB, treble to −2 dB via B×3 + y×2;
Settings row + player badge update in step; config.toml persists
`bass = 3` / `treble = -2`.
Previously the layout stacked "+2.5" on one row and "dB" on the next.
Reading it took an extra saccade — and the "dB" row was pure whitespace
for the column, cluttering the panel.
Now the top row of each slider column reads "+2.5 dB" as a single line
(number in the band's style, unit in muted style). The bar picks up one
more row of vertical resolution as a side effect.
Links rockbox-dsp 0.1 from crates.io — a Rust wrapper around the fixed-point
Rockbox DSP pipeline (10-band EQ + tone + resampler + more).
Config
- fin-config: eq_enabled + eq_band_settings at the top level, matching the
on-disk shape rockbox uses so presets round-trip. A fresh config is
auto-populated with the ISO-octave flat preset (32 Hz → 16 kHz, Q 7.0,
0 dB) via Config::ensure_eq_bands() on load, so the sliders always show
10 bands and the DSP has something meaningful to process. Old configs
with fewer bands get padded to 10 without touching what's there.
Audio path
- SymphoniaPlayer owns an Option<Dsp> singleton, lazy-initialised on the
first track load. When eq_enabled is true, the *current* track's post-
resample samples run through Dsp (f32 → i16 saturating → dsp_process →
i16 → f32) before volume + ReplayGain + fade multiply. Non-stereo output
bypasses (rockbox-dsp is stereo-only).
- The next track during a crossfade always bypasses — the Rockbox pipeline
is a process-wide singleton (CODEC_IDX_AUDIO), can't run two streams.
- New PlayerCommand::SetEq for live toggle + band updates without a
reload; SetReplayGain-style forwarding through LocalRenderer.
- main.rs applies cfg.eq_enabled + cfg.eq_band_settings on startup.
TUI
- New EqSliders widget: 10 vertical block-char slider columns with a
+N.N dB label above, "dB" unit hint, and a Hz/kHz cutoff label below.
Selected band gets highlighted; a ±24 dB axis label sits in the corner.
- Settings screen grew a 3-row layout — global config, EQ panel, servers.
- Keybindings: E toggles EQ. On Settings: [ / ] cycle bands, Shift+↑/↓
bump the selected band's gain by ±1 dB. Changes persist to config.toml
immediately and are reapplied to the running DSP.
- Player bar shows a compact "EQ" badge when active.
- Help hint + README table of contents + keybindings table updated with
a new "Equalizer" sub-section describing the on-disk format, keys, and
the licensing implication (linking Rockbox DSP makes the binary GPL).
Verified live: Settings screen renders 10 sliders at the ISO cutoffs; E
lights the panel border; ] moves to band 3 (125 Hz); five Shift+Up
presses take it to +5.0 dB with the status line and the slider fill
updating in step; config.toml persists gain = 50 on band 3.
Adds a linked table of contents at the top so readers can jump to any
section. While in there, reflects everything that landed since the
last README pass:
- Audio is in-process (symphonia + cpal) now — mpv is video-only. Any
mention of mpv as the "local renderer" is corrected.
- Features section covers shuffle, repeat, ReplayGain, crossfade
(Crossfade vs Mixed), queue persistence, and remove/clear queue.
- Keybindings table gains z / Shift+R / g / f / Shift+F / d / Shift+C
and the Enter-jumps-on-Queue behaviour.
- New "Playback modes & effects" section with a sub-heading per feature
(Shuffle / Repeat / ReplayGain / Crossfade), each with anchor links
from the ToC.
- New "Queue persistence" section explains queue.json survival across
restarts.
- "Streams & transcoding" split into audio (symphonia) vs video (mpv).
- All-settings table gains a sub-table for the replaygain/crossfade
TOML keys.
- Install snippets mention libasound2/alsa-lib on Linux and clarify
mpv is only a runtime dep when playing video.
When the user played from the Queue screen (or any Play command), the
handler called `queue.replace(items, start_index)` which already sets
current_index at the incoming track. Then the crossfade promotion step
would unconditionally call `queue.advance()`, shifting current_index
one row past the actually-playing track.
Track whether the pending promotion should advance:
- End-approach preload (peek_next_item, next-in-queue): advance = true
- User Play with crossfade (queue already positioned): advance = false
The Next command's snap-promote reads the same flag so hitting Next
mid-Play-crossfade doesn't double-advance either.
Debug log now includes `advance=<bool>` at promotion time.
Verified: enable Mixed 8s, play a queue item at index 6, wait for the
overlap to complete. Queue title stayed (6/16); log shows advance=false.
- Delete the unused drain_ring helper and the Track.ring_capacity field
that only fed it. build_output_stream's tuple shrinks by one slot.
- Shift+F cycles the crossfade duration through 3/5/8/12 s, preserving
the current mode. `f` still toggles the mode.
- Settings row + help hint updated to advertise both keys.
Adjacent tracks now blend for a configurable window. Two modes:
- Crossfade: cosine/sine curves (outgoing^2 + incoming^2 = 1) — perceived
loudness stays constant across the overlap.
- Mixed: no curves, both tracks at full volume — sums additively for a
louder overlap window (DJ-style mix).
The SymphoniaPlayer worker now runs two decoders in parallel during an
overlap. Each track has its own cpal Stream + ring buffer; the OS mixes
them at the audio layer. Fade envelopes are applied per-frame in the
sample push path (using this track's produced_output_frames as the
progress clock).
Data
- fin-config: CrossfadeMode (Off/Crossfade/Mixed) + CrossfadeSettings
(mode + duration_secs, default 5s). #[serde(default)] on the Config
field keeps older configs loading.
- fin-player: crossfade module with fade_at() + FadePair, 6 unit tests
covering endpoint values, midpoint equal-power, sum-of-squares
invariant, and clamping.
Audio path
- Track gets `ended: bool`, `overlap_incoming/outgoing: Option<OverlapContext>`.
On EOF the current track marks itself ended but doesn't advance the queue
— that's the promotion step's job.
- Two ways an overlap starts:
1. End-approach: worker sees decoder-position + duration <= xf duration,
peeks the next queue item, loads it as `next` with fade_in, marks
current with fade_out.
2. User Play/enqueue-jump: while a track's playing and crossfade is on,
the incoming track loads as `next` instead of hard-cutting.
- Promotion fires when either the fade-in completes OR the current track's
fade-out reaches full length OR current ended prematurely.
- Manual Next snap-promotes any pending crossfade for instant response.
- SetReplayGain propagates to both tracks so a mid-overlap RG change hits
both sides.
Renderer + config
- PlaybackState.crossfade mirrored so the TUI can render without a
separate query path.
- Renderer::set_crossfade with a no-op default (Chromecast/UPnP each
handle their own transitions).
- main.rs applies cfg.crossfade on startup before playback begins.
TUI
- `f` cycles Off → Crossfade → Mixed → Off, persisted to config.toml.
Duration edit stays in config.toml.
- Player bar shows ⋈ Ns (Crossfade) or ≈ Ns (Mixed) when active.
- Settings screen row: mode + duration + hint.
- Help text updated.
Verified live: debug log confirms two crossfades executed with
length_frames=240000 (5s at 48 kHz), promoted after exactly 5 s;
Mixed mode swaps to the ≈ glyph. mpv still never spawns for audio.
82 workspace tests pass.
Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN / REPLAYGAIN_*_PEAK
tags off decoded tracks and applies a linear multiplier to samples on
their way to cpal. User picks Off / Track / Album via `g` in the TUI.
Data
- fin-config: new ReplayGainMode enum and ReplayGainSettings struct
(mode, preamp_db, prevent_clip). Config gains a `replaygain` field
with #[serde(default)] so old on-disk configs keep loading. Pure
data, no fin-player dep required.
- fin-player: new replaygain module owns ReplayGainInfo (per-track
tag values) and the linear_gain math — dB→linear, preamp additive
in dB domain, peak-based clip prevention, fallback to the other
scope when the requested one is missing. 11 unit tests cover the
numeric edge cases (clip prevention, preamp, fallback, junk parse).
Audio path
- SymphoniaPlayer stores current ReplayGainSettings in the worker,
extracts ReplayGainInfo from the first packet's metadata on each
track load, and precomputes a linear gain that the sample push
loop folds into the same multiply as user volume.
- New PlayerCommand::SetReplayGain updates the settings live and
recomputes the current track's linear gain in place.
- Renderer trait gains set_replaygain with a no-op default (Chromecast
and UPnP do their own device-side normalization).
TUI
- `g` cycles Off → Track → Album → Off, saves to config, statuses
"ReplayGain: <mode>".
- Player bar shows a compact "RG:track" / "RG:album" badge only when
active; muted when Off (row stays balanced).
- Settings screen has a new row: mode + preamp dB + clip-guard flag
+ "press g to cycle" hint. Panel grew from 7→8 rows to fit.
- Help hint updated.
Verified live: on a track without RG tags, linear=1.0 is logged and the
badge lights up when cycled; config.toml grows a [replaygain] section
that survives restart; 76 workspace tests pass.
Adds session survival plus a few overdue queue controls. The audio queue,
its shuffle/repeat state, and the exact playhead within the current track
now round-trip through cache_dir/queue.json — restore leaves playback
paused so Space resumes from where the user left off.
Playback
- RepeatMode enum (Off/One/All) on PlaybackQueue with advance()/back()
honoring it. Repeat-all wraps in both directions.
- Fisher-Yates shuffle over items after the current position via `rand`,
so shuffling mid-track doesn't jump the playhead.
- Renderer trait gains set_shuffle / set_repeat / restore /
remove_from_queue with no-op defaults for Chromecast + UPnP.
Persistence
- crates/fin-player/src/persist.rs: PersistedQueue serde struct and a
background writer thread that coalesces bursts and atomically renames
a .tmp into place.
- SymphoniaPlayer worker snapshots on every mutating command AND every
~3 s while playing so position stays fresh even if the app is killed.
- Restore filters out video items, remaps the saved index to survive
filtering, and stashes position_secs as a pending_seek applied on the
next track load.
TUI
- z: toggle shuffle. Shift+R: cycle repeat off/all/one.
- On the Queue screen: d removes the highlighted entry (keeps playback
going unless that entry was the current track); Shift+C clears the
whole queue.
- Player bar shows compact ⇄ (shuffle) and ↻/↺ (repeat) glyphs, colored
when active, muted when off.
- Queue rows: currently-playing track gets a ▶ marker in the accent
color, distinct from the cursor's ▍. Title shows (position/total).
- Help hint updated.
Startup
- fin_config::queue_path() lives in the cache dir. main::build_renderer
loads it (silent-none on missing/corrupt) and hands it to
LocalRenderer::with_persist + renderer.restore() before the TUI runs.
Verified end-to-end: 45 s of playback → quit → restart → queue restored
with shuffle+repeat state + 54 s playhead, resumes on Space; cursor and
now-playing markers move independently on the Queue screen; d removes a
non-current entry without interrupting playback.
Pressing Enter on a row in the Queue screen went through the generic
play_selected(PlayMode::PlayNow) path, which called
`renderer.play(selected_item_only, 0)` — replacing the entire queue
with just that one item.
Added a Queue-screen-specific Enter handler that snapshots the current
queue, hands it back to the renderer with the selected index as the
start position, and reports the jump. Same items, playhead moves.
pkg-config's setup hook only scans `buildInputs` for `.pc` files, so
alsa-lib landing in `nativeBuildInputs` made cpal's ALSA link resolve
inconsistently in the dev shell — the cpal build script could run
pkg-config but wouldn't find libasound.pc.
Split the devShell into a proper nativeBuildInputs (tools including
pkg-config) and buildInputs (link-time libs: libiconv on Darwin,
alsa-lib on Linux). crane's commonArgs was already correct; this only
touches the devShell.
`current_list()` returns `BaseItem`s from the Jellyfin library and is
empty for the Devices (Chromecast/UPnP) and Settings (saved servers)
screens, which render their own row types. The Up/Down/PageDown
handlers were sizing against that empty list, so the cursor never
moved.
Added a screen-aware `list_len()` helper that reads the real backing
collection for Devices and Settings, and routed the nav handlers
through it.
Previously the decoder pushed samples at the source rate (e.g. 44.1 kHz
FLAC) into a cpal stream we asked to run at that same rate — but on
macOS and some Linux setups cpal silently keeps the OS device at its
own default (48 kHz here), so playback drifted (slow or fast depending
on the direction).
Fix:
- Open cpal at `device.default_output_config()` — the only spec every
backend is guaranteed to honor.
- Decode the first packet before opening cpal so we take sample rate
and channel count from the actual AudioBufferRef spec, not from the
codec_params metadata (which lies for HE-AAC / some Ogg streams).
- Route every packet through a stateful linear-interpolation resampler
+ channel converter, so the ring buffer holds samples already at the
device's output spec.
- Simplify the cpal callback to a plain copy from the ring.
- Track "produced_output_frames" instead of source frames so playhead
position matches wall-clock exactly.
Verified locally: 44.1 kHz FLAC on a 48 kHz macOS device now plays at
real-time speed (86 s wall-clock = 85 s playhead over a two-minute run).
cpal links against ALSA on Linux for the new symphonia audio path.
- Release workflow installs libasound2-dev before the Linux build.
- .deb Depends and .rpm Requires pull in the runtime library.
- flake.nix adds alsa-lib to both the crane buildInputs and the
devShell on Linux.
Local playback used to send everything through mpv. Audio now decodes
in-process via symphonia and outputs through cpal; mpv is spawned only
when a video item plays. Chromecast and UPnP still handle both.
- New SymphoniaPlayer: streaming HTTP media source, symphonia probe +
decode, SPSC ring buffer feeding a cpal output stream.
- New LocalRenderer: dispatches per QueueItem — audio→symphonia,
video→mpv — behind the existing Renderer trait.
- Preflight no longer aborts when mpv is missing; audio still works,
video prints an actionable hint.
- Player bar and help hints relabelled "mpv" → "local".
The status bar shows a keybinding cheat sheet whenever `status_message`
is `None`, but load results like `♪ 118 album(s)` were set on screen
load and never cleared, so the help was only visible for a blink before
being overwritten.
Stamp each status message with an `Instant` and treat entries older than
4s as `None` at draw time. The 200ms tick loop guarantees the redraw
that flips them back to the help text.
Discover MediaRenderers via SSDP and drive them with AVTransport +
RenderingControl SOAP actions, alongside the existing Chromecast path.
- linux-aarch64 now builds on ubuntu-24.04-arm (GitHub's native ARM
runner), so we drop the cross toolchain and the QEMU-flavored build.
Faster, and matches production glibc more closely.
- Both macos targets run on macos-latest. macos-latest is now
Apple-silicon; the x86_64-apple-darwin build uses rustup's cross
target (host aarch64 → x86_64) which works out of the box.
- Drop the `use_cross` matrix flag and the split cargo/cross build
steps — there's just one `cargo build` now.
Passing Limit=<omitted> to /Users/{id}/Items still let Jellyfin apply its
own silent cap on some deployments, so Music / Videos / Playlists / album
tracks / series episodes were being truncated at whatever that cap was.
Now items() paginates internally with Limit=500 + StartIndex, driven by
the server-reported TotalRecordCount. It keeps calling until either:
* a partial page comes back (we've hit the tail), or
* the collected count equals TotalRecordCount.
Same treatment for /Playlists/{id}/Items so long playlists load fully.
Also bump the http timeout to 120s so a very large single page can't
time out mid-response.
- Drop `darwin.apple_sdk.frameworks.SystemConfiguration` and `.Security`
references. `darwin.apple_sdk_11_0` was removed from nixpkgs as a
legacy compatibility stub — the current stdenv auto-links the SDK.
- Stop overriding crane's `nixpkgs` input (crane no longer exposes one),
which silences the `input 'crane' has an override for a non-existent
input 'nixpkgs'` warning.
- Regenerate flake.lock.
Chromecast:
- Content-Type follows the URL that stream_url() built — new
content_type_for_url() sniffs the extension (m3u8, mp4, mp3, flac,
opus, mkv, webm, …). Fixes the "metadata shows then stops" pattern
where we were labeling .m3u8 URLs as video/mp4 and the receiver
refused to play them.
- Queue advance fixed: get_status() is now called without a media
session id (None). Chromecast invalidates the previous session id
the instant playback ends, so polling with Some(id) returned an
empty list and we never saw the FINISHED signal. Empty entries
after having been Playing is now treated as end-of-track too.
- Idle-with-error is also treated as end-of-track so a single bad
stream can't hang the whole queue.
Streams:
- No more forced mp3/mp4 URLs. BaseItem now carries `container`
(fetched via Fields=Container,MediaSources), and stream_url(Direct)
uses it: FLAC stays .flac, MKV stays .mkv, Opus stays .opus.
Static=true keeps Jellyfin from unnecessarily transcoding.
- Both mpv and Chromecast now default to Direct; Chromecast decodes
H.264/MP3/AAC/FLAC/Opus/Vorbis/WebM/WAV natively so this is faster
and more reliable than the old HLS pipeline. `--hls` still forces
transcoding for edge codecs.
Release / packaging:
- .github/workflows/release.yml — cross-arch matrix for
linux amd64/aarch64 and darwin amd64/aarch64. Builds tarballs
(with checksums), .deb for both Linux arches, and .rpm for x86_64.
Publishes a GitHub release on `v*` tags and pushes .deb/.rpm to
Gemfury when FURY_TOKEN + FURY_ACCOUNT secrets are set.
- dist/debian/{amd64,arm64}/DEBIAN/control and dist/rpm/amd64/fin.spec
declare `mpv` as a runtime dependency so `apt install fin` /
`dnf install fin` pull it in automatically.
Docs:
- README: brew tap, apt/dnf via GitHub release AND via Gemfury repo,
Arch source install, prebuilt tarballs.
- Preview screenshot reference at .github/assets/preview.png.
TUI:
- Drop Home tab; default screen is Music. New order: Music, Videos,
Playlists, Queue, Search, Devices, Settings.
- Drill-in on Enter for MusicAlbum, Series, and Playlist; `x` plays
the whole container without drilling in. Esc pops the drill-in.
- Lists fetch every item (no `Limit`) — nothing gets truncated.
- Every row shares a fixed column layout (icon / title / subtitle /
right-aligned time) so columns line up across rows.
- Reactive search: immediate "searching…" status, 40ms debounce,
blinking cursor. Status bar highlights errors red, other messages
in primary teal.
Jellyfin API:
- Tolerant JSON parser normalizes response keys to PascalCase, so
Jellyfin 10.10 camelCase responses work alongside 10.8 PascalCase.
- Fallback from /Search/Hints to /Users/{id}/Items when hints return
no parsable results.
- Query params switched to camelCase; explicit includeMedia=true.
- 5s connect / 15s request timeouts.
Chromecast:
- Install rustls aws-lc-rs CryptoProvider at startup — rust_cast 0.20
ships rustls without a default provider and panicked on the first
TLS handshake ("Could not automatically determine the process-level
CryptoProvider").
Config / logging:
- Silence mdns_sd's cosmetic ERROR on ServiceDaemon shutdown at
default verbosity (-v bumps to warn).
The header now carries a "◈ Jellyfin" / "≋ Subsonic" badge for the
active server, and each row in the Settings server list shows its
backend between the name and URL — so it's obvious at a glance which
kind the current/other servers speak.
Also adds a getStarred2 deserialization regression test covering the
real-world response shape (status key after the payload, starred
artists present but dropped).
New ♥ Favorites screen (key 4; Queue/Search/Devices/Settings shift to
5-8) lists everything the user has liked, backed by both servers:
Jellyfin via /Users/{id}/Items?Filters=IsFavorite + FavoriteItems,
Subsonic via getStarred2 + star/unstar — surfaced through new
MediaClient::favorites() / set_favorite() trait methods.
Shift+L likes and Shift+D dislikes the highlighted item, falling back
to the now-playing track on screens without library rows. Disliking
while the Favorites tab is open reloads the list so the row drops
immediately. Help modal, status-bar hints, and README updated.
Two independent bugs, both surfaced as "Listening now works but the
listen never lands":
Jellyfin (root cause the user hit)
- report_stopped shipped `state.position_secs` at the moment we detected
a track transition — but by then the queue had already advanced to
the next track, so PositionTicks was ~0. Jellyfin's ListenBrainz
plugin uses PositionTicks (vs. RunTimeTicks) to gauge whether the
track was actually played through; a zero position looks like a skip
and the listen is silently dropped.
- Snapshot `state.position_secs` into a new App.scrobble_last_position
on every tick where the reported track is still current. At transition
or end-of-playback, feed the snapshot into report_stopped instead of
the (now-zero) live position. The plugin sees a realistic end-of-track
position and records the listen.
Subsonic (belt-and-braces for Navidrome/Airsonic + ListenBrainz)
- Subsonic scrobble calls now include `time=<ms-since-epoch>`. Servers
like Navidrome forward this straight to ListenBrainz as `listened_at`;
without it the submission is either dropped or timestamped
approximately. For submission=true we compute the listen-start as
now − position_secs so the ListenBrainz entry lands close to when the
song actually started playing.
- scrobble() takes the timestamp as a plain argument; scrobble_now_playing
uses the current wall clock, scrobble_submission accepts an
`Option<u64>` from the caller.
Verified with cargo build --workspace --release + cargo test --workspace
--release (140 tests). Live Jellyfin retest expected to record the
listen in "Recently Listened" now.
Two belt-and-braces tightenings so failed session-report calls can never
crash the process:
- The transition arm in emit_scrobble_events used to unwrap
state.now_playing after the derivation proved it was Some. The proof
was sound but implicit; swap it for an explicit `if let Some(...)` so
the safety is textual and survives a future refactor of `now_playing_id`.
- SubsonicClient held its username/password in std::sync::Mutex, which
panics on `.lock().unwrap()` if a prior holder aborted while holding
the guard. Extremely narrow window, but a real panic path. Switch to
parking_lot::Mutex — no poisoning, no unwrap.
Every network call already ran under tokio::spawn with `warn!(?e, ...)`
on Err, so failed reports were already logged instead of propagating.
These two changes remove the last two theoretical panics on the path.
Release build + all 140 tests pass.
Two new crates plus a backend abstraction. `fin login <url>` probes the
URL to detect Jellyfin vs Subsonic and picks the right client without the
user having to say. Both backends now flow through the same `MediaClient`
trait so the TUI, CLI, and player don't have to branch on server kind.
Crates
- fin-subsonic: SubsonicClient speaking Subsonic REST v1.16.1. Salt +
MD5(password + salt) auth (no plaintext ever hits the wire once logged
in). Endpoints: ping, getAlbumList2, getAlbum, getPlaylists,
getPlaylist, search3, stream, getCoverArt, scrobble. Album / Song /
Playlist responses are mapped into fin_jellyfin::BaseItem so downstream
widgets stay backend-blind. Compatible with Airsonic, Navidrome, Gonic,
Astiga, and every server that speaks the standard vocabulary.
- fin-media: hosts the MediaClient async trait plus a `probe_server(url)`
that races GET /System/Info/Public and GET /rest/ping.view — first to
answer wins, so a healthy server responds in one round-trip. Impls for
both JellyfinClient and SubsonicClient live here (orphan rules — the
trait's crate owns them). `login_any(url, user, pass)` bundles probe +
login into a single call that returns Arc<dyn MediaClient>.
`client_from_stored(...)` reconstructs the right concrete client from a
ServerConfig after loading config.toml on startup.
Config
- fin_config: new ServerKind enum (Jellyfin | Subsonic), and ServerConfig
grows a `server_kind` field with #[serde(default)] = Jellyfin so old
configs load unchanged. Legacy single-server migration slots into
Jellyfin.
fin binary
- cmd_login now uses `fin_media::login_any` for auto-detection and stores
the detected server_kind on the ServerConfig. The "signed in as" line
advertises the backend so it's obvious which one was picked.
- make_client returns Arc<dyn MediaClient>, dispatched off server_kind.
- content_type_for_url etc. still use the JellyfinClient statics — they
don't need the abstraction.
fin-tui
- App::jellyfin is now Arc<Mutex<Arc<dyn MediaClient>>>. Zero-change to
every jf().items(...) call site — trait method signatures match.
- switch_server rebuilds via client_from_stored, so switching from a
Jellyfin server to a Subsonic one (or vice versa) picks the right
concrete client.
Scrobble / session-report hooks
- MediaClient trait gains report_started / report_progress /
report_stopped with no-op defaults.
- JellyfinClient impl delegates to /Sessions/Playing[/Progress|/Stopped].
- SubsonicClient impl fires /rest/scrobble.view — submission=false on
started, submission=true on stopped. progress is a Subsonic no-op
(there's no per-tick endpoint in the standard vocabulary).
- fin-tui event loop tracks the last-reported item id + a 10 s throttle
and emits started / progress / stopped as playback transitions. Only
fires for local audio playback; Chromecast / UPnP receivers manage
their own reporting server-side.
Verified: cargo test --workspace passes 140 tests across 13 crates
(previous baseline was 133 in 12 crates — the +7 are Subsonic-side
MD5/salt/JSON round-trip tests plus the fin-media ServerKind test).
Ships everything on the 0.2.x → 0.3.0 line: symphonia+cpal audio path,
UPnP renderer, queue persistence, shuffle/repeat, ReplayGain, dual-track
crossfade (crossfade + mixed modes), Rockbox EQ + bass/treble, the 10-band
EQ sliders in the TUI, album drill-in (discs + track numbers + year +
sort), and the ? keyboard-shortcuts modal.
- workspace Cargo.toml: 0.2.0 → 0.3.0 (all five crates pick it up via
version.workspace = true).
- flake.nix commonArgs.version bumped to match.
- README install snippets now reference fin_0.3.0_*.deb / fin-0.3.0-*.rpm.
- Debian control files (amd64 + arm64) and the RPM spec updated.
- Cargo.lock refreshed via `cargo build --workspace`.
Ratatui popup over the current screen listing every key fin binds,
grouped by category (Navigation / Playback / Queue / Modes & effects /
Equalizer & tone / Help). ? toggles it; Esc closes it too.
While the modal is open, only ? / Esc / Ctrl+C do anything — every
other key is swallowed so you can't accidentally start music while
reading. The popup is drawn LAST in the frame so it lands on top of
the player bar and status line, and is backed by ratatui::Clear so
the underlying UI doesn't bleed through.
Widget layout is data-driven off a static HELP_SECTIONS table so tests
can lock the shape and typos surface at compile time. Column layout
pads the key side to the widest key across all sections, which keeps
"Enter" and "Shift+↑ / Shift+↓" column-aligned regardless of section.
Sizing: the popup grows to the terminal height (minus a 2-row margin)
capped at 60 rows so every section fits without scrolling — verified
in a 60-row tmux window.
Four unit tests cover HELP_SECTIONS: no empty sections or entries,
? is always documented, and build_lines' first row is a section
header (not a blank spacer).
Bottom-of-screen hint promoted "?: help" to first position and README
keybindings table gained a matching row.
Album drill-in now uses its own renderer instead of the generic list.
Improvements:
- Album title includes the production year in parentheses when
Jellyfin has one (e.g. "17 (2017)").
- Every track row shows its track number, padded to the widest number
in the album so "1." and "12." line up. The subtitle / duration
columns follow the same layout math as the standard list, so they
stay column-aligned with sibling views.
- Multi-disc albums render a "▤ Disc N" header before each disc's
tracks — non-selectable, so Up/Down navigation walks track-index
space and the cursor never lands on a header. Single-disc albums
render as a flat list (no header noise).
- After fetching the tracks, sort_album_tracks() re-orders by
(disc, track number, name) as a safety net. Jellyfin already applies
ParentIndexNumber,IndexNumber,SortName server-side, but a single
mis-tagged track was enough to leak into the disc grouping downstream.
Under the hood the app still tracks a TRACK-index cursor; the album
renderer builds visual rows + a track_to_visual map and drives a local
ListState so the highlight lands on the right visible line even when
disc headers push the visual index above the track index.
Four unit tests cover sort_album_tracks: disc-then-track ordering,
tracks with missing metadata sink to the tail, ties break on name,
and already-sorted input is preserved.
Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.
DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.
Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.
Wires the second Rockbox DSP stage into the audio path — the same singleton
that hosts the EQ. Both stages now apply to the outgoing AND incoming
crossfade tracks (not just current) — the biquad state gets briefly stirred
at the switch each tick, but the transient is sub-millisecond at 48 kHz and
inaudible in practice.
Config
- fin-config: bass / treble (dB, ±24) + bass_cutoff / treble_cutoff (Hz,
0 = Rockbox defaults 200 / 3500) at Config's top level, matching the
Rockbox settings.toml schema so presets round-trip. All default to 0.
Audio path
- New PlayerCommand::SetTone forwarded through the Renderer trait and
LocalRenderer. Applied to the DSP via a new apply_tone_to_dsp helper
that sets cutoffs first (so the prescale in set_tone recomputes filter
coefficients on the right shelf frequencies).
- The DSP is now routed for both current AND next during a crossfade —
same coefficients / same shelves on both sides of the overlap.
- The DSP path is skipped entirely when EQ and both tone shelves are 0,
so no f32↔i16 conversion overhead on unaffected sessions.
- Tone state mirrored on PlaybackState.bass_db / treble_db.
TUI
- New keys: b / Shift+B step bass by −1 / +1 dB, y / Shift+Y for treble
(avoids conflict with `t` = cycle server). Values persist immediately.
- Settings screen grew a new "Tone" row showing bass and treble alongside
the existing ReplayGain / Crossfade rows. The top card grew one line.
- EQ card is now 18 rows (was 11) so the vertical sliders have real
resolution; server list falls back to a Min(3) floor if terminal is short.
- Player bar shows a compact "B+3/T-2" badge whenever either shelf is
non-zero.
- Help hint + README ToC + keybindings + a new "Bass & treble"
sub-section under Playback modes covering keys, cutoffs, and the same
GPL note as EQ.
Verified live: bass toggled to +3 dB, treble to −2 dB via B×3 + y×2;
Settings row + player badge update in step; config.toml persists
`bass = 3` / `treble = -2`.
Previously the layout stacked "+2.5" on one row and "dB" on the next.
Reading it took an extra saccade — and the "dB" row was pure whitespace
for the column, cluttering the panel.
Now the top row of each slider column reads "+2.5 dB" as a single line
(number in the band's style, unit in muted style). The bar picks up one
more row of vertical resolution as a side effect.
Links rockbox-dsp 0.1 from crates.io — a Rust wrapper around the fixed-point
Rockbox DSP pipeline (10-band EQ + tone + resampler + more).
Config
- fin-config: eq_enabled + eq_band_settings at the top level, matching the
on-disk shape rockbox uses so presets round-trip. A fresh config is
auto-populated with the ISO-octave flat preset (32 Hz → 16 kHz, Q 7.0,
0 dB) via Config::ensure_eq_bands() on load, so the sliders always show
10 bands and the DSP has something meaningful to process. Old configs
with fewer bands get padded to 10 without touching what's there.
Audio path
- SymphoniaPlayer owns an Option<Dsp> singleton, lazy-initialised on the
first track load. When eq_enabled is true, the *current* track's post-
resample samples run through Dsp (f32 → i16 saturating → dsp_process →
i16 → f32) before volume + ReplayGain + fade multiply. Non-stereo output
bypasses (rockbox-dsp is stereo-only).
- The next track during a crossfade always bypasses — the Rockbox pipeline
is a process-wide singleton (CODEC_IDX_AUDIO), can't run two streams.
- New PlayerCommand::SetEq for live toggle + band updates without a
reload; SetReplayGain-style forwarding through LocalRenderer.
- main.rs applies cfg.eq_enabled + cfg.eq_band_settings on startup.
TUI
- New EqSliders widget: 10 vertical block-char slider columns with a
+N.N dB label above, "dB" unit hint, and a Hz/kHz cutoff label below.
Selected band gets highlighted; a ±24 dB axis label sits in the corner.
- Settings screen grew a 3-row layout — global config, EQ panel, servers.
- Keybindings: E toggles EQ. On Settings: [ / ] cycle bands, Shift+↑/↓
bump the selected band's gain by ±1 dB. Changes persist to config.toml
immediately and are reapplied to the running DSP.
- Player bar shows a compact "EQ" badge when active.
- Help hint + README table of contents + keybindings table updated with
a new "Equalizer" sub-section describing the on-disk format, keys, and
the licensing implication (linking Rockbox DSP makes the binary GPL).
Verified live: Settings screen renders 10 sliders at the ISO cutoffs; E
lights the panel border; ] moves to band 3 (125 Hz); five Shift+Up
presses take it to +5.0 dB with the status line and the slider fill
updating in step; config.toml persists gain = 50 on band 3.
Adds a linked table of contents at the top so readers can jump to any
section. While in there, reflects everything that landed since the
last README pass:
- Audio is in-process (symphonia + cpal) now — mpv is video-only. Any
mention of mpv as the "local renderer" is corrected.
- Features section covers shuffle, repeat, ReplayGain, crossfade
(Crossfade vs Mixed), queue persistence, and remove/clear queue.
- Keybindings table gains z / Shift+R / g / f / Shift+F / d / Shift+C
and the Enter-jumps-on-Queue behaviour.
- New "Playback modes & effects" section with a sub-heading per feature
(Shuffle / Repeat / ReplayGain / Crossfade), each with anchor links
from the ToC.
- New "Queue persistence" section explains queue.json survival across
restarts.
- "Streams & transcoding" split into audio (symphonia) vs video (mpv).
- All-settings table gains a sub-table for the replaygain/crossfade
TOML keys.
- Install snippets mention libasound2/alsa-lib on Linux and clarify
mpv is only a runtime dep when playing video.
When the user played from the Queue screen (or any Play command), the
handler called `queue.replace(items, start_index)` which already sets
current_index at the incoming track. Then the crossfade promotion step
would unconditionally call `queue.advance()`, shifting current_index
one row past the actually-playing track.
Track whether the pending promotion should advance:
- End-approach preload (peek_next_item, next-in-queue): advance = true
- User Play with crossfade (queue already positioned): advance = false
The Next command's snap-promote reads the same flag so hitting Next
mid-Play-crossfade doesn't double-advance either.
Debug log now includes `advance=<bool>` at promotion time.
Verified: enable Mixed 8s, play a queue item at index 6, wait for the
overlap to complete. Queue title stayed (6/16); log shows advance=false.
- Delete the unused drain_ring helper and the Track.ring_capacity field
that only fed it. build_output_stream's tuple shrinks by one slot.
- Shift+F cycles the crossfade duration through 3/5/8/12 s, preserving
the current mode. `f` still toggles the mode.
- Settings row + help hint updated to advertise both keys.
Adjacent tracks now blend for a configurable window. Two modes:
- Crossfade: cosine/sine curves (outgoing^2 + incoming^2 = 1) — perceived
loudness stays constant across the overlap.
- Mixed: no curves, both tracks at full volume — sums additively for a
louder overlap window (DJ-style mix).
The SymphoniaPlayer worker now runs two decoders in parallel during an
overlap. Each track has its own cpal Stream + ring buffer; the OS mixes
them at the audio layer. Fade envelopes are applied per-frame in the
sample push path (using this track's produced_output_frames as the
progress clock).
Data
- fin-config: CrossfadeMode (Off/Crossfade/Mixed) + CrossfadeSettings
(mode + duration_secs, default 5s). #[serde(default)] on the Config
field keeps older configs loading.
- fin-player: crossfade module with fade_at() + FadePair, 6 unit tests
covering endpoint values, midpoint equal-power, sum-of-squares
invariant, and clamping.
Audio path
- Track gets `ended: bool`, `overlap_incoming/outgoing: Option<OverlapContext>`.
On EOF the current track marks itself ended but doesn't advance the queue
— that's the promotion step's job.
- Two ways an overlap starts:
1. End-approach: worker sees decoder-position + duration <= xf duration,
peeks the next queue item, loads it as `next` with fade_in, marks
current with fade_out.
2. User Play/enqueue-jump: while a track's playing and crossfade is on,
the incoming track loads as `next` instead of hard-cutting.
- Promotion fires when either the fade-in completes OR the current track's
fade-out reaches full length OR current ended prematurely.
- Manual Next snap-promotes any pending crossfade for instant response.
- SetReplayGain propagates to both tracks so a mid-overlap RG change hits
both sides.
Renderer + config
- PlaybackState.crossfade mirrored so the TUI can render without a
separate query path.
- Renderer::set_crossfade with a no-op default (Chromecast/UPnP each
handle their own transitions).
- main.rs applies cfg.crossfade on startup before playback begins.
TUI
- `f` cycles Off → Crossfade → Mixed → Off, persisted to config.toml.
Duration edit stays in config.toml.
- Player bar shows ⋈ Ns (Crossfade) or ≈ Ns (Mixed) when active.
- Settings screen row: mode + duration + hint.
- Help text updated.
Verified live: debug log confirms two crossfades executed with
length_frames=240000 (5s at 48 kHz), promoted after exactly 5 s;
Mixed mode swaps to the ≈ glyph. mpv still never spawns for audio.
82 workspace tests pass.
Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN / REPLAYGAIN_*_PEAK
tags off decoded tracks and applies a linear multiplier to samples on
their way to cpal. User picks Off / Track / Album via `g` in the TUI.
Data
- fin-config: new ReplayGainMode enum and ReplayGainSettings struct
(mode, preamp_db, prevent_clip). Config gains a `replaygain` field
with #[serde(default)] so old on-disk configs keep loading. Pure
data, no fin-player dep required.
- fin-player: new replaygain module owns ReplayGainInfo (per-track
tag values) and the linear_gain math — dB→linear, preamp additive
in dB domain, peak-based clip prevention, fallback to the other
scope when the requested one is missing. 11 unit tests cover the
numeric edge cases (clip prevention, preamp, fallback, junk parse).
Audio path
- SymphoniaPlayer stores current ReplayGainSettings in the worker,
extracts ReplayGainInfo from the first packet's metadata on each
track load, and precomputes a linear gain that the sample push
loop folds into the same multiply as user volume.
- New PlayerCommand::SetReplayGain updates the settings live and
recomputes the current track's linear gain in place.
- Renderer trait gains set_replaygain with a no-op default (Chromecast
and UPnP do their own device-side normalization).
TUI
- `g` cycles Off → Track → Album → Off, saves to config, statuses
"ReplayGain: <mode>".
- Player bar shows a compact "RG:track" / "RG:album" badge only when
active; muted when Off (row stays balanced).
- Settings screen has a new row: mode + preamp dB + clip-guard flag
+ "press g to cycle" hint. Panel grew from 7→8 rows to fit.
- Help hint updated.
Verified live: on a track without RG tags, linear=1.0 is logged and the
badge lights up when cycled; config.toml grows a [replaygain] section
that survives restart; 76 workspace tests pass.
Adds session survival plus a few overdue queue controls. The audio queue,
its shuffle/repeat state, and the exact playhead within the current track
now round-trip through cache_dir/queue.json — restore leaves playback
paused so Space resumes from where the user left off.
Playback
- RepeatMode enum (Off/One/All) on PlaybackQueue with advance()/back()
honoring it. Repeat-all wraps in both directions.
- Fisher-Yates shuffle over items after the current position via `rand`,
so shuffling mid-track doesn't jump the playhead.
- Renderer trait gains set_shuffle / set_repeat / restore /
remove_from_queue with no-op defaults for Chromecast + UPnP.
Persistence
- crates/fin-player/src/persist.rs: PersistedQueue serde struct and a
background writer thread that coalesces bursts and atomically renames
a .tmp into place.
- SymphoniaPlayer worker snapshots on every mutating command AND every
~3 s while playing so position stays fresh even if the app is killed.
- Restore filters out video items, remaps the saved index to survive
filtering, and stashes position_secs as a pending_seek applied on the
next track load.
TUI
- z: toggle shuffle. Shift+R: cycle repeat off/all/one.
- On the Queue screen: d removes the highlighted entry (keeps playback
going unless that entry was the current track); Shift+C clears the
whole queue.
- Player bar shows compact ⇄ (shuffle) and ↻/↺ (repeat) glyphs, colored
when active, muted when off.
- Queue rows: currently-playing track gets a ▶ marker in the accent
color, distinct from the cursor's ▍. Title shows (position/total).
- Help hint updated.
Startup
- fin_config::queue_path() lives in the cache dir. main::build_renderer
loads it (silent-none on missing/corrupt) and hands it to
LocalRenderer::with_persist + renderer.restore() before the TUI runs.
Verified end-to-end: 45 s of playback → quit → restart → queue restored
with shuffle+repeat state + 54 s playhead, resumes on Space; cursor and
now-playing markers move independently on the Queue screen; d removes a
non-current entry without interrupting playback.
Pressing Enter on a row in the Queue screen went through the generic
play_selected(PlayMode::PlayNow) path, which called
`renderer.play(selected_item_only, 0)` — replacing the entire queue
with just that one item.
Added a Queue-screen-specific Enter handler that snapshots the current
queue, hands it back to the renderer with the selected index as the
start position, and reports the jump. Same items, playhead moves.
pkg-config's setup hook only scans `buildInputs` for `.pc` files, so
alsa-lib landing in `nativeBuildInputs` made cpal's ALSA link resolve
inconsistently in the dev shell — the cpal build script could run
pkg-config but wouldn't find libasound.pc.
Split the devShell into a proper nativeBuildInputs (tools including
pkg-config) and buildInputs (link-time libs: libiconv on Darwin,
alsa-lib on Linux). crane's commonArgs was already correct; this only
touches the devShell.
`current_list()` returns `BaseItem`s from the Jellyfin library and is
empty for the Devices (Chromecast/UPnP) and Settings (saved servers)
screens, which render their own row types. The Up/Down/PageDown
handlers were sizing against that empty list, so the cursor never
moved.
Added a screen-aware `list_len()` helper that reads the real backing
collection for Devices and Settings, and routed the nav handlers
through it.
Previously the decoder pushed samples at the source rate (e.g. 44.1 kHz
FLAC) into a cpal stream we asked to run at that same rate — but on
macOS and some Linux setups cpal silently keeps the OS device at its
own default (48 kHz here), so playback drifted (slow or fast depending
on the direction).
Fix:
- Open cpal at `device.default_output_config()` — the only spec every
backend is guaranteed to honor.
- Decode the first packet before opening cpal so we take sample rate
and channel count from the actual AudioBufferRef spec, not from the
codec_params metadata (which lies for HE-AAC / some Ogg streams).
- Route every packet through a stateful linear-interpolation resampler
+ channel converter, so the ring buffer holds samples already at the
device's output spec.
- Simplify the cpal callback to a plain copy from the ring.
- Track "produced_output_frames" instead of source frames so playhead
position matches wall-clock exactly.
Verified locally: 44.1 kHz FLAC on a 48 kHz macOS device now plays at
real-time speed (86 s wall-clock = 85 s playhead over a two-minute run).
Local playback used to send everything through mpv. Audio now decodes
in-process via symphonia and outputs through cpal; mpv is spawned only
when a video item plays. Chromecast and UPnP still handle both.
- New SymphoniaPlayer: streaming HTTP media source, symphonia probe +
decode, SPSC ring buffer feeding a cpal output stream.
- New LocalRenderer: dispatches per QueueItem — audio→symphonia,
video→mpv — behind the existing Renderer trait.
- Preflight no longer aborts when mpv is missing; audio still works,
video prints an actionable hint.
- Player bar and help hints relabelled "mpv" → "local".
The status bar shows a keybinding cheat sheet whenever `status_message`
is `None`, but load results like `♪ 118 album(s)` were set on screen
load and never cleared, so the help was only visible for a blink before
being overwritten.
Stamp each status message with an `Instant` and treat entries older than
4s as `None` at draw time. The 200ms tick loop guarantees the redraw
that flips them back to the help text.
- linux-aarch64 now builds on ubuntu-24.04-arm (GitHub's native ARM
runner), so we drop the cross toolchain and the QEMU-flavored build.
Faster, and matches production glibc more closely.
- Both macos targets run on macos-latest. macos-latest is now
Apple-silicon; the x86_64-apple-darwin build uses rustup's cross
target (host aarch64 → x86_64) which works out of the box.
- Drop the `use_cross` matrix flag and the split cargo/cross build
steps — there's just one `cargo build` now.
Passing Limit=<omitted> to /Users/{id}/Items still let Jellyfin apply its
own silent cap on some deployments, so Music / Videos / Playlists / album
tracks / series episodes were being truncated at whatever that cap was.
Now items() paginates internally with Limit=500 + StartIndex, driven by
the server-reported TotalRecordCount. It keeps calling until either:
* a partial page comes back (we've hit the tail), or
* the collected count equals TotalRecordCount.
Same treatment for /Playlists/{id}/Items so long playlists load fully.
Also bump the http timeout to 120s so a very large single page can't
time out mid-response.
- Drop `darwin.apple_sdk.frameworks.SystemConfiguration` and `.Security`
references. `darwin.apple_sdk_11_0` was removed from nixpkgs as a
legacy compatibility stub — the current stdenv auto-links the SDK.
- Stop overriding crane's `nixpkgs` input (crane no longer exposes one),
which silences the `input 'crane' has an override for a non-existent
input 'nixpkgs'` warning.
- Regenerate flake.lock.
Chromecast:
- Content-Type follows the URL that stream_url() built — new
content_type_for_url() sniffs the extension (m3u8, mp4, mp3, flac,
opus, mkv, webm, …). Fixes the "metadata shows then stops" pattern
where we were labeling .m3u8 URLs as video/mp4 and the receiver
refused to play them.
- Queue advance fixed: get_status() is now called without a media
session id (None). Chromecast invalidates the previous session id
the instant playback ends, so polling with Some(id) returned an
empty list and we never saw the FINISHED signal. Empty entries
after having been Playing is now treated as end-of-track too.
- Idle-with-error is also treated as end-of-track so a single bad
stream can't hang the whole queue.
Streams:
- No more forced mp3/mp4 URLs. BaseItem now carries `container`
(fetched via Fields=Container,MediaSources), and stream_url(Direct)
uses it: FLAC stays .flac, MKV stays .mkv, Opus stays .opus.
Static=true keeps Jellyfin from unnecessarily transcoding.
- Both mpv and Chromecast now default to Direct; Chromecast decodes
H.264/MP3/AAC/FLAC/Opus/Vorbis/WebM/WAV natively so this is faster
and more reliable than the old HLS pipeline. `--hls` still forces
transcoding for edge codecs.
Release / packaging:
- .github/workflows/release.yml — cross-arch matrix for
linux amd64/aarch64 and darwin amd64/aarch64. Builds tarballs
(with checksums), .deb for both Linux arches, and .rpm for x86_64.
Publishes a GitHub release on `v*` tags and pushes .deb/.rpm to
Gemfury when FURY_TOKEN + FURY_ACCOUNT secrets are set.
- dist/debian/{amd64,arm64}/DEBIAN/control and dist/rpm/amd64/fin.spec
declare `mpv` as a runtime dependency so `apt install fin` /
`dnf install fin` pull it in automatically.
Docs:
- README: brew tap, apt/dnf via GitHub release AND via Gemfury repo,
Arch source install, prebuilt tarballs.
- Preview screenshot reference at .github/assets/preview.png.
TUI:
- Drop Home tab; default screen is Music. New order: Music, Videos,
Playlists, Queue, Search, Devices, Settings.
- Drill-in on Enter for MusicAlbum, Series, and Playlist; `x` plays
the whole container without drilling in. Esc pops the drill-in.
- Lists fetch every item (no `Limit`) — nothing gets truncated.
- Every row shares a fixed column layout (icon / title / subtitle /
right-aligned time) so columns line up across rows.
- Reactive search: immediate "searching…" status, 40ms debounce,
blinking cursor. Status bar highlights errors red, other messages
in primary teal.
Jellyfin API:
- Tolerant JSON parser normalizes response keys to PascalCase, so
Jellyfin 10.10 camelCase responses work alongside 10.8 PascalCase.
- Fallback from /Search/Hints to /Users/{id}/Items when hints return
no parsable results.
- Query params switched to camelCase; explicit includeMedia=true.
- 5s connect / 15s request timeouts.
Chromecast:
- Install rustls aws-lc-rs CryptoProvider at startup — rust_cast 0.20
ships rustls without a default provider and panicked on the first
TLS handshake ("Could not automatically determine the process-level
CryptoProvider").
Config / logging:
- Silence mdns_sd's cosmetic ERROR on ServiceDaemon shutdown at
default verbosity (-v bumps to warn).