The system_capabilities profiler (Normal vs LowMemory, built for #331)
lost all its consumers in the Tauri removal; 2.0.2 shipped it as dead
code and qbzd got OOM-killed (exit 137) on 1 GB Pi hosts at track
transitions. Phase A wiring:
- Frontends read memory_profile() at startup and push it down via the
new player memory_tuning (initial-buffer cap, L1 budget, low-memory
gate) before the Player is created.
- L1 audio cache sized from the profile (400 MB Normal / 50 MB
LowMemory; was hardcoded 400 MB).
- Oversized tracks (>L1/4 = 12.5 MB on LowMemory) skip the promotion
clone and the parallel CMAF RAM accumulator entirely; the finished
track is streamed to the L2 disk cache in 1 MB chunks straight from
the playback buffer — no second in-RAM copy. Gapless degrades to a
small gap for those tracks (a gap beats an OOM kill).
- Stream buffer preallocates total_size up front (try_reserve_exact,
1 GiB cap, dynamic fallback) — no more ~8 doubling reallocs + memcpy
per track (weak-CPU relief).
- New memory-pressure watchdog (15 s): critical pressure evicts the
L1 (L2 disk kept) and halts prefetch; low pressure pauses HiRes
prefetch (downgrades to Lossless). Spawned by both frontends; qbzd
aborts+joins it for the #521 shutdown ordering.
- Prefetch honors the profile (depth cap, HiRes->Lossless downgrade).
Normal-profile behavior is byte-identical (every behavioral change is
gated on the LowMemory class; profile values equal the old hardcodes).
Phase B (follow-ups): tempfile spill of the live stream buffer for
giant tracks, POLLERR flood rate-limit, desktop kick_prefetch halt
check, L2-read on the play_track cache-hit path.
km
- Sidebar + playlist manager sorts gain both directions with a caret on
the active row (re-select flips); 'recent' keeps the API order
(newest-first — the old reverse was the #657 bug).
- Import now inserts the new playlist optimistically and bounded-retries
until the API lists it (read-after-write lag forced a manual refresh).
- Play later: tracks land at the end of the manual block (after every
play-next/later, before the source resumes); Play next and Add to
queue are untouched. The queue sidebar shows the section divider
(Next in queue / Next up) with drag-math compensation, and the context
menu shows the landing position of each queue action.
km
When a track ends before its gapless prefetch completes, 'track
finished (engine empty)' sets thread_state.is_playing=false, and the
late PlayNext still plays (the engine's append re-opens the writer
gate) but nothing restores the player-level flag: the position monitor
freezes, no next gapless arm, no QConnect reports, the queue cursor
stays on the finished track, and playback dies silently at the next
end. The PlayNext handler now captures engine.empty() before appending
and restores is_playing when the append landed on an empty engine —
a user-paused engine still holds its source (not empty), so manual
pause is never resumed by this.
km
Without this the #638 fix is intermittent: the L1/L2 audio cache is
keyed by track id alone with no quality dimension, so a track warmed at
the old tier keeps serving local plays and casts at that tier until it
ages out. Clear both tiers the moment the Settings dropdown lands on a
different key (no-op re-selects do not clear).
The clear is fire-and-forget, deliberately not deferred: deferring
would let the next play after a change still deliver the old tier,
which is exactly the intermittency this exists to remove. Known cost,
accepted: an armed gapless handoff can drop (one possible audible gap
on an explicit, rare user action) and warmed bytes for local playback
are discarded too. Logged at info so a reported gap is attributable.
Protected-crate note: the qbz-player change is one pub fn on Player
that purely delegates to the already-existing AudioCache::clear()
(which clears both L1 memory and L2 disk). No engine logic, no contact
with playback_engine.rs or any sample-rate handling.
cc
Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.
- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
short-circuit without touching the network, with an exponential cooldown
and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
translated across all 8 locales.
cc
Store the CMAF duration at command-accept so the seek bar and progress can't
freeze at 0:00, backfill it on the Resume rebuild, and clear + surface the
zombie stream-create-failure path. Degrade every non-priority device-reservation
contention error (only a higher-priority holder stays fatal) so a sandboxed
D-Bus RequestName denial can't block bit-perfect playback. Honor the
stream_buffer_seconds floor in the dynamic CMAF path and wire the network
throttle (record_segment_bandwidth / record_underrun / prefetch cap) to tame the
#591 underrun loop. Grant --own-name=org.freedesktop.ReserveDevice1.* in the
repo flatpak manifest so WirePlumber releases the card cleanly.
cc
The merged #599 typed DsdErrorReport.state as Arc<SharedState>, but the audio thread passes thread_state — a SharedState, which is itself a #[derive(Clone)] handle of Arc<Atomic..> fields (a clone already shares the state). The extra Arc broke the qbz-player build in the combined pre-release (E0308 at the four DoP / native-DSD wrap sites), failing the test-crates CI. Switches the field + new() to SharedState and drops the now-redundant Arc::new in the two DSD tests. Verified with scripts/cargo-test.sh (workspace, no UI): all crates green.
cc
The "playing from" button read a single global context set once at play time;
refresh_now_playing_meta never republished it, so it went stale across track
changes (previous track's album) and single-track plays fell back to the
current track's album instead of the playlist it came from. Add per-track
context_kind/context_id to QueueTrack (serde-default None, back-compat), stamp
the real container at every play path (album/artist/playlist/label), and
republish the context from the current track on EVERY track change (gapless
included) — never cached. open-context routes all stamped kinds.
cc
Buffer timeout, engine creation failure, and incremental source failure
no longer leave has_loaded_audio true with no engine; each records a
user-visible stream error.
Follow-ups to #591/#592. The streaming initial-buffer wait blocked the audio thread up to 60s even when the play had been superseded by a newer track, which fed the stop/restart-loop feel; play_generation moved onto SharedState and threaded through PlayStreaming so a superseded wait exits in ~50ms (the 60s timeout stays for genuine slow networks). And the non-streaming Play path gained the same legacy-CPAL fallback the streaming path got, instead of dead-ending on backend-init failure (macOS keeps surfacing the error). Control flow only; sample path untouched.
cc
Two stacked defects broke playback under sandboxes (Snap/Flatpak/minimal .deb) in 2.0.1: PipeWireBackend::is_available() probed only `pactl info`, which those images don't ship, so PipeWire reported unavailable even though it was running (native pw-dump enumeration succeeded in the same log). And the streaming stream-init path had no legacy fallback (unlike init_device), so a failure dead-ended into a stuck spinner until the 45s watchdog. is_available() now checks the pipewire-0 socket + pw-dump (both time-bounded, fail-fast) with pactl kept as a last fallback; the StartStreaming path falls back to legacy CPAL on non-macOS. Sample path untouched (no gain/resample/rate change). Addresses #591/#592.
cc
Volume snapped between 1% steps from two integer quantizers. UI: the volume sliders now use a 0..1000 scale so the shared int slider's round() lands on 0.1% (imperceptible). Runtime: the player volume atomic widened from integer-percent AtomicU64 to f32-bits AtomicU32 (same idiom as normalization_gain) so a continuous value no longer re-snaps on track restart / device switch / gapless. Bit-perfect untouched: 1.0f32 round-trips its bits, no gain multiply or ramp added to the protected ALSA-direct / DoP / JACK paths.
cc
Bump a play generation on each play intent so a slow CMAF/legacy fetch
cannot apply AudioCommand::Play after the user has already started another
track. play_data still bumps generation for standalone offline/cache calls.
After current_engine.take(), seek error paths used to return with no
engine while is_playing stayed true. Abort now sets stream_error and
stops the playing flag so the UI cannot show silent "playing".
truncate(1) always preserved tracks[0], so Clear queue mid-album left the
wrong now-playing row. Keep the track at current_index and remap history
by id so removed rows do not leave stale indices.
The per-track context menu in the Queue sidebar had two placeholder items
with no click handler: "Remove all after" and "Add to playlist". Wire both
end to end.
- QueueState gains remove-all-after(int) and add-to-playlist(int), resolved
from the page-local row index like remove-upcoming (search-filter aware).
- Core/player gain remove_upcoming_after(upcoming_index): truncates the
upcoming list in PLAY order (shuffle-aware, reuses remove_upcoming_track),
clears the stop-after marker if it lands in the removed range, and emits a
single QueueUpdated. The pre-existing remove_after (absolute-index, not
shuffle-aware) was dead code and stays only for its unit coverage.
- Add to playlist reuses the save-as-playlist picker handoff seeded with the
single row's track id.
cc
A slider drag delivers dozens of SetVolume commands per second and
each logged an info line from the audio thread — 758 of 1025 lines in
a #555 field log. Log-level change only; the audio path is untouched.
cc
Conversion rides the existing play_streaming path as an exact-size
88.2 kHz/24-bit WAV. DoP and native DSD (Phases 2-3) get a dedicated
direct engine: additive S32/DSD_U32 ALSA stream constructors and a
verbatim i32 write path (no f32, no gain), a writer thread that feeds
0x69 DSD silence on pause and pads stream teardown, and a source queue
for gapless DSD. AudioSettings gains dsd_mode (convert|dop|native,
default convert). Delivery resolves per track: user mode + ALSA direct
backend + stereo + rate support, everything else converts. Seek is
ignored in direct modes and the pause suspend path is gated off there.
cc
The now-playing bar, MPRIS metadata, and the desktop notification showed
the clean album name; for a re-edition (e.g. Octavarium (2009 Remaster))
the variant was lost. Carry the album-level version on QueueTrack
(populated on the album-play path) and append it to the displayed album.
Last.fm scrobbling keeps the CLEAN album name so editions don't fragment
stats. New album_version field defaults to None on every other queue source.
cc
Three restart-persistence features built and tested together (they share
ui_prefs + the enter_shell restore sequence, so kept as one change):
- Startup page: persist + restore the last SAFE top-level view via the
canonical apply_entry dispatcher (loads data, not a blank view). Wired to
Settings > Appearance > "Where you left off"; ui_prefs startup_page/last_view.
- Window geometry: persist logical size + outer position and restore them at
launch, clamped to the current monitor so a smaller/disconnected display can
never open an oversized or off-screen window (winit Resized/Moved hook).
- Playback session: new session_persist module wiring qbz-app's SessionStore.
Saves queue + current track + volume + shuffle/repeat + position on
track-change / pause / ~5s tick / exit (incl. hide-to-tray and tray Quit);
restores the queue PAUSED at launch. Position resume on first play threads
the player's existing start_position_secs (no protected-audio change beyond
passing that param). Gated on the persist_session / resume_playback_position
playback prefs; opened per-user on login + boot-restore + offline entry.
Known issue: the seek bar/timer do not visibly show the resume position until
play is pressed (the audio resume itself works); marked in code for follow-up.
cc
The sidebar Queue reads the same core pointer as the now-playing bar,
but several paths refreshed the bar without the sidebar, so the panel
showed a stale NOW PLAYING track and the NPB shuffle button looked dead
(the reordered up-next was never re-pushed). Pair refresh_sidebar with
the now-playing refresh in toggle_shuffle, the local-tracks inline
shuffle, the gapless seamless-change (regardless of moved), and the Plex
quality patch. The shuffle engine itself was already correct.
cc
The cast was slow because the resolver always did a full CMAF download
(~10s for a 190MB hi-res FLAC) and never read the cache — unlike the fast last
Tauri build, which resolved bytes cache-first.
- P1 cache-first: Player::fetch_for_external_stream now checks L1 (in-memory) ->
L2 (on-disk playback cache) before any download. The gapless prefetch already
warms the next 2 tracks at UltraHiRes while casting, so next/advance/replay are
now instant; only a cold track pays the download.
- Offline tier: CoreBridge::fetch_for_external_stream_resolved gains offline +
sink params and tries resolve_offline_bytes (local CMAF decrypt, no network)
before the network path — a downloaded track casts fast with no connection
(the same local-segments+decrypt path offline playback uses). Replaces the
hard "Qobuz cast needs a connection" gate (offline-cached tracks now work).
- P2 quality label: derived from the track's catalog metadata
(bit_depth/sample_rate/hires) in cast_track — no extra API call, consistent
whether bytes came from cache, offline, or network.
cargo check -p qbz-slint: clean.
cc
Add a frontend-agnostic API to resolve a track's bytes + MIME + actually-
delivered quality for an external renderer (Chromecast / DLNA), so the Slint
Cast port does not need an improvised downloader (ADR-006).
- qbz-models: ExternalStreamAsset { bytes, content_type, quality, duration,
origin }, StreamQualityInfo (kHz-normalized via from_raw, handles the
kHz-vs-Hz endpoint ambiguity), AssetOrigin.
- qbz-qobuz/cmaf: download_full_with_quality(_progress) returns the resolved
format_id/sampling_rate/bit_depth alongside the FLAC bytes; download_full is
now a thin wrapper (no duplicated segment assembly).
- qbz-player: Player::fetch_for_external_stream — CMAF-first (FLAC, accurate
quality) with legacy stream-URL fallback; never reads the local playback
cache (casting attempts the best tier and must report the real delivered
quality); never serves an empty content type (external_content_type helper).
- qbz-core: CoreBridge::fetch_for_external_stream_resolved wrapper.
Decision D3 (attempt UltraHiRes first + surface delivered quality) is satisfied
by passing Quality::UltraHiRes and returning StreamQualityInfo. Unit-tested:
content-type fallback by format id, sampling-rate unit normalization.
cc
S4 of the lyrics epic: ms-precision position source, 30Hz sync engine,
word-anchored karaoke fill, auto-centered ladder scroll, QConnect peer
following.
- qbz-player: read-only SharedState::current_position_ms() derived from
the existing playback_start_millis/position_at_start anchors (Q1; no
stream/seek/format path touched) + unit test
- qbz-lyrics: new sync module porting the Tauri active-line math (binary
search active index, per-line progress with the 0.99 snap) plus the
word-anchored clip fraction for Qobuz wsync lines (char-proportional
word width approximation, Q2); unit tests
- qbz-slint: lyrics_sync engine - adaptive slint::Timer (33ms while
panel open + synced + playing, 250ms gate polling otherwise),
equality-guarded pushes, fill animation zeroed across discontinuities
(line change / backward seek); kick() on doc commit and panel open
- QConnect (Q7): the poll loop controller branch publishes the raw
renderer anchor; the engine extrapolates it per tick so lyrics follow
the peer (Tauri freezes); local fallthrough clears the anchor
- LyricsLinesView: karaoke fill via clipped bright overlay text over the
dim base (rect-clip approximation, D5); auto-center scroll from
layout-relative active-row geometry, smooth for <=2-line moves and
instant for jumps/initial sync, animated via a separate scroll-target
property so user wheel input is never animated
- LyricsState: line-progress + fill-anim-ms properties
cc
Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
refresh of all badge surfaces — album-card grid (re-read aggregate + patch
every model + the raw filter source), album-detail header + rows, now-playing
stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).
Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
(tier, detail, tooltip); the album card, album-detail header and track rows
all route through it (kills the per-surface duplication that produced
"96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
"24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.
cc
- badge: gate is-remote on transport_connected so it clears on every session-end
path (transport drop / watchdog / reconnect-exhausted), not only toggle-off
- multi-track: route a whole album/playlist/favorites enqueue to the peer in one
QueueAddTracks/QueueInsertTracks (all-or-nothing admission), not single-track only
- shuffle inbound: SetShuffleMode renderer arm is flag-only (set_shuffle_flag) so the
renderer never invents a local order; the cloud's echoed order is authoritative
- shuffle outbound: send shuffle_seed + shuffle_pivot_queue_item_id (the cloud needs
the seed to build the order); hoist resolve_qconnect_shuffle_pivot to qconnect-app
- start index: resolve_remote_start_index validates the track_id before trusting a
qid, so a freshly-pushed album no longer inherits a stale cursor (wrong NowPlayingBar track)
- history: play_index records history only when actually moving, so the current track
is no longer duplicated in the History tab
- pause-to-peer: resolve current_queue_item from the core-aligned cursor instead of the
stale renderer snapshot, so pausing an iOS renderer (reports null qid) is accepted
cc
The poll loop's seamless-transition branch guessed whether a track-id change
while still playing was a gapless hand-off via a peek_upcoming(1) == track_id
heuristic. When that heuristic missed (queue pointer lagging the engine), the
meta was never refreshed: the seek bar kept advancing while the title/art/album
stayed on the previous track (the reported now-playing populate bug).
Replace the heuristic with reconciliation to what is ACTUALLY playing. New
frontend-agnostic primitive QueueManager::sync_current_to_id(id) moves the queue
pointer to the track the engine advanced to (recording history + keeping the
shuffle cursor aligned) and reports whether it moved; QbzCore wraps it and emits
a queue update only on a real move. The poll loop now syncs to the live track_id
and always refreshes the card, doing the record/sidebar/prefetch bookkeeping only
on a genuine advance (a manual play already moved the pointer, so it is idempotent).
cc
After bit-perfect ALSA Direct grabs a DAC exclusively (hw: + D-Bus
reservation), the device stays sequestered and is invisible to PipeWire,
so switching back to the PipeWire backend can't see it without restarting
the app. Tauri had a refresh affordance for hot-plugged DACs; this is the
refresh plus an explicit release, in one button.
Backend: Player::release_device() / AudioCommand::ReleaseDevice tears the
stream down immediately (cancelling the deferred drop), freeing the hw:
grab + its reservation, then un-suspends the parked sink and resets any
forced graph clock (both self-gating). It does NOT reopen, unlike
reinit_device which would re-grab the device.
UI: a refresh-cw icon button beside the Output device select, propagated
through the settings callback chain to handle_release_device, which
releases, waits for PipeWire to reclaim, then re-enumerates so a freed or
hot-plugged DAC reappears without an app restart.
cc
Completes the JACK output on top of the foundation (fbc03f41):
- StreamType::Jack + the player dispatch (backend==Jack -> JackStream).
- PlaybackEngine::Jack: a feeder thread (mirrors AlsaDirect) that resamples each
source to the JACK graph rate (UniformSourceIterator, stereo) and writes it to
a lock-free f32 SPSC ring (ringbuf); wired at the 4 engine-creation sites.
- jack_backend.rs: f32-native ring (fixes a byte-alignment white-noise bug) plus
auto-connect of qbz:out_FL/FR to the physical playback on activation, so audio
reaches the hardware without a patchbay (a user may re-patch freely).
- Re-enabled the available_backends push (JACK is now offered in the selector).
Verified on hardware: select JACK -> QBZ registers as client 'qbz' with stable
ports qbz:out_FL/FR (client.api=jack) -> music plays through the DAC, resampled
to the graph rate (NOT bit-perfect; BitPerfectMode::Disabled, as designed). The
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
Slice 3.2 (pending, separate): JACK<->passthrough/exclusive mutual-exclusion UI,
the bit-perfect-OFF note, runtime graph-rate-change callback, mono/multichannel.
cc
Exclusive-mode device acquisition suspends the default PipeWire sink
(pactl suspend-sink @DEFAULT_SINK@ 1) on the EBUSY-retry and CPAL-exclusive
paths, but never resumed it — leaving the rest of the system on a suspended
sink after exclusive playback.
Add suspend_default_sink_for_exclusive() (resolves + records the suspended
sink name) and resume_suspended_sink() (resumes that exact name; PipeWire sink
names are deterministic, so resume-by-name survives a vacate/re-create). The
player resumes on the pause-suspend teardown — after the exclusive stream is
actually dropped, never while the device is still held. Self-gating: a no-op
unless QBZ suspended a sink, so it is safe to call on every stop.
Does not touch the bit-perfect path. Verified no regression on hardware:
exclusive mode plays through the DAC (LED-confirmed) and system audio uses the
onboard sink (not stuck suspended). The suspend/resume paths themselves
(EBUSY race / CPAL-exclusive fallback) are rare and were not exercised in that
test; the fix is correct-by-construction and cannot affect bit-perfect.
cc
QBZ forces the global PipeWire clock for any non-locked PipeWire stream, but the
stop/pause-suspend reset was gated on pw_force_bitperfect — which is only set in
DAC-passthrough mode. So a plain PipeWire user (no passthrough) forced the graph
clock on play and never reset it, leaving the whole graph force-clocked after
stopping (a stale clock.force-rate that resamples every other app).
Track whether QBZ actually wrote the force (CLOCK_FORCE_APPLIED) and make
reset_pipewire_clock self-gating: it only resets a force WE applied, never
clobbering another app's forced rate. The player's two reset call sites (Stop,
pause-suspend) now call it unconditionally.
Verified on hardware (Cambridge USB DAC, PipeWire + passthrough off): a 192kHz
track forces clock.force-rate=192000; on pause-suspend the clock returns to 0
(previously stayed stuck at 192000).
cc
A Local Library album, or the Tracks list, now plays through with auto-advance
across mixed local/offline sources.
The one change to the shared playback path is additive and provably safe for
Qobuz: play_audible gains a guard at the top — when the CURRENT queue track's
source is "local" (and its id matches), it plays the file from disk via
play_local_file_audible (read -> player.play_data + CUE seek); otherwise it
falls through to the EXISTING offline/Qobuz tier-walk, byte-for-byte unchanged.
Qobuz/offline queue tracks never carry source "local", so their behavior cannot
change. Auto-advance, next/previous and play-all all route through play_audible,
so they become source-aware for free. current_track() releases the queue read
lock before play (no nested lock / deadlock).
- qbz-player: QueueManager::current() (additive).
- qbz-core: QbzCore::current_track() (additive).
- qbz-slint playback.rs: source-aware play_audible + play_local_file_audible
(extracted) + play_local_tracks_now + play_local_album
(get_album_tracks_metadata) + play_local_tracks_from (search_with_filter);
replaces the Stage-1 single-track helper.
- main.rs: album card "play" -> play the album; Tracks row "play" -> play from
that row down the searched list. (play-next / queue still stubbed.)
Built green (cargo check). Local-file gapless isn't wired (local tracks aren't
prefetched, so local/mixed transitions use the simple track-ended -> next path);
Qobuz gapless is unchanged. Runtime smoke-test pending: (1) Qobuz streaming
still plays + advances, (2) an offline album advances, (3) a local album/list
advances.
cc
OOTB playback froze (seekbar advancing, no audio) on hosts where the
configured backend was not usable — e.g. PipeWire as the Linux default but
`pactl` absent (minimal Debian/Ubuntu). "Auto" (a null backend_type) fell
through to a legacy CPAL path that forced the track rate onto the shared
default device, producing a suspended/silent stream and leaving a
process-wide stuck audio handle that survived Reset.
- Add a cross-platform "System" backend (CpalDefaultBackend now on Linux
too): opens the OS default output at its negotiated rate with rodio
resampling and an explicit ~100ms CPAL buffer (MixerDeviceSink has zero
internal buffering, so the bare default underran and stayed suspended).
Shared, no exclusivity, no `pactl` — mixes with other apps like any
normal player.
- Make System the OOTB default and what Reset lands on; seed it on first
run. Drop the per-startup #375 NULL->PipeWire backfill migration.
- Resolve a null backend_type to System in the player so the legacy CPAL
path is never reached (covers legacy installs and headless callers).
- "Auto" in Settings is now a resolve-and-set action: it picks the best
available backend (PipeWire if available, else System), stores that
concrete value and updates the dropdown + device picker, so backend_type
is never null.
- Explicit PipeWire (DAC passthrough) and ALSA Direct (exclusive) configs
are untouched and remain bit-perfect.
cc
The stream-recreate decision only forced a rebuild on a decoded-format change
for bit-perfect backends (DAC passthrough, ALSA Direct, CoreAudio exclusive).
The default/PipeWire path relied on Stop dropping the stream to get a fresh one
at the new rate; once that drop was deferred to avoid a track-change click
(e93fcaec), the PipeWire node stayed locked to the first track's rate for all
subsequent tracks — a regression vs 1.2.10. Make any decoded-format change
rebuild the stream on every backend; same-rate tracks still reuse it, so the
click fix is preserved.
Verified on hardware via pw-top: PipeWire without passthrough now switches rate
per track; PipeWire with passthrough and ALSA Direct unchanged (no regression).
cc
Recovered from 63ecc324 (authored on the native-port branch, never merged to
pre-release). Backend-only, no Slint. Scales the prefetch cap down on slow
links and during audio underruns, and exposes the per-segment download signal
the offline detector reuses as a positive liveness override (#467).
cc
Add three public Player methods that drive gapless playback without
touching the audio thread:
- prefetch_into_cache: download an upcoming track fully into L1/L2
cache so it later plays via play_data (cache hit) rather than the
streaming path. CMAF download_full first, legacy full download as
fallback. No-ops on streaming_only / already-cached / in-flight.
- is_track_cached: cheap L1/L2 membership check for the controller.
- fetch_for_gapless: resolve a track's bytes for a gapless handoff
via L1 memory then L2 disk then CMAF download_full. Ports the
L1/L2/CMAF tiers of Tauri's v2_play_next_gapless.
All three reuse existing primitives (audio_cache, download_audio);
the protected audio-thread command handlers are untouched.
cc
QueueManager::get_state_full() returns the upcoming and history lists
without the 20/10 caps that get_state() applies, so clients can
paginate the upcoming list. QbzCore exposes get_queue_state_full()
plus favorite_track_ids() and set_track_favorite() for reflecting and
toggling per-track favorite state.
cc
play_track now checks the qbz-cache two-level cache (L1 memory ~400 MB
+ L2 disk ~800 MB) before fetching: a hit replays through play_data,
skipped only when the cached copy is a lower quality than requested
(cached_quality_below_requested, ported from Tauri). After a play the
track is stored — the CMAF path accumulates the assembled FLAC while
streaming, the legacy path stores the download. streaming_only
suppresses the write.
cc