The renderer-engine seam: a 16-method trait both frontends implement with
zero-cost forwards to their QbzCore/Player, so the hard-won renderer
orchestration (echo-seek rejection, cursor align, queue materialize, shuffle
deferral, load dedup window) can move UP into qconnect-app and be written once
against the trait instead of re-derived per frontend (slice 6, Phase T 1-2).
- Adds qbz-player as a qconnect-app dep, for PlaybackState (read by the echo
gate + should_reload; it is not in qbz-models and not re-exported). qbz-player
does NOT depend on qconnect-app -> no dependency cycle.
- The two protected bit-perfect seams (play_data / play_streaming_dynamic) are
reached ONLY through the high-level start_track_stream method; BufferWriter +
reqwest + the detached feeder spawn stay impl-side and never cross the crate
boundary. play_data / play_streaming_dynamic are never modified.
No impls, no logic moved yet -- pure foundation. qconnect-app 38 green, src-tauri
cargo check green.
cc
Slice 5 moved the transport subscription INTO the spawned run_session_loop
(self.subscribe_transport_events()), which races the WS handshake:
NativeWsTransport::connect spawns the WS loop and returns WITHOUT awaiting it,
and tokio broadcast has no replay -- so the initial Connected / Subscribed /
SessionEstablished / SESSION_STATE events could be sent to zero receivers and
silently dropped before the spawned task is first polled (a lost
SessionEstablished leaves the UI stuck Connecting; a lost SESSION_STATE skips
the deferred renderer join). The 42 wire tests miss it: they subscribe before
connect against a non-self-driving InMemoryWsTransport.
Restore the prior guarantee -- subscribe synchronously in connect() right after
app.connect() returns and before the spawn / any further await, and pass the
broadcast::Receiver into run_session_loop. Found by adversarial review.
qconnect-app 38 green, src-tauri qconnect 42 green.
cc
Move the spawned transport event-loop body out of the Tauri adapter into the
shared QconnectApp::run_session_loop, so both frontends inherit takeover
arbitration / deferred renderer-join / reconnect resync / Lagged re-ask. The
adapter-coupled seams go through a new SessionLoopHost trait:
- update_lifecycle -- Tauri gates on a live runtime + dedups, emits via sink
- bootstrap_after_reconnect
- deferred_renderer_join -- R2: the CoreBridge track-duration read stays adapter-side
- on_reconnect_exhausted -- R3: Exhausted + last_error + runtime=None teardown +
the raw qconnect:status_changed emit; returns whether the loop should break
- on_loop_error -- the raw qconnect:error channel
TauriSessionLoopHost implements it over the existing service handles; connect()
is reduced to: build host -> spawn(app.run_session_loop(host, idle_retry)). The
diagnostic/lifecycle/resync emits still route through self.sink, so the Svelte
wire surface is byte-identical. hex_preview + queue_payload_track_preview + the
re-ask budget const relocate with the loop; the dead Tauri copies are removed.
R3 (QconnectRuntime/JoinHandle ownership, #358 latch, disconnect teardown) stays
adapter-side.
qconnect-app 38 green, src-tauri qconnect 42 green (the byte-identity fence).
cc
Relocate the transport-loop helpers out of the Tauri adapter into QconnectApp
methods + qconnect-app free items, so the shared session loop (slice 5.2) and a
future Slint adapter use one definition:
- QconnectApp::capture_session_state_takeover_input (locks self.sync)
- QconnectApp::clear_pending_if_matches (locks self.state)
- QconnectApp::send_set_active_renderer -- inline {"renderer_id": i32} payload,
byte-identical to the prior QconnectSetActiveRendererRequest serialization
- QconnectApp::ask_for_active_renderer_state / QconnectApp::ask_for_queue_state
- SessionStateTakeoverInput struct + queue_payload_track_preview (pure free fn)
Deleted the service.rs free fns (capture_session_state_takeover_input,
send_set_active_renderer_via_app, ask_for_active_renderer_state,
ask_for_queue_state, queue_payload_track_preview, clear_pending_if_matches) and
the SessionStateTakeoverInput struct; rewired every call site (the loop +
deferred_renderer_join + the reconnect/lagged paths) to the methods. The request
structs serialize identically to the inline json!, so the wire is unchanged.
qconnect-app 38 green, src-tauri qconnect 42 green.
cc
The Tauri event sink's apply_session_management_event is now a thin delegator:
it resolves this device's identity (resolve_local_identity) and calls
QconnectApp::apply_session_management_event, then runs the post-lock CoreBridge
work the returned SessionApplyOutcome asks for in the exact prior order
(loop mode -> local-playback handoff -> projection -> freeze -> watchdog).
Freeze + watchdog route through app.freeze_active_renderer_projection /
app.arm_renderer_watchdog (same shared sync Mutex, same epoch guard).
Deleted from the Tauri adapter (now solely in qconnect-app): the locked
session-management match, bump_watchdog_generation, the inline 12s watchdog
spawn, freeze_active_renderer_projection_to_unknown, and the duplicate
ensure_session_renderer_state / sync_session_renderer_active_flags. ensure_* is
re-exported from qconnect-app for service.rs; refresh_local_renderer_id wrapper
is replaced by resolve_local_identity (returns the injected LocalIdentity), and
the 3 renderer-id-resolution tests call the pure qconnect-app fn with it.
Byte-identity fence holds: src-tauri qconnect 42 green, qconnect-app 38 green.
cc
Relocate the session-management apply + renderer-liveness logic into
qconnect-app so both the Tauri and the future Slint adapter share it:
- apply_session_management_event(message_type, payload, identity)
-> SessionApplyOutcome: the locked critical section, byte-identical,
now locking self.sync. Local identity is injected as data (LocalIdentity)
so the crate stays frontend-agnostic (refresh_local_renderer_id takes it
instead of resolving device-info itself). The CoreBridge post-lock work is
RETURNED via the outcome rather than run inline -- materialization stays
adapter-side (renderer-engine seam, slice 6). Early returns yield a default
outcome, exactly as the prior `return;` skipped the post-lock block.
- arm_renderer_watchdog / run_renderer_watchdog: the generation-guarded 12s
liveness task, modeled on watch_pending_action_timeout (tokio::spawn + sleep
+ re-lock + 4-field epoch revalidation).
- freeze_active_renderer_projection: cached playing_state -> UNKNOWN, then
emit the freeze event through the sink (dedicated channel + blanket event
preserved).
- ensure_session_renderer_state + sync_session_renderer_active_flags moved to
qconnect-app (pure mutators over the relocated accumulator).
Adds 5 tests (SESSION_STATE outcome, watchdog arm/fire-after-12s/epoch-disarm,
ACTIVE_DISCONNECTED freeze) using start_paused + advance. The Tauri sink keeps
its own live copy this commit; slice 2+4.5 rewires it to delegate and deletes
the duplicate. qconnect-app 38 green, src-tauri qconnect 42 green.
cc
QconnectApp now holds the shared QconnectRemoteSyncState accumulator behind
its own Mutex, disjoint from `state` (the two are never co-locked). The Tauri
adapter still builds the Arc and passes the SAME clone to its event sink /
service loop and into new(), so there is exactly one lock backing the
session/liveness/renderer paths -- the app simply gains a handle to it via
sync_handle(). Pure wiring, byte-identical locking, no logic moved. Prepares
slices 2+4.4/5 to relocate the session-apply + watchdog logic into qconnect-app.
cc
Move the cross-submodule remote-sync accumulator into the new
frontend-agnostic qconnect_app::sync_state module. All field types are
now frontend-agnostic (primitives / qconnect-core / qconnect-app), so
the struct moves cleanly with pub fields; the Tauri qconnect module
re-exports it so existing super::QconnectRemoteSyncState references and
the Arc<Mutex<>> creation/threading in service.rs compile unchanged.
It stays ONE struct under ONE lock by design: capture_session_state_takeover_input
reads session topology together with last_renderer_playing_state, and the
materialize path reads last_renderer_* with last_applied_queue_state, all
atomically. Splitting them would tear the takeover decision, so the whole
accumulator relocates as a unit (single-lock locking is byte-identical).
Two test-only re-exports (find_unique_renderer_id, QconnectSessionRendererState)
are gated #[cfg(test)] since their non-test consumers moved to qconnect-app.
qconnect-app + src-tauri qconnect tests green (33 / 42), no new warnings.
cc
Relocate the pure session mutators (refresh_local_renderer_id,
is_peer/local_renderer_active, find_unique_renderer_id,
normalize_active_renderer_id, renderer_allows_remote_volume) and the
QconnectFileAudioQualitySnapshot type into qconnect-app::session.
refresh_local_renderer_id now takes an injected LocalIdentity (device
uuid + device-info) so qconnect-app stays free of the persistence /
transport crates (ADR-006). A thin Tauri wrapper resolves the identity
from default_qconnect_device_info() and delegates, keeping the three
event_sink call sites and the existing tests unchanged. The eager
device-info build is idempotent and side-effect-free, so renderer-id
resolution is byte-identical.
qconnect-app + src-tauri qconnect tests green (33 / 42).
cc
Move QconnectSessionState, QconnectRendererInfo, and
QconnectSessionRendererState from the Tauri adapter into the
frontend-agnostic qconnect-app::session module, re-exporting them from
the Tauri qconnect module so all existing super:: references and the
command surface compile unchanged. Field visibility widened to pub for
cross-crate access; serialized shape is unchanged.
First step of hoisting the QConnect session/liveness layer up so both
the Tauri adapter and a future Slint adapter share one session model.
qconnect crates + src-tauri qconnect tests green.
cc
Add scripts/slint-dev-fast.sh: a DEBUG (non-release) build for quick visual
iteration — opt-level 0 so the giant Slint-generated module skips the heavy
LLVM passes, plus the mold linker, no debuginfo, and the nightly parallel
frontend. It builds into target/debug, separate from the release cache.
slint-dev.sh (release, kept for perf measurement) gains the mold linker and
bumps the frontend threads 8 -> 16 — both only cut compile time, the optimised
binary is unchanged.
cc
Add the second of the four switchable now-playing bar layouts plus the
4-layout architecture behind it.
ShellState.npb-mode (0=New, 1=Classic) drives mode-conditional columns in
PlayerBar; the transport cluster is extracted into TransportControls.slint and
IconButton/PlayButton into primitives/BarControls (PlayButton gains a `circle`
prop) so both layouts share them without duplicating the popups. The choice
persists via UiPrefs.npb_mode ("new"/"classic") — restored on startup, written
on the "Now Playing view" menu select.
Classic = the Tauri arrangement: transport LEFT (plain play glyph, leading
track-info, inline add-to-playlist + favorite-with-state), glass song card
CENTRE, actions + volume RIGHT. SongCard.glass is a contained card capped at a
fixed 520px; the audio stamp is ABSOLUTELY pinned to the card's right edge
(cover 56 | song info that truncates with ellipsis | stamp 140) so it stays
flush right regardless of the text layout. Track info fires from both the
title click and the transport (i). Classic uses the full-size audio stamp; the
backend LED reads PIPEWIRE.
cc
Land the "New" now-playing bar as the first of the four switchable
layouts. A dedicated SongCard component holds the cover (or an idle
sleeping cat), the elidable title, the artist·album links, and the
vertical audio stamp (compact quality badge over the backend/mode LEDs).
A full-width seek bar sits on top with a buffer-driven stream lock.
The controls row uses responsive symmetric column zones (39/30/25% by
window width); the side columns share one fraction so the transport stays
absolutely centred and PLAY lands on the window centre. The song-card /
controls divider is a hard wall: the text column stretches to push the
audio stamp to its right edge, the album elides first (and never collapses
to zero) so the stamp is always visible and never crosses the wall.
Adds QualityBadgeFull compact mode, backend/mode output LED labels from
settings, a global TooltipOverlay so clipped columns keep their tooltips,
and the supporting icons.
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
Add five QconnectAppEvent variants (RendererUnreachable, RendererDisconnected,
ResyncComplete, LifecycleChanged, Diagnostic) and a Tauri mapper so the
session/liveness/diagnostic signals flow through the QconnectEventSink trait
instead of raw app_handle.emit calls. Move QconnectLifecycleState into
qconnect-app (pure data); Tauri types.rs re-exports it for the command surface.
No logic moves: the session logic still lives Tauri-side but now calls
sink.on_event(variant) at each rewired emit site. The sink mapper turns each
variant back into the exact same qconnect:* channel + payload, so the Svelte
wire surface is byte-identical. Each dedicated channel still fires exactly once
per occurrence (raw emits are replaced, not duplicated). The blanket
qconnect:event emit now also carries the new variants, which is additive and
ignored by the Svelte listener (pattern-matches known keys only).
Rewired emit sites:
- event_sink.rs freeze helper now takes/emits the freeze variant
(RendererUnreachable/RendererDisconnected); the mapper emits the dedicated
channel. State mutation precedes on_event and the variant arms only emit, so
there is no re-entrancy/recursion into the watchdog/session dispatch.
- service.rs resync-complete -> ResyncComplete; inbound_queue_event/cloud_error/
max_reconnect -> Diagnostic; update_lifecycle_state_if_running -> LifecycleChanged.
Tests: add qconnect-app unit tests asserting the new variants flow through a
sink and that QconnectLifecycleState serializes snake_case (matching the wire
payload). crates green (qconnect-app 33 passed); src-tauri cargo test qconnect
42 passed.
cc
Relocate the persistent QConnect device-identity resolution out of the
Tauri adapter into the frontend-agnostic qbz-app crate
(qbz-app/src/qconnect_identity.rs) so both the Tauri and a future Slint
frontend resolve the SAME uuid for the same install.
Moved: resolve_qconnect_device_uuid, device_uuid_from_db,
load/persist_device_uuid, qconnect_settings_db_path.
Byte-identical persistence contract preserved: DB path
(<data_dir>/qbz/qconnect_settings.db), the 'device_uuid' key, and the
QBZ_QCONNECT_DEVICE_UUID env override are unchanged, so existing installs
keep their uuid. The Tauri transport.rs::resolve_qconnect_device_uuid is
now a one-line delegate; the device-NAME persistence shares the same DB
file via the qbz-app path helper.
qbz-app gains a uuid dependency (it already had rusqlite + dirs, so no
SQLite dep was added to qconnect-app per R6). The 2 device_uuid tests
move to qbz-app.
cc
Move the frontend-agnostic pure functions and enums out of the Tauri
qconnect adapter into the shared crates, with zero behavior change. The
Tauri side keeps thin re-exports so all existing call sites compile
unchanged.
Into qconnect-app (new `session` module):
- should_arm_renderer_watchdog
- compute_connection_state + ServerActiveState + ConnectionDecision
- deferred_join_reason, should_reask_queue_state
- RendererStatus + from_wire
- quality_from_max_audio_quality
- QCONNECT_RENDERER_LOST_TIMEOUT_MS + JoinSession reason consts
Into qconnect-core (next to evaluate_remote_queue_admission):
- validate_track_origins_for_admission (pure over TrackOrigin); the
Tauri command wrapper stays thin and only maps wire origins to core
origins.
The 6 pure-fn unit tests move into the destination crates' test
modules. R5 honored: compute_connection_state matrix cells are relocated
verbatim, not changed.
cc
The P1-4 remote-queue routers (clear/remove/reorder) existed and were
unit-tested but their call sites in +page.svelte were never wired, so
reorder/remove/clear kept diverging from the renderer when a Qobuz
Connect peer owns playback.
- handleClearQueue: route to clearQconnectQueueIfRemote() first when a
peer renderer is active; return before the local clearQueue() to avoid
double-mutation.
- handleRemoveFromQueue: map the local upcoming index to the renderer's
stable queue_item_id via the QConnect queue snapshot (slice past the
current track, index into the upcoming portion, corroborate by
track_id), then route to removeQconnectQueueItemsIfRemote(). Falls
through to the local invoke if the id can't be resolved unambiguously.
- handleQueueReorder: left local-only by design (DONE_WITH_CONCERNS).
The remote queue_reorder_tracks command needs the full target order of
queue_item_ids, which can't be reconstructed reliably from a local
upcoming from/to index pair (no per-row queue_item_id, ambiguous
duplicate track_ids, renderer shuffle/autoplay ordering). Documented
inline; sending wrong ids would silently corrupt the renderer queue.
Gate is qconnectPeerRendererActive (matching seek/volume/play-track
handlers), not bare transport-connected, so local playback under a
connected-but-not-peer session keeps mutating the local store.
cc
Extend the QconnectQueueCommandType union with queue_reorder_tracks,
queue_remove_tracks and clear_queue, and add the exported
reorderQconnectQueueIfRemote / removeQconnectQueueItemsIfRemote /
clearQconnectQueueIfRemote routing functions. Each returns true when the remote
queue handled (or reported) the mutation and false when the caller should fall
back to the local store. Add the three qconnect.remoteQueue* toast keys to all
five locales and a covering test for the connected/disconnected paths.
cc
Add volume_remote_control to QconnectRendererInfo plus the
renderer_allows_remote_volume predicate (absent => allowed, only an explicit
non-1 value disables). Parse it from device_info.capabilities in the ADD and
UPDATE renderer paths, and short-circuit set_volume_if_remote when the active
renderer disallows remote volume (returns handled no-op so the frontend does
not fall back to local). Frontend surfaces volume_remote_control in the session
snapshot, derives a unified volume lock + reason, guards handleVolumeChange,
and shows a distinct player.volumeLockedRemote tooltip (added to all 5 locales).
cc
Renderer side now applies three previously-ignored controller commands:
SetMaxAudioQuality threads the pure quality_from_max_audio_quality mapping
into the remote load path (dropping the hardcoded UltraHiRes); SetShuffleMode
calls CoreBridge::set_shuffle; SetActive(true) preloads the current track and
emits an is_active=true StateUpdated report via a late-bound weak app handle
on the event sink. No fake acks for genuinely unsupported inbound commands.
cc
Add the RndrSrvrDeviceAudioQualityChanged(27) renderer report end to end:
the report-type variant + message string in qconnect-protocol renderer.rs,
the RendererDeviceAudioQualityChangedMessage proto struct plus the genuinely
free tag-27 field on QConnectMessage (26=file AQ, 28=max AQ), and the mapper
arm. Wire the adapter emitter report_device_audio_quality_if_changed (deduped
via a new last_reported_device_audio_quality sync-state field) next to the
file-AQ report, sourcing the real output stream config. Fix the deferred-join
StateUpdated report to resolve the real current track duration (via CoreBridge)
and current/next queue_item_ids instead of hardcoding nulls.
cc
Add a pure evaluateQconnectReconnectAdvisory helper that, on a
reconnecting->connected transition, replays a recently-issued idempotent intent
(play/pause/set_track) verbatim but only advises for next/previous to avoid a
double-skip; stale or non-reconnect transitions are no-ops. No invoke, fully
unit-testable.
Add the qconnect.lastActionMayNotHaveApplied i18n key to all five locales
(en/es/de/fr/pt) and emit a qconnect:resync_complete diagnostic from the backend
after the P1-8 post-reconnect resync so the frontend can run the advisory.
cc
On reconnect the bootstrap only re-asked for queue state, never for the active
renderer's full state, and a Lagged broadcast drop could swallow the
SESSION_STATE that carries the session_uuid / active_renderer_id.
Add free helpers ask_for_active_renderer_state + ask_for_queue_state and a pure
should_reask_queue_state(session_uuid_known, attempts, max) predicate. Wire:
- AskForRendererState after the reconnect Subscribed bootstrap and after the
deferred-join's final AskForQueueState (when an active renderer is known).
- Idempotent deferred-join: store last_joined_session_uuid; a repeat of the same
session_uuid skips re-announcing us but still re-asks renderer state.
- On a Lagged recv drop, re-AskForQueueState until the session_uuid is confirmed
or the attempt budget (5) is spent; the budget resets on disconnect.
No reconnect counters are reset (#358 latch untouched).
cc
Add a pure compute_connection_state(was_active, was_playing, ServerActiveState,
queue_equal) -> ConnectionDecision in session.rs implementing the plan's reduced
takeover matrix, plus a guarded CtrlSrvrSetActiveRenderer sender that only fires
for a valid id differing from the current active renderer.
Wire it on SESSION_STATE: the event loop captures was_active/was_playing and
classifies the server's active renderer from the incoming payload BEFORE
event_sink applies the new state, then runs the decision after the apply and
claims the active slot when warranted.
The exact web truth-table cell values were not dumped verbatim; the OtherPaused
vs OtherPlaying divergence and repeat-mode reconciliation are pending a
decompiled-bundle cross-check. Repeat-mode reconciliation is out of scope here.
queue_equal is defaulted false pending a controller/renderer queue diff. No
reconnect counters are reset (#358 latch untouched).
cc
The deferred renderer JoinSession always sent reason=CONTROLLER_REQUEST,
even on a rejoin after a transport drop. The event loop already tracked
has_disconnected but never threaded it into the deferred join.
Add JOIN_SESSION_REASON_{CONTROLLER_REQUEST,RECONNECTION} consts to mod.rs,
a pure deferred_join_reason(has_disconnected) selector, and thread the flag
through deferred_renderer_join so a post-drop rejoin carries RECONNECTION=2
while a first join keeps CONTROLLER_REQUEST=1. Transient-drop continuity
already works via the server-echoed session_uuid; restart-persistence of
session_uuid is intentionally deferred.
cc
Add `reconnect_idle_retry_ms` (default 0 = legacy terminate). When > 0, the
transport loop no longer breaks permanently on Exhausted: the new
`idle_retry_after_exhausted` helper idles (shutdown-cancellable), resets the
attempt counter / backoff to base, and retries (gap #7). The real config
sets 60s. The reset happens ONLY after Exhausted plus a deliberate idle
wait, never on connect, so the issue #358 latch is untouched.
Critical cross-file coupling: `service.rs`'s `MaxReconnectAttemptsExceeded`
handler previously dropped the runtime and broke the event loop. With
idle-retry active the transport stays alive and re-emits events, so the
handler now surfaces lifecycle Exhausted to the UI but keeps the runtime and
continues the loop (only the legacy path still drops + breaks). When the
re-armed transport later reaches SessionEstablished, the existing handler
moves the lifecycle back to Connected.
Startup auto-connect now retries on a bounded, monotonic schedule
(`startup_retry_schedule` = 2s/5s/15s/30s) instead of giving up after one
failed attempt (gap #8), re-resolving the transport config each iteration.
cc
Track `last_pong_at` and an outstanding-ping count per connection. On each
keepalive tick, before sending the next ping, check `keepalive_is_stale`:
when at least two pings have gone unanswered AND the last pong is older than
~2.5x the ping interval, break the read loop with reason `keepalive_timeout`
(gap #6). This catches a dead TCP path that no read/write has yet surfaced
as an error.
The break falls into the existing increment-only `handle_reconnect_delay`,
so the issue #358 latch is unaffected. The staleness decision is a pure
helper for headless unit testing.
cc
Add `require_jwt` (default false) to `WsTransportConfig` and a
`MissingAuthenticateJwt` error variant. When `require_jwt` is set but no
`jwt_qws` is present, the connect attempt now emits a `TransportError`,
closes the socket, and routes through the existing bounded reconnect path
instead of silently skipping the AUTHENTICATE frame (gap #12).
The default stays false so the InMemory / test transport path keeps
working without a JWT; `resolve_transport_config` flips it on for the real
Qobuz endpoint. Reuses the increment-only `handle_reconnect_delay`, so the
issue #358 latch is untouched.
cc
Consumer maps PlaybackError -> handleSkipForward for streamable/network/
not-found failures on the current queue item; toast otherwise. Predicate
shouldAutoSkipOnPlaybackError unit-tested. i18n keys added in 5 locales.
cc
Decodes PlaybackErrorMessage off the InboundPayloadBytes batch and emits a
PlaybackError app event; the Tauri event sink forwards it to qconnect:event
via its existing catch-all emit.
cc
Models the renderer per-track failure (TRACK_NOT_STREAMABLE/NETWORK_ERROR/...).
Envelope-vs-batch placement verified against transport bytes: the decoder
receives the batch QConnectMessages (CloudPayload.payload tag 7), and the
official Message record carries playback_error at tag 3, so the field is
attached to QConnectMessage inside messages[], NOT to the batch wrapper.
cc
Comparison seam joins the existing resync path. Local hash algorithm is a
documented BLOCKING open question; stub returns None so detection stays inert
(no spurious resyncs) until the Qobuz algorithm is verified.
cc
Decoder now reads field #100 (previously decoded-then-dropped) on QueueState
and carries it through to QConnectQueueState.last_server_queue_hash via the
QueueStateReplaced reducer path (full-state replace already clones it).
cc
105 carries only bare queue_item_ids (no track_id/context), unreconstructable
by the reducer; resync pulls the authoritative QueueState so autoplay-grown
tracks become visible. Matches the ShuffleModeSet/Reordered/Removed pattern.
cc
A playing active-peer renderer that goes silent left the controller UI
stuck on stale 'playing' (no equivalent of the webplayer's 12s
rendererLost timer). Add a generation-guarded 12s watchdog keyed on the
active peer renderer in the RENDERER_STATE_UPDATED handler: reset on every
update, arm only while playing_state==PLAYING, disarm on
pause/stop/active-change/disconnect. On expiry freeze the projection to
UNKNOWN and emit qconnect:renderer_unreachable; Svelte shows an error
toast (rendererUnreachable, all 5 locales). Reuses the
freeze-projection-to-unknown helper from the ACTIVE_DISCONNECTED path.
cc
The wire 'status' field on RENDERER_STATE_UPDATED was decoded into the
adapter payload (decoder.rs:801) then dropped — a graceful peer
disconnect was invisible and the controller kept showing stale 'playing'.
Add a RendererStatus enum (UNKNOWN/UNRECOGNIZED -> INACTIVE collapse) and,
on ACTIVE_DISCONNECTED for the active remote renderer (id != -1), freeze
the projection to UNKNOWN and emit qconnect:renderer_disconnected. Svelte
surfaces it as an error toast (rendererDisconnected, all 5 locales). Pure
consumer-side: no protocol change.
cc
resolve_qconnect_device_uuid() generated a fresh Uuid::new_v4() per
process via a OnceLock, so every launch registered a new renderer
identity cloud-side (zombie renderers) and broke reconnect continuity.
Persist it beside device_name in qconnect_settings.db: generate once on
first run, reuse thereafter, keeping the QBZ_QCONNECT_DEVICE_UUID env
override for tests. Mirrors load_persisted_device_name/persist_device_name.
cc
v2_qconnect_send_command_with_admission trusted a single frontend origin and
never inspected track_ids. Add track_origins[] to the request and
validate_track_origins_for_admission: re-evaluate every track via the core
gate, block if any is non-Qobuz or the list is empty. The per-track check is
only enforced when the caller actually ships per-track origins (bulk queue
loads); single-track play-next/play-later carry no track_origins and stay
gated by the command-level origin alone, so an empty list never over-blocks
them. The existing command-level check and admission_blocked emit are kept.
Frontend loadQconnectQueue now computes per-track origins from its tracks arg
via resolveQconnectTrackOrigin and ships them to the sender.
Defers the optional DB cross-check (LibraryState / has_local_track_by_qobuz_id
defense-in-depth) as a follow-up; this commit is the per-track-origins
validator + wiring + test only.
cc
The Jack backend variant was added to qbz-audio but the backend-name
match in commands_v2/settings.rs was not updated, leaving the lib crate
non-exhaustive (E0004) and uncompilable on this branch. Add the missing
arm and sync Cargo.lock with the jack/jack-sys deps. Pre-existing breakage
unrelated to qconnect; fixed here only to unblock the build and tests.
cc
The TransportConnected handler pushed the live queue via loadQconnectQueue
with no source check, shipping local/Plex row ids as Qobuz ids. The frontend
display queue (getQueueState) carries no `source`/`is_local`, so wire the push
to getBackendQueueState() whose tracks DO carry them (v2_get_queue_state maps
source per track in commands_v2/queue.rs). Route the ordered snapshot
([history, current, upcoming]) through assessQconnectQueueSync: refuse the
whole push on a mixed queue (no silent subset) and toast the user via the new
qconnect.mixedQueueNotCast key (all five locales). Lock the refuse-not-subset
contract with a unit test; page wiring covered by svelte-check + manual verify.
cc
The bulk-load hardcoded origin=qobuz_online and never looked at IDs, so the
Rust gate always accepted local/Plex ids shipped as Qobuz. Export the shared
isQconnectSyncEligibleTrack predicate, add partitionQconnectLoadableTracks,
and add an optional tracks[] arg to loadQconnectQueue that refuses the load
when any track is non-Qobuz.
cc
Cross-check test now pins admission to is_castable_to_qconnect (incl. OfflineCache)
instead of the streamable predicate. Dedupe the queuePlaybackService test builder.
Document is_castable_to_qconnect/from_source_str_strict/TrackOriginTag as the shared
QConnect-admission gate consumed by the Slint port.
cc
Three sites disagreed: model blocked OfflineCache, live gate allowed it, TS
sync filter blocked qobuz_download. Decision: ALLOW — an offline copy's queue
id is the real Qobuz track id. Flip the TS sync filter and fix the stale model
doc comment to match the live gate.
Also add Rust lock-in tests (offline_cache_is_castable / offline_cache_is_admitted)
and restore the missing $app/environment vitest mock the config already aliases,
so queuePlaybackService.test.ts can load.
cc
Admission must do the opposite of from_source_str: add from_source_str_strict
returning TrackOriginTag::ExternalUnknown so an untagged local/Plex row can
never be cast.
cc
is_qobuz_streamable was dead code. Add is_castable_to_qconnect as the
single source of truth and depend qconnect-core on qbz-models so the
admission gate and the cast predicate derive from one enum.
cc
Committed together because these changes share main.rs, playlist.rs and
PlaylistView.slint, and the new quality field is atomic across every
TrackItem builder, so they cannot compile as separate whole-file commits.
- Universal play-from-here: a single play_track_in_context dispatcher builds
the queue from the current view's VISIBLE list and starts at the clicked
track (playlist, album, artist, favorites, label, mix, search). Cached
views order by the visible model; re-fetch views (album / artist top
tracks) reorder to the in-page filter; the search "most popular" hero
prepends to the results queue. Drops the playlist-track / mix-track arms
that always started at track 1 (the clicked id was read from the wrong
media-action slot).
- Album-name links in track rows open the album page (the ("album","open")
media-action arm was missing and fell through to a no-op).
- Unified track-row quality badge: a new quality-detail field on TrackItem,
populated from bit-depth / sample-rate in every builder through a shared
crate::quality::detail, rendered with a bare (no chip background/border,
centered text) QualityBadgeFull. Restores the exact bit-depth / sample-rate
line that had regressed to a plain CD / Hi-Res tier.
- Playlist track list: reserve the favorite / download / context-menu columns
in the column header, match the row's 36px cover cell, and anchor the
scrollbar to the view edge (matches Local Library > Tracks).
cc
Insert a 20px spacer before the body row. The JUMP TO bar's natural-top tracks body-row.absolute-position.y, so it drops with the row while its sticky fix-on-scroll clamp stays at 0 (unchanged).
cc
set_tab updated the Rust view state and called refresh, but never pushed the new index onto the Slint QueueState.tab property, so the History body (gated on tab == 1) never appeared. Sync the property synchronously in set_tab and on every refresh.
cc
Mirror the Flathub manifest: replace --device=dri with --device=all so the
sandbox can reach /dev/snd directly, enabling bit-perfect / exclusive ALSA
output like native installs. Flatpak has no granular --device=snd; /dev/snd
is only exposed via --device=all (same as Strawberry, mpv, and other
bit-perfect audio apps), which already includes --device=dri (GPU) — so the
separate dri entry is dropped. Reduces PipeWire/audio friction in the
Flatpak build.
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
QbzSelect badges now render a "BP" tag for bit-perfect outputs and a
speaker glyph (volume-2) for the rest (real outputs that play audio but
not bit-perfect) — an addition, not a replacement. Only the device select
passes badges, so other selects are unchanged.
Add opt-in `searchable`: long output lists overflowed at 1440p, so the
open list gets a case-insensitive filter box and a 360px scroll cap.
Filtering hides non-matching rows by visibility only (model order intact,
so selected(i) keeps the original index). Backed by a new Rust
TextUtil.contains-ci pure callback (Slint 1.16 has no contains builtin).
cc
The System/JACK (CpalDefaultBackend) and output_sinks enumerations dumped
CPAL's raw ALSA PCM list: the `null` discard sink plus one output repeated
across every plugin profile (8x/Nx duplicates that share a display name).
The precise pickers (ALSA /proc/asound, PipeWire pactl) were already clean;
only these CPAL paths lacked filtering.
Add device_filter::retain_real_outputs(): collapses entries that share a
display name (keeping the best-ranked id), and sorts the `null` discard
sink to the end instead of offering it as a first-class output. Pure,
host-agnostic (PipeWire node names pass through), unit-tested.
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
Backend-side foundation for a native JACK output. The hard, RT-sensitive part
is done and compiles: jack_backend.rs holds a JackStream that opens a JACK client
'qbz' with stable ports qbz:out_FL/FR, a lock-free SPSC ring buffer, and an
allocation-free process callback (de-interleaves stereo f32 to the ports). The
feeder pushes interleaved f32 via write_f32().
Also: jack 0.13 dep (linux), AudioBackendType::Jack + its factory arm + the
qbz-slint label/bit-perfect match arms. NOT yet offered in the selector
(available_backends push commented) because the player wiring is still pending:
StreamType::Jack + the dispatch branch (backend==Jack -> JackStream) + a
PlaybackEngine::Jack feeder thread that resamples each track to the JACK graph
rate. JACK mode will be opt-in and NOT bit-perfect (one fixed graph rate); the
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
TODO(distribution): feature-gate jack for Flatpak/distro builds without libjack.
cc
Completes the "Lock output device" (skip_sink_switch) fix. The lock already
skips set-default-sink + (Tier 1) the global clock force, but targeting was
implemented AS set-default-sink — so with the lock on QBZ's stream followed the
current default, and if the selected DAC was not the default, locked audio went
to the wrong device.
In locked mode, set PIPEWIRE_NODE=<selected sink> before the cpal PCM open so the
pipewire-ALSA plugin routes this stream to the chosen node WITHOUT changing the
system default. A Drop guard restores the prior env value after the open.
Delivers the reporter's full comment-3 ask: output to the selected sink AND never
change the default/priority AND never force the graph clock. Verified on hardware:
lock on + DAC selected + system default = onboard -> QBZ plays on the Cambridge DAC
(alsa_playback.qbz-slint linked to the DAC sink) while the default stays onboard and
another app keeps using it. Bit-perfect is traded for routing freedom in locked mode
(the graph clock is not forced), as designed.
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
When "Lock output device" (skip_sink_switch) is on, also skip the global
pw-metadata clock.force-rate writes (the pre-stream force and the hi-res
re-apply), not just set-default-sink. This stops QBZ from yanking the whole
PipeWire graph clock on every track-rate change, which broke external routing
(qjackctl/qpwgraph/Reaper) for users who enabled the lock — issue #263 comment-3.
The bit-perfect path is unchanged: with the lock off (including DAC passthrough)
the clock is still forced to the track's native rate. skip_sink_switch is
transitively mutually exclusive with dac_passthrough/pw_force_bitperfect, so this
never collides with a forced-rate bit-perfect session.
Verified on hardware (Cambridge USB DAC): lock on + 44.1k->192k leaves
clock.force-rate untouched; lock off + passthrough forces 44100/192000 with the
DAC running at the native rate.
cc
The album header had several dead icon buttons and a non-navigating label
card. Wire them:
- Shuffle -> play_album_shuffled (SystemTime-seeded xorshift Fisher-Yates,
same as the playlist shuffle, no rand dep) + ("album","shuffle") arm.
- Radio -> existing ("album","radio") album-radio path.
- Artist (user icon) -> open the album artist.
- Label sidebar card -> navigate to the label page; added label-id to
AlbumState (populated from album.label.id) and made the card clickable.
Still pending for AlbumView (next batches): multi-select (square-check +
MultiSelectBar), the "more" ellipsis menu, the digital-booklet viewer, and
add-to-mixtape. Awards pages deferred (1:1 Tauri port, separate session).
cc
Playlist cards (carousels, search, favorites) emitted ("playlist","queue")
and ("playlist","play-next") but no handler existed, so the menu items
were dead. Add enqueue_playlist (fetch the playlist's tracks by id and
append, or insert-next in reverse to keep order) and the two match arms.
The album/track enqueue paths already worked but gave no visible feedback
when the queue panel was closed, reading as "unwired" — add a success
toast (Added to queue / Playing next) to every enqueue path.
cc
With the sidebar fully closed there was no way to reach playlists. Add a
playlists button to the closed-sidebar header cluster that opens a flyout
over SidebarState's entries (folders + playlists), six rows visible with
the rest scrolling, plus an always-visible footer linking to the Playlist
Manager. Rendered at the AppShell level (SidebarPlaylistsPopup +
SidebarPlaylistsPopupState); header button needs no absolute-position
correction (not inside a Flickable).
cc
The drag-end relied on the row TouchArea's pointer-up, which only arrives
if the row still holds the grab. Inside a ListView (or a Flickable that
stays interactive) the scroll gesture can steal the grab mid-drag, so the
up never reached the row and the ghost stayed glued to the cursor. Add a
`changed pressed` safety net that ends the drag on any release or grab
cancel (both paths guard on ta.dragging, so end() runs once). Also give
LabelPageView's Flickable the interactive: !DragState.active flag the
other track views already have.
cc
SidebarNavRow (mini sidebar) opened its section flyout with no header;
the closed-sidebar CompactNavBtn dropdowns show the section name + a
hairline. Set HeaderMenuState.title to the row label so the mini flyouts
match (same @tr strings).
cc
play_local_tracks_now backs all five Local Library play entry points but
omitted the refresh_sidebar(true) its sibling play paths run after
set_queue + after_track_change, so the Queue panel kept showing the
previous queue until reopened or its tab toggled. Add the missing refresh.
cc
In the mini (icon-only) sidebar the rows show no name. Add a hover bubble
to the right of each folder/top-level-playlist row surfacing the name
(folders also append their playlist count), mirroring the native title
Tauri puts on its collapsed buttons. Rendered at the AppShell level
(SidebarTooltip + SidebarTooltipState) because the sidebar clips its
overflow; anchored with the same Flickable absolute-position correction as
the folder flyout. Closes on click so it does not linger behind the flyout.
cc
absolute-position of an element nested in a Flickable double-counts the
element's own layout offset (measured: abs.y = true_y + root.y), so the
collapsed-sidebar folder flyout drifted down by one row-pitch per row
(lower folders left the viewport) and the track drag ghost plus the
playlist drop-target hit box drifted the same way. Subtract root.{x,y}
to recover the true window position; scroll-safe because root.{x,y} is
the row's fixed in-layout offset, not the viewport scroll. Rows outside
a Flickable (the section nav rows) are unaffected and left as-is.
Sites: SidebarRow folder-flyout anchor + recompute-drop hit box;
TrackRow drag down/threshold/start/move coordinates.
cc
- Local Library Tracks: build the play queue from the already-loaded rows
(instant) instead of re-querying the DB + filling covers on every click.
- Mouse back/forward buttons now drive history nav over track rows and album
cards (a TouchArea swallows them, so each interactive surface forwards them;
the AppShell catcher only saw the gaps).
- ExpandableSearch open width trimmed 20px (no longer rides over left controls
on small screens).
- QbzSelect toolbar (Group/Sort/filter) trimmed 40px total.
cc
Flyouts & idle (main effort):
- Header dropdowns now glue under the tab: open-menu resets from-sidebar +
title (a stale sidebar flyout state was flinging the panel far away).
- Idle-close now pauses while the cursor rests on the TRIGGER too (full
header tabs gained the trigger-hovered logic the others already had).
- Dropped the dropdown caret (saves width on small screens).
Header nav layout:
- Section nav moved INTO the left-controls cluster, after the three sacred
buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off
and sidebar not closed; compact icons when closed — mutually exclusive (no
more duplicate/cluttered header).
- Search field gives up 60px (animated) when the header hosts the nav.
Settings made functional:
- Toast component mounted in AppShell (it was never rendered) + the in-app
toasts toggle drives ToastState.enabled.
- "Show album artwork in Local Library track list" now drives the Tracks/
Folders rows. Both rows un-flagged as unsupported.
Collapsible search (ExpandableSearch, app-wide):
- Smooth open AND close (width animates both ways; magnifier fades).
- Reliable auto-focus on open (deferred via a one-shot timer).
cc
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.
Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.
Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.
cc
Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
grouping/filtering span the whole set. derive_albums = search + sort + group
(off / by letter with A-Z jump / by artist, both with section headers) + the
quality/format/source filter (1:1 with matchesQualityFilters: OR within a
group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
layout, not a layout slot) with an active-count badge. Fixed the year sort
(sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.
Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
multiple copies of the same album no longer merge into a duplicated track
list. Best-quality version is the default; a source-icon version picker
(hard-drive / Qobuz / Plex glyph in both the selected display and the options)
switches the shown copy in place. The album cover is the best-quality version
that has one, falling through versions, else a placeholder (covers backfilled
from disk via fill_missing_covers). Header padding-top 11px matches the app.
Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.
cc
Complete the tag editor with remote metadata auto-fill.
- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
remote lookup without depending on the Tauri binary; src-tauri is left fully
untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
search_releases_extended / DiscogsClient search_releases, generation-guarded),
select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
full metadata; overwrites album fields + positional per-track title merge;
track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
card strip with select highlight, Apply + open-in-browser). ADR-008: square
badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.
LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.
cc
Bring the Tracks tab up to the Favorites precedent.
- Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an
A-Z jump strip for Name (over the loaded set); show-album hidden under album
grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs
precedent; default "off"), loaded in navigate_local_library before the tab
derives.
- Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode)
+ MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue /
Play next / Add to playlist / Clear (no make-offline, no remove).
- derive_tracks (reduced port: local folder_alpha_key, no genre filter) +
TRACKS_CURRENT selection-source cache (cloned in apply/append before the move)
so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip.
- playback::enqueue_local_tracks (source-aware via local_queue_track; reversed
for play-next); per-row play-next/queue wired in on_track_action.
- Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode
+ on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped
(decided on the underlying LocalTrack.source, not the remapped TrackItem one).
- No-match empty state added.
cc
The local-album tag editor (no remote lookup yet — that lands with the
remote-metadata crate move). Opens from a pencil on the local album detail.
- TagEditorModal.slint: album title/artist + year/genre/catalog + per-track
title/track#/disc# table (indexed model write-back) + persistence QbzSelect
(sidecar default / direct opt-in) + save-with-progress. Mounted last in
AppShell (ADR-009 by mount order). ADR-008: dropdown, no pills.
- tag_editor.rs controller: open_tag_editor (off-thread pre-fetch via
fetch_album_tracks_blocking + D1 year seed + can-direct-write CUE guard),
save_tags (validate, is_dir gate, CUE guard, one-time direct-write confirm
via rfd + library_kv ack, sidecar/direct branch on spawn_blocking with
per-file progress, update_album_group_metadata via new with_db_mut, post-save
refresh of the album + browse models), close.
- AlbumPageView: edit pencil CircleAction, visible is-local && source != plex;
AlbumState gains `source`, set in apply_local_album.
- library_db: with_db_mut for the transactional metadata update.
- main.rs: on_media_action("album","edit") + TagEditorActions wiring (close/
save/set-persistence with the CUE gate).
cc
Frontend-agnostic foundation for the LocalLibrary tag editor (consumed by the
Slint modal + controller in a follow-up slice; the Tauri direct-write command
can later fix-forward onto this):
- tag_writer.rs: write_album_tags_to_files() ports the Tauri lofty loop 1:1
(dedup by file_path first-wins, per-file on_progress closure instead of a
Tauri event, destructive remove-on-None for album-artist/year/genre/catalog,
honest partial-failure error wording) + compute_track_artist_match().
- database.rs: a small library_kv (key,value) table + get_kv/set_kv (upsert) to
persist the direct-write one-time acknowledgement (replaces Tauri localStorage).
cc
Render artist portraits on the two-column Artists rail and fill missing ones
from Qobuz in the background.
- qbz-library: new get_all_artist_image_urls() bulk reader (custom path else
cached Qobuz url, keyed by artist_name) so previously-fetched portraits
reappear on revisit without re-hitting Qobuz. (The Tauri batch command was
never registered; this is the corrected one-pass reader.)
- state.slint: LocalArtistItem gains a decoded `image` field; LocalLibraryState
gains the fetch toggle + progress props. merge_artists now returns a Send-safe
ArtistRow (no slint::Image) across spawn_blocking; apply_artists builds the
LocalArtistItem with the (default-empty) image on the UI thread.
- ensure_artists_loaded: seeds the rail from get_all_artist_image_urls, spawns
decode jobs by scheme (local path -> spawn_local_loads, http -> spawn_loads),
and kicks the capped fetch.
- fetch_missing_artist_images: sequential worker, max 50/session, 1s apart,
generation-guarded, exact-normalized match only (no wrong-artist persist),
paints each portrait immediately, persists via cache_artist_image_with_canonical.
- artwork.rs: Copy-safe LocalArtistRowImage { index, gen } target + apply arm
that resolves index -> name and routes through the new name-keyed dual-setter
set_artist_row_image (flat master + every grouped section), so portraits
survive a derive_artists re-run.
- LocalArtistRow renders the decoded image (placeholder until it lands).
cc
Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.
Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.
Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.
cc
Port the Tauri folder-management surface to a new Settings > Local Library
sub-section (index 4); the browse-view gear routes here and pre-selects it.
UI:
- LocalLibrarySettings.slint: folder list (icon, display name, disabled/
unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/
remove icon buttons) + filter + Maintenance (cleanup missing) + two-step
Danger Zone clear. ADR-008: plain bordered icon buttons + square badges.
- LibFolderEditModal.slint: alias, enabled, network override + fs-type,
change-path, accessibility status, last-scanned, scan-this-folder. Mounted
at the AppShell root (overlays the window, ADR-009 by mount order).
- SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the
new globals so the Rust layer can populate them.
Controller (local_library_settings.rs): load/add/remove/edit/save/change-path,
cleanup-missing (stats outside the DB lock), two-step clear, filter, select.
All DB access via qbz-library through library_db::with_db on spawn_blocking;
native dir picker + confirms via rfd. Selection preserved across reloads;
network folders get an async 6s-timeout accessibility check (exists() fallback
on timeout, mirroring Tauri).
Core (qbz-library): network_fs_label() for fs-type parity; separator-safe
delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the
prefix-collision bug without changing the legacy fn). Scan engine lands in
Slice B.
cc
Replaces the thin name+counts grid (which was built without a Tauri
review) with the real structure: a two-column master/detail, 1:1 with
the Svelte Artists tab.
Left rail: a merged/deduped, A-Z grouped master list of compact rows
(round avatar + display name + "N albums · M tracks") with a proportional
alpha jump strip and in-place search. The name-merge collapses
normalized-equal spellings into one canonical row (canonical = most
albums, tie by most tracks) and computes accurate unique album counts
from the album set's all_artists, mirroring Tauri's artistMergeResult.
Diacritics are folded best-effort (no unicode-normalization dep).
Right pane: the selected artist's albums, filtered IN PLACE from the
loaded album set by primary/all_artists/multi-credit match (no new
backend call) and rendered via the shared AlbumCollectionView; covers
load through a new LocalArtistAlbumCard artwork target. Clicking an
album opens the local album detail; the play action queues the album.
Custom DB artist images are resolved into the rail model; rendering them
(plus the capped/sequential Qobuz photo fetch) is the remaining image
follow-up — the rail shows the placeholder avatar until then.
cc
Flat mode now has the full toolbar: search, sort (artist/title/year),
group-by (off/letter/artist) and a grid/list view toggle, driven by a
derive_folders() pass that mirrors favorites::derive_albums over the
directory-grouped album set (folders-visible / folders-grouped).
Tree mode is a new two-pane filesystem browser: a flattened FolderNode
list in the left rail (lazy one-level expand via list_folder_children,
collapse drops the descendant block, recursive track counts gate the
chevron) and a detail pane on the right showing the selected folder's
direct tracks plus immediate subfolders, with Play all (recursive) and
per-track play scoped to the folder's direct list. A flat/tree segmented
toggle switches modes; the tree loads its roots lazily on first switch.
Slint has no native TreeView, so the tree is a depth-indented ListView of
visible nodes spliced on expand/collapse. No pills (ADR-008): underline
tabs, small-radius rectangles and an accent selection bar.
cc
LocalLibrary track rows (Tracks tab + local album detail) no longer carry the
favorite or offline-cache columns — they don't apply to local content. Instead
they show a subtle per-source indicator (local -> hard-drive tinted; qobuz
offline / plex -> their mark), small + flat rather than the bold album-card
badge (mirrors Tauri's track-row local indicator). TrackItem gains a `source`
field, set by every constructor (Qobuz surfaces -> "qobuz"; map_local_track ->
local/qobuz/plex); TrackRow gains `show-source`.
Also fixes Qobuz AlbumView: the track list had a Favorite column header but the
rows never rendered the heart (show-favorite defaulted false). AlbumPageView
now sets show-favorite + show-download for Qobuz albums (matching the header)
and the source indicator for local albums, with the column header switched to
match each.
cc
Fills the last empty tab: a full-loaded grid of artists (get_artists_with_filter)
with album/track counts, inline circular cards (placeholder icon for now). All
four Local Library tabs (Albums / Tracks / Folders / Artists) now browse. The
navigate match is exhaustive over LibTab. Artist images, click-to-detail, and
name-merge dedup (Tauri's artistMergeResult) are later slices.
cc
Fills the Folders tab: a full-loaded grid of albums grouped by directory
(get_albums_with_full_filter), via the shared AlbumCollectionView (grid +
source badge), covers loaded from disk, loaded once on first visit. Clicking
a folder album opens its detail + plays it — navigate_local_album and
play_local_album now resolve tracks via fetch_album_tracks_blocking, which
tries the metadata grouping (Albums tab) then the folder grouping (Folders
tab), so both album views work. New ArtworkTarget::LocalFolderCard applies
covers to the folders model.
Tree mode (two-pane lazy folder tree) + folder search/sort are later slices.
cc
Clicking a local album (grid card or a track row's album link) opens its
detail in the shared AlbumPageView. navigate_local_album loads the album's
metadata-grouped tracks (get_album_tracks_metadata) + fills folder covers,
populates AlbumState (title / album-artist or "Various Artists" / cover /
quality / "N tracks · duration" / disc-ordered TrackRows) with a new is-local
flag, and resolves the cover from disk. Play routes to local: the media-action
dispatcher gains a guard — when the album view shows a local album, album play
-> play_local_album, a track row -> play the album from that track; Qobuz
album/track play is untouched (guarded by view==Album && is-local).
cc
Three playback-correctness fixes reported after testing:
- Album/offline cover art now reaches the now-playing bar AND the queue:
fill_missing_covers fills a track's artwork from the folder cover when the
index has no artwork_path (the offline-cache writes cover.jpg into the track
folder but doesn't always backfill the index), applied when building local /
album play queues. The cover that exists on disk shows, not just on the grid.
- Queue-panel art is source-aware: load_artwork routed every cover through an
HTTP fetch, so local file:// covers silently failed. It now parses the url
into an ArtworkRef (Remote vs LocalFile) and decodes local files from disk.
- now-playing/queue current-track mismatch: the poll loop treated ANY track-id
change while playing as a gapless hand-off and advanced the queue pointer,
which falsely fired on a manual new-track play and desynced now-playing from
the queue (erratically, by timing). It now advances only when the new id is
the expected upcoming track (a real gapless next); Qobuz gapless is unchanged.
cc
The Tracks list scrollbar sat inside the 32px content padding (too far into
the content). Move it to the view's true right edge — the position the Albums
grid already uses, which is the standard for chrome-bezel views: a padded
VerticalLayout holds the ListView while the ListScrollbar floats at the
full-width edge.
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
First source-aware playback stage. Clicking a track in the Local Library
Tracks tab now plays it, mirroring Tauri's v2_library_play_track routing:
- offline-cached (qobuz_download) tracks reuse the shared tier-walk
(after_track_change -> play_track_resolved -> offline resolve -> play_data),
keyed by the Qobuz id;
- local user files are read from disk and handed to the player's play_data
seam (which extracts the sample rate + drives the PROTECTED device init,
untouched), with a CUE seek for virtual tracks.
Additive: play_local_library_track + local_queue_track (mirrors Tauri's
local_track_to_queue_track) are new; the shared Qobuz play_audible path is
unchanged (no regression risk to streaming). Single-track play (a 1-track
queue) — album/queue + auto-advance need a source-aware advancement handler
and land in the next stage. Built green (cargo check -p qbz-slint);
runtime smoke-test pending (a local FLAC + an offline track should play).
cc
Adds the Tracks browse tab. Each page is a search_with_filter_page query
(new paged variant of search_with_filter) appended on scroll-near-bottom, so
the list never materializes the whole table — the documented ~16K-row Tracks
freeze. Reuses the shared TrackRow in a virtualized ListView + ListScrollbar
(the Favorites Tracks pattern) and the Albums controller's generation-guard
(TRACKS_GEN) so a search/retry can't be clobbered by a stale in-flight page.
- qbz-library: search_with_filter_page(query, offset, limit, include_qobuz,
exclude_network) — same ordering as search_with_filter, LIMIT/OFFSET.
- local_library.rs: map_local_track (LocalTrack is Send -> crosses
spawn_blocking; TrackItem built on the UI thread), fetch_tracks_page,
apply/append, reload/ensure/load_more (TRACKS_GEN-guarded).
- LocalLibraryView.slint: Tracks body (ListView + TrackRow, show-artwork off
per Tauri's perf default, show-favorite/show-download off) + a search
toolbar + loading/error/empty states.
- state.slint + main.rs: tracks state/actions + lazy load on first visit +
search/load-more/retry wiring.
Group-by, multi-select/bulk, and per-row playback land with the source-aware
playback slice (track-action is a stub, like Albums). Built green
(cargo check -p qbz-slint); runtime smoke-test pending.
cc
Generalize resolve_album_cover_fallback beyond a single track + cover.jpg:
scan several of the album's tracks (CMAF keeps each track in its own folder,
only some of which may carry a cover), check both the track folder and its
parent (multi-disc layouts keep art one level up), and accept the common
local cover filenames (cover/folder/front, jpg/png). Resolves more on-disk
covers without touching the paged query. Albums whose offline bundle never
saved a cover (segments only) still need an offline-cache cover re-fetch.
cc
Two fixes from the first Albums-grid test pass.
Offline/folder covers (the reported "cover exists in the folder but isn't
shown"): some albums — offline-cached CMAF bundles and folder downloads —
have no artwork_path in the index even though a cover.jpg sits in the track
folder (older offline-cache downloads didn't backfill it). Add a frontend-
agnostic LibraryDatabase::resolve_album_cover_fallback(group_key) that finds
a representative track, derives its folder (the path itself for a CMAF bundle
dir, else the parent), and returns <folder>/cover.jpg when it exists. The
Slint Albums mapper calls it whenever the album has no artwork. Verified
against the live index: it resolves the folder covers that exist on disk
(FLAC-layout + CMAF-with-cover). Older CMAF bundles that never downloaded a
cover (segments only) still show the placeholder — those need a cover
re-fetch (offline-cache maintenance), tracked separately.
Source badge: the Local Library album card now shows the always-visible
source badge inside the artwork (bottom-right) — a hard-drive glyph for local
files, the Qobuz logo for offline copies, the Plex logo for Plex. Added
AlbumCard.show-source-badge (default off; threaded through AlbumCollectionView
/AlbumGrid) + AlbumCard.source; album_map now carries the real source and
map_local_album maps user->local / qobuz_download / plex. Small rounded
square, not a pill (ADR-008). Logos copied into the Slint icon assets.
Built green (cargo check -p qbz-slint); runtime smoke-test pending.
cc
First Local Library data slice. The Albums tab browses the metadata-grouped
local albums via the performant server-paginated path (the same query Tauri's
chunked store uses), reusing the shared AlbumCollectionView grid — no new
rendering. Scales past the documented ~16K-row Tracks freeze by never
materializing the full set.
- local_library.rs: map_local_album (LocalAlbum -> the plain album_map
AlbumCard, Send-safe; the non-Send slint::Image conversion via to_item
happens on the UI thread), local_quality (Hz -> kHz), sort_params; a
blocking fetch_albums_page over LibraryDatabase::get_albums_metadata_page
(spawn_blocking); apply/append + reload/ensure/load_more driven by an
ALBUMS_GEN generation guard so a search/sort/retry can't be clobbered by a
stale in-flight page.
- LocalLibraryView.slint: Albums body reusing AlbumCollectionView (grid,
show-favorite: false), a search + sort toolbar (both server-side), auto
load-more on scroll-near-bottom (DiscoverBrowse pattern), and loading /
error+retry / empty states.
- Local covers: ArtworkTarget::LocalAlbumCard + spawn_local_loads resolve the
on-disk cover path through the source-aware ArtworkRef::LocalFile pipeline;
the target carries the generation so a cover decoded after a reload is
dropped instead of landing on a replaced row.
- state.slint: LocalLibraryState album fields + LocalLibraryActions callbacks.
- main.rs: navigate_local_library lazily loads page 1 on first visit; the
on_albums_* callbacks wire search/sort/load-more/retry (open-album /
open-artist / album-action are TODO no-ops until the detail + playback slices).
List/group/A-Z/quality filters, album detail, and playback are later slices.
include_qobuz_downloads mirrors the show_in_library setting default (true).
Built green (cargo check -p qbz-slint); runtime smoke-test pending.
cc
The shared AlbumCard always rendered the favorite heart (overlay button +
"Add to favorites" menu item). Local Library albums are not Qobuz-favoritable
(Tauri's AlbumCardLibraryLite hides favorite/genre), so the upcoming local
grid needs to suppress it.
Add an additive `show-favorite: bool` (default true) on AlbumCard, threaded
through AlbumCollectionView -> AlbumGrid. The fav overlay button uses
`visible:` (not an `if`) so it stays referenceable by `overlay-on` (keeping
the Qobuz hover-no-flicker behavior intact); the menu item is gated with an
`if`. All existing callers (Discover / Favorites / Label) default to true,
so behavior is unchanged.
cc
The nav-scaffolding view (c50ae171) invented its own chrome: a title+stats
header with no NavButtons (so no back/forward history nav), plus a tab bar
of border-radius:18px pill chips — an ADR-008 violation. Rebuild it 1:1 on
the app's mandatory fixed-chrome header (the same FavoritesView/Discover
use):
- Row 1: NavButtons (back/forward) + "Local Library" title (left), the tab
menu floated right. Tabs are underline tabs (LibTab/LibTabMenu mirroring
FavTab/FavTabMenu) — no pills.
- The settings gear becomes a standalone icon-only circle matching
NavButtons (the one circular shape ADR-008 permits), routing to
Settings > Local Library.
- Row 2 (per-tab actions + toolbar) is established with vertical-stretch:0
so it doesn't inflate while empty; filled per slice (Albums next).
- 1px divider, then a single scrolling content area with a placeholder.
Wiring (select-tab / open-settings, nav, mount) is unchanged — only the
.slint chrome was rebuilt.
cc
Add the frontend-agnostic contract for the source-aware playback context:
- qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache
/ Local / Plex; parses the existing stringly-typed `source` field, defaults
to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded /
None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both
without changing the wire format. `PlaybackSource::is_qobuz_streamable()`
seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable).
- qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves
cover bytes per origin — HTTP/Plex through the disk cache, local files read
directly, embedded bytes in place. This fixes the root cause of local/Plex
artwork never reaching the UI (the loader was HTTP-only). The URL-only
`fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are
unchanged. The now-playing loader now takes an `ArtworkRef` derived from the
queue track, so local-library/Plex covers reach the now-playing bar.
Groundwork for the Local Library Slint port (source-aware playback context
designed in from the start). qbz-models tests pass; qbz-slint cargo check green.
cc
Move the Plex LAN client, hand-rolled XML metadata parsing, and the
SQLite plex_cache.db logic out of src-tauri/src/plex/mod.rs into a new
qbz-plex core crate so it can be shared by every frontend (Tauri, Slint,
TUI) per ADR-006. Plex previously lived entirely in src-tauri (~1715
lines), which blocked the Slint Local Library port from shipping Plex.
The extraction is mechanical: the module was already almost entirely
plain Rust (reqwest + rusqlite + serde + hand-rolled XML). The only
Tauri coupling was a dead plex_play_track command bound to AppState
(unreferenced; the live path is commands_v2::v2_plex_play_track, which
calls plex_resolve_track_media + AppState.player itself). That dead fn
and the vestigial #[tauri::command] attributes are dropped.
src-tauri/src/plex/mod.rs becomes a `pub use qbz_plex::*;` re-export so
every existing crate::plex::* call site compiles unchanged. Full Tauri
workspace cargo check stays green; qbz-plex tests pass.
cc