Commit Graph
111 Commits
Author SHA1 Message Date
vicrodh a7cdab8810 fix(cast): clear the audio cache when the streaming quality changes
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
2026-07-20 14:20:04 -06:00
vicrodh 075582920a fix(qobuz): 403 circuit breaker + prefetch backoff; header tooltips
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
2026-07-19 10:01:37 -06:00
vicrodh f778874fdd fix(audio): #508 ALSA-exclusive + streaming regression; flatpak ReserveDevice1 own-name
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
2026-07-16 16:29:58 -06:00
vicrodh bf29892aa4 fix(dsd): DsdErrorReport holds SharedState, not Arc<SharedState>
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
2026-07-13 21:15:22 -06:00
vicrodh 3f974752ae Merge branch 'pr/595' into integration/pr-review-smoke-2026-07-13
# Conflicts:
#	crates/qbz-player/src/player/mod.rs
2026-07-13 14:13:47 -06:00
vicrodh 83c47933cb Merge branch 'pr/594' into integration/pr-review-smoke-2026-07-13 2026-07-13 14:12:49 -06:00
vicrodh 7b0b2ed7da Merge branch 'pr/599' into integration/pr-review-smoke-2026-07-13 2026-07-13 14:12:39 -06:00
vicrodh c2e23dfa95 Merge branch 'pr/593' into integration/pr-review-smoke-2026-07-13 2026-07-13 14:12:39 -06:00
vicrodh b3d12e6b2c fix(playback): give the now-playing "source" button a per-track context
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
2026-07-13 13:11:43 -06:00
Afonso Jorge Ramos b4450965df fix(player): surface stream feeder failures to BufferWriter
CMAF and remote download tasks now error the streaming buffer on failure
so readers do not hang waiting for complete/error forever.
2026-07-13 16:16:14 +02:00
Afonso Jorge Ramos 68317f5a34 fix(dsd): treat demux I/O errors as sticky failures, not clean EOF
Only Ok(0) ends the stream cleanly; mid-file read errors set io_error.
2026-07-13 16:11:02 +02:00
Afonso Jorge Ramos 21c4817fcf fix(player): bump play generation on stop, DSD, and streaming
Non-play_track entry points must supersede in-flight play_track so a
slow fetch cannot clobber stop or a later source.
2026-07-13 13:50:06 +02:00
Afonso Jorge Ramos 542ee8050f fix(player): clear streaming loaded state on streaming setup failures
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.
2026-07-13 13:49:34 +02:00
vicrodh d0a7ded531 fix(audio): superseded plays skip the 60s initial-buffer wait; non-streaming path falls back to legacy
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
2026-07-13 05:01:22 -06:00
vicrodh f8a256c62d fix(audio): don't require pactl for PipeWire, and fall back to legacy on streaming init failure
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
2026-07-13 04:44:08 -06:00
vicrodh 42105eaf46 fix(volume): make the volume control fully fluid (de-quantize)
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
2026-07-12 20:17:12 -06:00
Victor RH 22a69a1db8 Merge pull request #585 from afonsojramos/bugfix/internal/dop-write-errors
fix(player): stop DoP writer on ALSA write failure
2026-07-12 19:40:00 -06:00
Victor RH db2f21289c Merge pull request #584 from afonsojramos/bugfix/internal/seek-engine-restore
fix(player): clear playing state when seek rebuild fails
2026-07-12 19:39:57 -06:00
Victor RH 287c3e93b5 Merge pull request #583 from afonsojramos/bugfix/internal/cancel-superseded-play
fix(player): cancel superseded play_track completions
2026-07-12 19:39:54 -06:00
Afonso Jorge Ramos 3dc54a751d fix(player): stop DoP writer on ALSA write failure
Match the PCM ALSA path: on write_dop_i32 Err, stop the writer thread
instead of logging and advancing position (which desyncs DoP markers).
2026-07-11 23:01:05 +02:00
Afonso Jorge Ramos 101490043d fix(player): cancel superseded play_track completions
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.
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos 12a278fc28 fix(player): clear playing state when seek rebuild fails
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".
2026-07-11 23:01:04 +02:00
Afonso Jorge Ramos acc033915e fix(queue): keep current_index track on clear(true)
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.
2026-07-11 23:01:04 +02:00
vicrodh 222001a9eb fix(queue): wire the Remove all after and Add to playlist row actions
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
2026-07-08 18:38:27 -06:00
vicrodh 11467fce2a fix(player): volume tick log at debug, not info
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
2026-07-07 06:48:23 -06:00
vicrodh d6b4d43043 feat(player): DSD playback engines - PCM conversion, DoP and native DSD
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
2026-07-03 17:56:38 -06:00
vicrodh 7a31988767 feat(player): show album release version in now-playing + MPRIS
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
2026-06-30 14:07:33 -06:00
vicrodh dd32f573b4 feat(slint): persist session across restarts (startup page, window geometry, playback)
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
2026-06-23 12:09:34 -06:00
vicrodh 8401258326 fix(slint): reflect now-playing and shuffle on the queue sidebar
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
2026-06-22 21:35:20 -06:00
vicrodh 2d4d4d0906 perf(cast): cache-first resolver + offline tier + cheap quality (P1+P2)
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
2026-06-18 12:13:06 -06:00
vicrodh fdf2fec3bb feat(core): shared external-stream asset resolver for Cast (Phase 0)
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
2026-06-18 09:19:38 -06:00
vicrodh 5f9fd5bdd6 feat(slint): lyrics sync engine with karaoke fill and auto-center scroll
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
2026-06-10 17:14:37 -06:00
vicrodh 5939c090ca feat(slint): Plex quality hydration on album-open + consistent quality badges (port slice 6)
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
2026-06-08 10:27:28 -06:00
vicrodh 1f1d7303ef fix(qconnect): badge clear, multi-track enqueue, shuffle WS-authoritative, cursor/history/pause
- 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
2026-06-04 21:59:58 -06:00
vicrodh c763d5b2fe fix(slint): reconcile now-playing card to the live track on seamless change
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
2026-06-01 13:29:21 -06:00
vicrodh d1ebf8ea1c feat(audio): release/refresh output-device button (#263)
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
2026-05-31 17:41:01 -06:00
vicrodh 68b6cfbcfa feat(audio): native JACK backend, functional end to end (#263 Tier 3)
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
2026-05-31 14:09:45 -06:00
vicrodh 64fb77ade1 fix(audio): resume the suspended sink after exclusive access (#263 leak)
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
2026-05-31 10:58:16 -06:00
vicrodh b139392b9b fix(audio): reset clock.force-rate on stop for all forced sessions (#263 leak)
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
2026-05-31 09:57:56 -06:00
vicrodh bcfab8e078 feat(locallibrary): album/list play + source-aware auto-advance (playback Stage 2)
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
2026-05-29 00:50:40 -06:00
vicrodh 022b3e46e6 Merge pre-release into slint-mvp (1.2.14 backend catch-up)
One-way backend catch-up bringing the 1.2.14 maintenance work into
slint-mvp: audio (#470/#449/#466), player/offline/prefetch (#467),
qobuz retry/backoff, credential-store + image-cache refactor, dep
bumps (cpal 0.17.3 / alsa 0.11 / tokio / ashpd / tower-http), and the
qbzd daemon removal.

Conflicts resolved:
- qbz-audio settings.rs: keep slint-mvp stream_first_track/buffer
  defaults (1/2, opt-out); adopt #470's CREATE-TABLE seed removal
  (row is seeded with backend_type below).
- crates/Cargo.toml: keep qbz-slint workspace member (qbzd removed
  upstream).
- crates/Cargo.lock: regenerated (no ratatui/qbzd; cpal/alsa bumped).

cargo check --workspace green.

cc
2026-05-25 15:12:58 -06:00
vicrodh a0051ab513 fix(audio): default to shared System output and resolve Auto to a concrete backend (#470)
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
2026-05-24 21:13:11 -06:00
vicrodh ee4c889c55 fix(player): rebuild output stream on sample-rate change for all backends (#449)
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
2026-05-23 08:35:38 -06:00
vicrodh e4fa90c1b2 feat(prefetch): adaptive throttle based on observed bandwidth + underrun signal
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
2026-05-22 21:46:07 -06:00
vicrodh 3a6aae19a3 feat(player): add prefetch and gapless cache helpers
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
2026-05-19 03:01:22 -06:00
vicrodh 712529070d feat(core): add uncapped queue snapshot and track-favorite helpers
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
2026-05-19 01:30:59 -06:00
vicrodh 7b84fa9a7b feat(qbz-player): L1+L2 playback cache for instant replay
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
2026-05-19 01:25:08 -06:00
vicrodh 655a90f400 fix(qbz-player): seek bar and cache overlay for CMAF streaming
The CMAF path passed duration_secs: 0 into play_streaming_dynamic, and
the streaming position timer clamps current_position to the duration
it was given — so elapsed, remaining, progress and auto-advance all
froze at zero. Duration is now derived from the CMAF segment table's
per-segment sample counts.

PlaybackEvent also gained buffer_progress (the streaming buffer fill
the audio thread already tracked but never surfaced); the poll loop
feeds it to the seek-bar cache overlay.

cc
2026-05-19 01:10:03 -06:00
vicrodh 74ab627c40 feat(qbz-player): use CMAF streaming in play_track with legacy fallback
Port the CMAF streaming playback path from the Tauri v2_play_track /
v2_cmaf_stream reference into the qbz-player core crate so any shell
gets CMAF transparently.

play_track now tries qbz_qobuz::cmaf::setup_streaming first: it fetches
only the init segment, derives sample rate / bit depth / total FLAC
size, estimates speed from init_fetch_ms, starts playback via the
existing play_streaming_dynamic, and spawns a background task that
fetches + decrypts + pushes audio segments. On setup failure it falls
back to the legacy get_stream_url_with_fallback + download_audio +
play_data path, which is kept unchanged.

The new private cmaf_stream_segments method ports the v2_cmaf_stream
fetch/decrypt/push loop: FLAC header first, per-segment reqwest get,
parse_segment_crypto, decrypt_frame for encrypted frames, push_chunk
to the streaming buffer, complete() at the end. The L1/L2 cache
parameters and cache calls from the reference are intentionally
omitted (separate later phase).

play_track's public signature and the bit-perfect output path are
unchanged.

cc
2026-05-19 00:36:03 -06:00
vicrodh 29b6c20547 build(qbz-player): add qbz-cmaf path dependency
Required for the CMAF streaming playback path migration into the
player core crate.

cc
2026-05-19 00:35:49 -06:00