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
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
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