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
Three bugs were stacking up to make session resume drop the saved offset
for streamed Qobuz tracks:
1. Position save was debounced, not throttled. Each playback tick reset
the 5s timer, so the save never fired while a track played
continuously — the persisted position froze at whatever value got
written during the first quiet window after track start (typically
~2s). Switch to throttle: fire the first call immediately, then once
per 5s window, with a tail call to flush the latest value when the
window ends.
2. togglePlay tried to apply the restore offset via a separate cmdSeek
right after cmdPlayTrack. For Qobuz streaming the audio thread's
Seek handler bails when current_audio_data is unset — streaming
uses current_streaming_source instead — so the seek was silently
dropped and the track started at 0:00. Pass start_position_secs
directly to v2_play_track. The streaming backend (legacy + V2
player) waits for enough buffer to cover the offset plus 8s
headroom, then eagerly decode-and-discards decoder output up to
the offset before engaging the audio engine — no race, no
first-pull underrun. Plex and local tracks keep the old play+seek
path; they load full audio_data so Seek works as-is.
3. The seekbar showed 0:00 from app launch until the user hit play,
even when a saved position was queued. setPendingSessionRestore
now pre-positions currentTime when positionSecs > 0 so the UI
reflects "paused at X" the moment the app opens.
Other plumbing:
- AudioCommand::PlayStreaming carries start_position_secs and
content_length (used to estimate bytes-per-second for the resume
buffer target).
- Player::play_streaming{,_dynamic} grow a start_position_secs
parameter; QConnect and api_server callers pass 0 (remote-driven
playback always starts fresh).
- cmdPlayTrack forwards startPositionSecs as a 5th optional arg.
cc
Clearing the error flag also drops any pending stream_error_message.
A fast record_stream_error -> set_stream_error(false) -> polling-tick
sequence will silently swallow the toast — that is the intent (a
recovered error is not user-actionable), but it surprises future
readers, so document it inline.
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).
set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.
Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
Extract the boolean decision out of evaluate_stream_recreate into a
pure function so the truth table can be tested without faking a
MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix:
no stream, unchanged format, format change with and without each
bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path.
The rate-mismatch detection block was duplicated between the Play and
Stream handlers, and each handler acquired thread_settings.lock() three
times in a row. Extract into one helper that reads settings once and
returns every flag the recreate decision needs.
Also drops a dead else-branch that logged "Creating initial audio
stream" — unreachable inside if stream_opt.is_some() — and the
redundant info-log that re-announced the rate-mismatch reason at
recreate time (the warn-level log inside the helper already says
"Recreating stream to avoid wrong-speed playback").
The field tracks the decoded source format, not the output stream's
hardware rate. The names are now explicit so future readers don't
conflate the two — on macOS shared mode these can legitimately differ
since Rodio resamples internally.
Address review feedback from the macOS Exclusive Mode PR.
Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.
Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.
Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.
Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.
Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
The audio-thread iteration that swaps in the next gapless track
mutates several atomics in qbz-player::player::SharedState. The
polling loop in lib.rs reads those atomics independently (one load
per field) at ~250 ms cadence and emits a PlaybackEvent that the
frontend handlePlaybackEvent then classifies. When the polling load
falls inside the swap window, it can observe an inconsistent mix:
current_track_id already advanced to the new track, but
gapless_next_track_id still pointing at that same track because the
reset hasn't run yet.
The frontend's isGaplessTransition predicate
(playerStore.ts) requires both `event.track_id !== currentTrack.id`
AND `event.gapless_next_track_id === 0`. The mid-swap event
satisfies the first and fails the second, so the event is mis-
classified as an external track change. The "Track changed
externally" branch then asks the queue manager for the current
queue track (still the previous track because the gapless flow
hasn't called v2_next_track yet) and prints the now-familiar
"Queue track mismatch or null: 212171 vs event: 216597" warn.
In 1.2.8 this race was self-recovering: subsequent ticks observed
the consistent post-swap state and isGaplessTransition fired one
tick later. dee987c1a tightened the gate with
`gaplessAttemptedForCurrent`, which gets reset to null by the
mid-swap event's external-track path; subsequent ticks then fail
the new gate and the misclassification becomes permanent for the
remainder of the track. UI stays on the previous title while the
audio plays the new one, and the queue cursor never advances —
visible to the user as "the player says it's still on Viento but
Afuera is playing".
Fix the race at the producer side: in both transition swap paths
(mainline pos>=dur detection and ALSA Direct take_source_transition),
clear gapless_next_track_id and gapless_ready BEFORE storing the
new current_track_id. With SeqCst ordering, any reader that
observes the new track_id is guaranteed to also observe the
cleared gapless slot — eliminating the inconsistent intermediate
state by construction. Cheaper than coordinating reads on the
consumer side and keeps the polling loop's lock-free design.
cc
Two regressions ride together when gapless plays a chain of more than
two tracks:
- The pos/dur snapshot at the top of the audio-thread iteration is
captured BEFORE the gapless transition swap. After the swap
updates current_track_id and duration, the local pos/dur still
point at the outgoing track, and the "approaching end" check a
few lines below fires immediately for the new track with stale
299/299-style values.
- That spurious firing arms gapless_request_armed=true and sets
gapless_ready=true. gapless_request_armed only gets cleared again
on a future transition (which will never happen because the new
track has no prefetch queued) or on engine empty (i.e. when
playback has already stopped). gapless_ready also stays sticky-
true because nothing else clears it once the false-positive fires.
- End result: the new track plays through with the gate stuck
closed, the real "approaching end" check at ~T-10s never fires,
the frontend never gets prompted to call play_next_gapless for
track #N+1, and the audio thread reaches engine empty and stops.
Fix:
- Track whether a transition swap consumed pending in this
iteration via a local transition_consumed_pending bool. Skip the
"approaching end" check while that flag is set — the next
iteration will run with fresh pos/dur for the new track and
fire the trigger correctly.
- Reset gapless_ready=false in both transition swap paths
(mainline and ALSA Direct), mirroring the existing
gapless_request_armed=false reset, so neither flag survives a
transition.
cc
The marker becomes part of the queue state snapshot the frontend
already consumes via syncQueueState(). Single source of truth — no
parallel event channel, no separate store on the frontend.
Refs #356.
cc
Removes all tracks at indices > pivot, preserving the pivot itself.
Returns the count removed. Updates shuffle_order and history to
drop dangling indices. Auto-invalidates the stop-after marker if it
referenced any removed track.
Refs #356.
cc
set_queue, set_queue_with_order, and clear unconditionally drop the
marker (queue replaced/emptied → marked track may not exist anymore).
remove_track and remove_upcoming_track conditionally clear the marker
when the removed track matches it. move_track and add_* are unchanged
— a moved track is still the same track ID, and adding doesn't touch
existing tracks.
Refs #356.
cc
Auto-advance drivers (frontend natural-end handlers) call this with
the just-finished track ID. If it matches the marker, returns true
and clears the marker; the caller pauses instead of advancing.
Otherwise returns false — caller proceeds with normal advance.
Manual skip paths must NOT call this method.
Refs #356.
cc
Add `stop_after_track_id: Option<u64>` to InternalState plus three
methods on QueueManager: `set_stop_after(id)`, `clear_stop_after()`,
`get_stop_after()`. Setting on an ID not present in the queue is a
silent no-op so the marker can never reference a phantom track.
Refs #356.
cc
set_queue and set_queue_with_order unconditionally cleared history, which
wiped legitimate plays whenever QConnect echoed a queue version bump
(reorder, shuffle toggle, peer-driven snapshot) without changing track
identity. That is the missing-tracks half of issue #316.
Instead, remap history entries by track id: each pre-existing index is
resolved to its track id via the old tracks list, then looked up in the
new tracks list. Entries whose track id is no longer present are dropped.
When the new queue has zero overlap with the old, history clears
naturally; when the queue is a pure reorder, history survives intact.
Six unit tests cover pure reorder, additions, partial removals,
disjoint replacement, and verify the remapped indices (not just rendered
ids) point at the right new tracks.
cc
StreamingConfig::from_speed_mbps inflates the initial buffer to 2 MB
on slow connections (< 1 MB/s) to absorb network jitter. On a healthy
desktop that's fine, but on a memory-constrained host the slow link
is itself a symptom: when QBZ on a Pi 3B starts allocating ~300 MB
of Hi-Res prefetch the kernel swaps, networking stalls, the speed
detector reports < 1 MB/s, and the buffer code reacts by allocating
ANOTHER 2 MB — exactly the wrong direction.
Add a process-wide AtomicUsize cap (defaults to usize::MAX, i.e.
no-op) plus a small public set_max_initial_buffer_bytes setter.
Refactor from_speed_mbps to clamp through the cap; extract the
speed-to-bytes ladder into a pure raw_initial_buffer_for_speed
helper plus a from_speed_mbps_with_cap variant that exposes the
clamping for unit tests without touching global state.
src-tauri/lib.rs's run() wires the cap into qbz-player from
qbz-core's MemoryProfile right after logging the profile. Effective
on Pi 3B (issue #331): all StreamingConfig::from_speed_mbps results
pin to 256 KB regardless of detected speed.
Re-export max_initial_buffer_bytes / set_max_initial_buffer_bytes
from qbz_player::player so external callers don't need to reach
into the private streaming_source module.
Adds 5 unit tests: ladder behavior, pass-through when cap is loose,
clamp at the LowMemory cap (the issue #331 case), no-op at the
Normal cap, and that max_buffer_bytes stays unchanged.
cc
Adds InMemorySource — a Symphonia FormatReader+Decoder backed by an
in-memory Cursor — and wires it into the cached branch of the Seek
handler so FLAC Hi-Res and MP3 seeks on LRU-cached / local-library /
offline-cached tracks jump via seek table instead of decoding every
sample from byte 0. Matches the streaming path's behavior, eliminates
the CPU spike during long seeks that was producing audible stuttering
after the streaming seek fix landed.
Falls back to the previous decode_with_fallback + skip_duration path
when Symphonia can't probe the format (rodio-only MP4/AAC container)
or when the native seek itself fails, so nothing regresses for those
formats.
Part of #335.
cc
Replaces skip_duration's decode-every-sample-from-zero loop with
FormatReader::seek(SeekMode::Accurate, SeekTo::Time) on a fresh
IncrementalStreamingSource. For FLAC this jumps via the seek table to
the nearest seek point, then decodes forward to the exact sample —
instead of grinding through millions of samples under the buffered
source's mutex (the cause of post-seek stuttering).
Added IncrementalStreamingSource::seek_to(Duration) which also resets
the decoder, clears the sample queue, and rearms the finished flag so
the source is cleanly re-primed for the iterator.
Non-streaming seeks (cache, offline-cache, local library) still use
skip_duration — they hit in-memory bytes with no lock contention, so
the linear decode is effectively free and there's no reason to change
the path.
Part of #335.
cc
Relaxes the seek gate added in 2dc1ee9e so seeks that fall inside the
already-downloaded portion of a streaming track succeed instead of being
rejected wholesale. The cutoff is approximated from download fraction ×
track duration with a 10% safety margin (0.90) to cover FLAC/VBR byte
density variance; exact for CBR.
Seeks past the watermark still reject cleanly with a warning log — they
would block the audio thread since skip_duration reads samples
sequentially. Cache, offline-cache, and local-library seeks are
untouched (they don't hit the streaming branch).
Part of #335.
cc
Tightens the streaming-seek handler to the simpler contract signed off
by the user: seek works after a streaming track finishes downloading
(buffered_source.is_complete()), but is rejected cleanly with a warning
log while the download is still in progress. Avoids skip_duration
blocking the audio thread on past-watermark seeks without adding
watermark-tracking complexity.
Cache, offline-cache, and local-library seeks are unaffected — they
reach this handler with current_audio_data Some and skip the streaming
branch entirely.
Fixes part of #335.
cc
The audio thread's Seek handler bailed on `current_audio_data.is_none()`
with a warning and returned. That field is intentionally None during
streaming playback — the bytes live inside current_streaming_source's
Arc<BufferedMediaSource>. Effect: every seek on a streaming track with
the CMAF download still in progress was silently dropped; the UI sent
the command, the player ignored it, and the seekbar snapped back to
whatever position the playback timer had reached (issue #335).
Branch the handler on the live source kind. Streaming: build a fresh
IncrementalStreamingSource from the shared buffered_source (new reader
at byte 0, same downloaded bytes — no re-download), then skip_duration
to the target. Non-streaming path unchanged.
Within-watermark seeks now work. Past-watermark seeks currently block
the audio thread while the decoder waits for more bytes; the clamp to
prevent that lands in the next commit.
cc
QueueManager::clear previously always preserved the current track;
plus bridge.play_index only mutates queue state, never triggers
audio. Result: when the user hit Clear Queue with nothing playing,
a stale track stayed in NOW PLAYING across app restarts.
- QueueManager::clear(keep_current: bool) — existing callers pin to
true (legacy behavior). New test covers the false path.
- QbzCore::clear_queue / CoreBridge::clear_queue take the flag through.
- v2_clear_queue accepts an optional includeCurrent flag from the
frontend; when true, calls bridge.stop() first so the audio pipeline
actually stops, then wipes the current slot too.
- qconnect sync path + qbzd daemon pin keep_current=true (no behavior
change).
cc
Reserved for upcoming Mixtape/Collection enqueue paths to mark item
boundaries. Existing enqueue paths populate it with the track's
album_id as a safe fallback so skip-to-item commands behave
correctly even for queues built via "play album" today.
cc
Offline-cache v2 bundles need runway for the decrypt pipeline. At 10
MB/s (software AES fallback) a 58 MB track needs 6 seconds, which
blew past the old 5s pre-emptive window and missed the gapless handoff
entirely. With AES-NI active the decrypt is ~500ms, so 10s is
harmless overkill — the decrypted bytes just sit in L1 a few seconds
earlier.
Design note: this is complementary to the AES-NI target-feature fix,
not a substitute. A too-small lead time makes gapless brittle even on
fast hardware (any hiccup blows the window); a too-large one is
wasted memory but otherwise fine. 10s is comfortable for every
realistic hardware/track-size combo we'll see.
If we ever want user-visible control, plumb a `gapless_lead_secs`
through AudioSettings and read it at the hot site.
cc
The frontend was resolving queue-panel clicks via
`actualIndex = current_index + trackIndex`, mixing the unshuffled
canonical position with the shuffle-aware display position. On shuffle
mode, the resulting index pointed at a different track than the one
the user clicked, which is exactly what issue #327 reports.
Solution:
- New `QueueManager::play_upcoming_at(upcoming_index)` resolves the
click position through `shuffle_order` + `shuffle_position` when
shuffle is on, and falls back to `current_index + upcoming_index + 1`
otherwise.
- Exposed through `QbzCore::play_upcoming_at` (emits QueueUpdated) and
`CoreBridge::play_upcoming_at`.
- New `v2_play_queue_upcoming_at` tauri command.
- `QueuePanel.onPlayTrack` now carries the `originalIndex` so the
caller knows the exact upcoming-list position the user clicked.
- `handleQueueTrackPlay` calls the new command directly — no more
client-side index arithmetic.
Also deletes the two dead `From<crate::queue::QueueTrack>` impls in
commands_v2/queue.rs (canonical queue lives in qbz-player now), per
the zero-legacy V2 policy.
Tests: two new unit tests cover the linear and shuffle-aware paths.
Session persistence is untouched — the new method only consults
existing state.
cc
- src-tauri: rand 0.10.0 → 0.10.1 via cargo update (alert #36)
- qbz-player: rand 0.8 → 0.9, `gen_range` → `random_range` (alert #38)
The underlying RUSTSEC issue only triggers for code that calls
`rand::rng()` alongside a custom logger — we don't do that anywhere,
so actual impact is nil. Bumping closes the alerts.
cc
Adds a bit_perfect_mode field to SharedState and PlaybackEvent so the
frontend can display whether the active stream is:
- DirectHardware — ALSA hw: direct access (bit-perfect guaranteed)
- PluginFallback — ALSA plughw: with software resample (bit-perfect
lost; happens when the DAC doesnt support the stream's sample
rate, e.g., the Vanatoo case in #288)
- Disabled — shared system path (pipewire/pulse/CPAL)
try_init_stream_with_backend now takes the SharedState so it can
record the mode the moment the stream is successfully opened,
whether via ALSA direct or the CPAL/Rodio fallback.
cc
In the Play and PlayStreaming handlers, the old engine's writer thread
still holds an Arc<AlsaDirectStream> when the stream_opt is dropped and
a new snd_pcm_open() is attempted. This causes EBUSY on track switches
with sample rate changes. Reorder to: stop engine -> drop stream -> open
new stream, matching the Stop handler's correct lifecycle.
cc
Show a subtle buffer progress bar in the seekbar when streaming
tracks (before they are fully cached). The bar disappears once
the track is fully buffered.
Backend: expose buffer_progress from BufferedMediaSource through
SharedState -> PlaybackEvent -> playerStore.
Frontend: render .seekbar-buffer bar behind the playback fill.
cc
When backend_type is None (auto-detect), default to SystemDefault on
non-Linux platforms so macOS gets CoreAudio device probing and sample
rate switching instead of falling through to the legacy CPAL path.
Add #[cfg(target_os = "linux")] to pipewire_backend and pulse_backend
modules. Add CpalDefaultBackend for non-Linux platforms using CPAL
default host (CoreAudio on macOS). Gate reset_pipewire_clock() calls.
Add Drop impl to PlaybackEngine so the ALSA Direct writer thread is
properly stopped when the engine is dropped (e.g. streaming timeout).
Previously, a failed streaming attempt would orphan the writer thread,
keeping the ALSA device locked and blocking all subsequent playback.
Also force HTTP/1.1 for audio downloads and add detailed error chain
logging to diagnose persistent "error decoding response body" failures
for specific tracks.
Refactor ALSA Direct engine from thread-per-source to a single
long-lived writer thread with a source queue. When one source ends,
the writer thread immediately picks up the next from the queue with
zero gap. Remove the ALSA Direct gapless rejection gate since the
engine now natively supports source queueing.