Commit Graph
721 Commits
Author SHA1 Message Date
vicrodh 82b7ce79fb refactor(qconnect-app): hoist build_effective_renderer_snapshot for cross-frontend reuse
Move the pure build_effective_renderer_snapshot helper from the Tauri adapter
into qconnect-app/src/session.rs (next to build_session_renderer_snapshot),
export it from the crate root, and repoint the Tauri side to the re-export.
Byte-identical behavior; enables the Slint controller path to reuse it.

cc
2026-06-03 00:41:50 -06:00
vicrodh b0274c39d4 feat(qbz-slint): move QConnect diagnostics to Settings > Developer
Relocate the QConnect diagnostics launcher out of the now-playing bar flyout
into a new opt-in Settings > Developer section (the modal + DEV log/status code
is unchanged). Also make the status block selectable (read-only TextEdit) so
session_uuid etc. can be copied, and relabel the event log.

cc
2026-06-03 00:36:59 -06:00
vicrodh 44876d01be feat(qbz-slint): Qobuz Connect renderer + controller integration
Port the QConnect adapter to the Slint frontend so qbz acts as a full peer
(renderer + controller), inheriting the shared qconnect-app hardening.

- Device-info / join payloads + transport config (piece a).
- SlintQconnectEventSink: queue materialize / renderer-command apply /
  session-management via the shared qconnect-app orchestration; renderer
  ready-report on SetActive(true).
- SlintSessionLoopHost + service connect recipe (subscribe-before-spawn) +
  controller bootstrap + deferred renderer-join; connect/disconnect toggle
  wired in the now-playing bar flyout.
- Renderer sync: outbound RndrSrvrStateUpdated reports (position / current
  queue item / file+device audio quality) while qbz is the active local
  renderer, and inbound UI refresh (now-playing card + queue sidebar) after
  remote queue / state changes.
- Controller-side queue push: when the local queue changes while connected,
  push it to the session (QueueLoadTracks) with all-or-nothing admission
  (refuse mixed local/Plex queues; offline qobuz_download is eligible) and
  echo suppression against the last-applied / last-pushed queue.
- Error + lifecycle toasts; DEV diagnostics modal (live session status +
  selectable rolling event log) opened from the connect flyout.

cc
2026-06-03 00:11:37 -06:00
vicrodh 3861efa79f refactor(qconnect-app): hoist renderer-projection helpers from the Tauri adapter
Move build_session_renderer_snapshot, queue_item_snapshot_for_cursor and
cache_renderer_snapshot into qconnect-app (session.rs / sync_state.rs) and
re-export them; repoint the Tauri qconnect adapter to consume them. This lets
the Slint adapter share one definition (ADR-006) instead of duplicating the
projection logic. Behavior is byte-identical.

cc
2026-06-03 00:11:21 -06:00
vicrodh 8e61568dc0 feat(qbz-slint): QConnect transport config + credential discovery (slice 6, phase S)
Ports the Tauri qconnect/transport.rs config layer to Slint: resolve_transport_config
(env overrides -> qws/createToken auto-discovery -> WsTransportConfig with require_jwt
+ 60s idle-retry + default channels) and fetch_qconnect_transport_credentials (POST
/qws/createToken with X-App-Id / X-User-Auth-Token), plus the device-identity helpers
(uuid via shared qbz_app::qconnect_identity, hostname, friendly name, persisted
device-name in qconnect_settings.db) and the hex-channel parsers.

Frontend difference handled: the Qobuz client is reached via runtime.core().client()
(Arc<RwLock<Option<QobuzClient>>>, may be None before API init) and is gated/ok_or'd,
unlike Tauri's always-present AppState.client. Uses qbz_qobuz::endpoints::build_url
(same base) instead of the Tauri-local one.

Deps added: qbz-qobuz (build_url), qconnect-transport-ws (WsTransportConfig /
NativeWsTransport). allow(dead_code) until the service wires it. cargo check
-p qbz-slint green (only the pre-existing unrelated rename_folder warning).

cc
2026-06-02 20:52:51 -06:00
vicrodh 88e72c3e61 feat(qbz-slint): add QConnect renderer engine over QbzCore (slice 6, phase S)
Phase S steps 9-10. Makes qbz-Slint a QConnect renderer: SlintRendererEngine
implements qconnect_app::QconnectRendererEngine over runtime.core(), so the Slint
frontend inherits the shared echo/cursor/materialize/shuffle orchestration in
qconnect_app::renderer instead of re-deriving it.

- Deps added to qbz-slint: qconnect-app, explicit qbz-player, futures-util, uuid.
- 16 trait forwards through runtime.core() (CoreError mapped to String) +
  current_output_format reading player().state.
- start_track_stream + the HTTP feeder (probe FLAC -> play_streaming_dynamic,
  fallback download -> play_data) ported from the Tauri CoreBridge impl. The
  protected bit-perfect seams are only called, never modified; the probe-derived
  sample_rate/channels/bit_depth flow straight through.
- TLS deviation: the crates-workspace reqwest ships rustls-tls (not native-tls),
  so .use_native_tls() is dropped. TLS is transport-only; audio bytes identical.

allow(dead_code) until the Slint QConnect service + event sink wire it (next
Phase S step). Verified: cargo check -p qbz-slint green (only a pre-existing
unrelated rename_folder dead-code warning).

cc
2026-06-02 20:27:44 -06:00
vicrodh 37cd1b154b test(qconnect-app): mock-engine tests for echo/cursor/materialize (slice 6)
Step 8 of the renderer-engine extraction — the payoff of sharing. A recording
MockRendererEngine drives the renderer orchestration end-to-end, proving the
hard-won behavior is engine-independent (any future Slint regression is a wiring
bug in its trait impl, not a re-derivation bug in the shared logic). This logic
previously could only be exercised through the Tauri adapter.

Six scenarios:
- load dedup window: two ensure_remote_track_loaded for the same track within
  5s -> start_track_stream once; no reload when track_id unchanged.
- #387 echo-seek: same-track SetState at <=1s while local is ahead -> seek
  rejected; a genuine far seek IS honored.
- stale-projected-track guard: state-only SetState (command.current_track=None)
  -> no align / no load from renderer_state's stale track; pause still applied.
- shuffle deferral: first event (shuffle_mode=true, order=None) -> shuffle_enabled
  false (no invented identity order); second event with order -> enabled.

qconnect-app 38 -> 44 tests; all green, no warnings.

cc
2026-06-02 19:46:41 -06:00
vicrodh a2d0df8b30 refactor(qconnect): hoist audio-quality report dedup into qconnect-app (slice 6)
Step 7 of the renderer-engine extraction. Moves report_file/device_audio_
quality_if_changed (dedup-against-last-reported + build RendererReport + send)
out of the Tauri service into QconnectApp methods, so the Slint report-back loop
reuses them. The Tauri service methods are now thin delegates that extract
runtime.app and forward.

Byte-identical: runtime.sync_state and the Arc passed to QconnectApp::new (the
one sync_handle() returns) are clones of the SAME Mutex (service.rs:232/242/277),
so the dedup state and the report build/send are unchanged; only the owner of
the code moved. send_renderer_report_command + the last_reported_* fields were
already in qconnect-app.

Verified: cargo check -p qbz-nix clean (no warnings); qbz-nix qconnect 42/42 +
qconnect-app 38/38 green.

cc
2026-06-02 19:39:25 -06:00
vicrodh b21d45c2f6 refactor(qconnect): hoist renderer orchestration behind engine trait (slice 6)
Step 6 of the renderer-engine extraction. Moves the renderer orchestration
out of the Tauri adapter UP into frontend-agnostic qconnect-app, rewritten
against &impl QconnectRendererEngine instead of &CoreBridge, so the Slint
adapter reuses the hard-won echo/cursor/materialize/shuffle logic instead of
re-deriving it.

Moved into qconnect_app::renderer (byte-for-byte, bridge -> engine):
apply_renderer_command (incl. the #387 echo-seek gate, the stale-projected-
track guard, force-restart-removed reasoning), materialize_remote_queue (skip-
gate, dedupe+batch hydrate, 3-tier start-index, shuffle deferral
effective_shuffle_enabled = shuffle_mode && order.is_some(), cursor-align +
stale-local-stop), sync_remote_shuffle_projection, align_queue_cursor,
apply_remote_loop_mode, ensure_remote_track_loaded (+ should_reload /
is_recent / queue_state_needs_materialization, LOAD_ATTEMPT_DEDUP_WINDOW,
PLAYING_STATE_* consts). The sync_state lock-drop-across-await discipline is
preserved; last_load_attempt is still recorded before the load.

Stays adapter-side: corebridge.rs is now thin wrappers that unwrap the
Arc<RwLock<Option<CoreBridge>>> guard and delegate (event_sink/service call
sites unchanged). In operation the bridge is populated at startup before any
QConnect session opens, so the guard-before-skip reorder is unreachable;
behavior is byte-identical. ensure_remote_track_loaded now resolves duration
via get_track then calls engine.start_track_stream (which owns get_stream_url +
the protected feeder); the two independent catalog reads swap order, benign.
mod.rs re-exports PLAYING_STATE_* from qconnect-app.

Verified: cargo check -p qbz-nix clean (no warnings); qbz-nix qconnect 42/42 +
qconnect-app 38/38 green; Opus adversarial review CLEAN across byte-identity,
guard-reorder, lock-discipline, ensure-reorder, Send/generics, re-exports.

cc
2026-06-02 18:59:34 -06:00
vicrodh 76a9169e2f refactor(qconnect): hoist queue-track / volume / loop-mode mappers into qconnect-app (slice 6)
Move the pure protocol mappers model_track_to_core_queue_track,
normalize_volume_to_fraction, qconnect_repeat_mode_from_loop_mode (+ the
QCONNECT_REMOTE_QUEUE_SOURCE tag) into qconnect-app/src/renderer.rs so the
renderer orchestration that calls them (hoisted in a later step) can live there
too. The src-tauri mod.rs re-exports them, so corebridge + tests compile
unchanged. The load-dedup predicates and audio-quality report helpers move
alongside their orchestration / report consumers in the next steps (cohesion +
avoids churning the AUDIO_QUALITY_* consts now).

src-tauri qconnect 42 green.

cc
2026-06-02 17:16:41 -06:00
vicrodh 1133b8b45d refactor(qconnect): hoist queue_resolution into qconnect-app (slice 6)
queue_resolution.rs is pure protocol math (queue_item_id/track_id/order cursor
reconciliation) over QConnectQueueState/QConnectRendererState only -- no engine,
no Tauri. Relocate it to qconnect-app so the renderer orchestration that calls it
(materialize / cursor-align, hoisted in a later step) can live there too. The
src-tauri module becomes a `pub(super) use qconnect_app::queue_resolution::*;`
re-export shim, so every `super::queue_resolution::…` call site + the existing
tests compile unchanged. Visibility widened pub(super)->pub for the re-export.

src-tauri qconnect 42 green (tests now exercise the moved code via the shim),
qconnect-app check green.

cc
2026-06-02 17:06:28 -06:00
vicrodh e5e8d4a853 feat(qconnect-app): add QconnectRendererEngine trait + qbz-player dep (slice 6)
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
2026-06-02 16:43:08 -06:00
vicrodh 6b26ea0e5e fix(qconnect): subscribe transport events before the session-loop spawn (slice 5)
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
2026-06-02 16:32:08 -06:00
vicrodh a07bb36327 feat(qconnect): factor transport loop into QconnectApp::run_session_loop (slice 5)
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
2026-06-02 16:23:46 -06:00
vicrodh e8ea401af3 refactor(qconnect): move loop helpers to qconnect-app (slice 5)
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
2026-06-02 16:05:20 -06:00
vicrodh 77717546fb feat(qconnect): session-apply + watchdog API on QconnectApp (slice 2+4)
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
2026-06-02 13:34:03 -06:00
vicrodh 7b143c2738 feat(qconnect): own remote-sync Mutex on QconnectApp via new(transport, sink, sync) + sync_handle (slice 2+4)
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
2026-06-02 13:22:41 -06:00
vicrodh a79971e470 refactor(qconnect): relocate QconnectRemoteSyncState struct to qconnect-app (slice 2+4)
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
2026-06-02 12:58:51 -06:00
vicrodh 979a09e7b2 refactor(qconnect): move session mutators to qconnect-app with injected identity (slice 2+4)
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
2026-06-02 12:47:10 -06:00
vicrodh b6ddfc1b5f refactor(qconnect): relocate session topology types to qconnect-app (slice 2+4)
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
2026-06-02 12:19:15 -06:00
vicrodh 1ad0d7c169 feat(slint): classic now-playing bar layout
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
2026-06-02 11:32:01 -06:00
vicrodh da7758b9d6 feat(slint): new now-playing bar layout
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
2026-06-02 06:48:36 -06:00
vicrodh c763d5b2fe fix(slint): reconcile now-playing card to the live track on seamless change
The poll loop's seamless-transition branch guessed whether a track-id change
while still playing was a gapless hand-off via a peek_upcoming(1) == track_id
heuristic. When that heuristic missed (queue pointer lagging the engine), the
meta was never refreshed: the seek bar kept advancing while the title/art/album
stayed on the previous track (the reported now-playing populate bug).

Replace the heuristic with reconciliation to what is ACTUALLY playing. New
frontend-agnostic primitive QueueManager::sync_current_to_id(id) moves the queue
pointer to the track the engine advanced to (recording history + keeping the
shuffle cursor aligned) and reports whether it moved; QbzCore wraps it and emits
a queue update only on a real move. The poll loop now syncs to the live track_id
and always refreshes the card, doing the record/sidebar/prefetch bookkeeping only
on a genuine advance (a manual play already moved the pointer, so it is idempotent).

cc
2026-06-01 13:29:21 -06:00
vicrodh 63d9f6b496 refactor(qconnect): route liveness/lifecycle/diagnostic emits through event sink (slice 3)
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
2026-06-01 09:20:12 -06:00
vicrodh f907d9d984 refactor(qconnect): move device_uuid persistence to qbz-app (slice 1)
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
2026-06-01 09:03:13 -06:00
vicrodh f100cf4d75 refactor(qconnect): relocate pure session/liveness fns to shared crates (slice 0)
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
2026-06-01 08:55:04 -06:00
vicrodh 03a551e6ff feat(qconnect): real duration/queue_item_ids in join report + DeviceAudioQualityChanged(27)
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
2026-06-01 04:29:18 -06:00
vicrodh 85a811c834 feat(qconnect): recover from terminal states via idle-retry + startup backoff
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
2026-06-01 03:50:18 -06:00
vicrodh 7628c284a6 feat(qconnect): detect half-open WS via keepalive pong-timeout
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
2026-06-01 03:43:13 -06:00
vicrodh 05d641d871 feat(qconnect): surface missing AUTHENTICATE JWT as a hard credential error
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
2026-06-01 03:42:09 -06:00
vicrodh fea9dbe1d4 feat(qconnect-app): emit PlaybackError app event from renderer failure
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
2026-06-01 03:23:03 -06:00
vicrodh d865097a4c feat(qconnect-protocol): decode PlaybackErrorMessage + ErrorType
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
2026-06-01 03:20:09 -06:00
vicrodh 92695c222a feat(qconnect): queue_hash divergence scaffold + AskForQueueState trigger
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
2026-06-01 03:17:09 -06:00
vicrodh 3ac68e788a feat(qconnect): surface server queue_hash onto queue state
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
2026-06-01 03:16:16 -06:00
vicrodh 452f961977 fix(qconnect): force AskForQueueState resync on QueueTracksAddedFromAutoplay
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
2026-06-01 03:09:17 -06:00
vicrodh c657b80271 refactor(qconnect): tighten cast-predicate test, dedupe test fixture, document shared-gate intent
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
2026-06-01 02:00:12 -06:00
vicrodh a16d6d0c81 qconnect: allow offline-cache to be cast (one consistent rule)
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
2026-06-01 01:52:22 -06:00
vicrodh 499b77879d qconnect: strict source parse for admission (unknown -> blocked)
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
2026-06-01 01:49:15 -06:00
vicrodh f3686c197a qconnect: make PlaybackSource the shared cast predicate (is_castable_to_qconnect)
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
2026-06-01 01:48:42 -06:00
vicrodh 34eaebd8ef feat(slint): play-from-clicked-track, track-row quality badge, album link, playlist layout
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
2026-05-31 21:50:51 -06:00
vicrodh aa226bbbd6 feat(slint): separate the artist portrait from the JUMP TO bar
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
2026-05-31 21:50:29 -06:00
vicrodh 2dd6e03321 fix(slint): switch and render the Queue panel History tab
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
2026-05-31 21:50:29 -06:00
vicrodh d1ebf8ea1c feat(audio): release/refresh output-device button (#263)
After bit-perfect ALSA Direct grabs a DAC exclusively (hw: + D-Bus
reservation), the device stays sequestered and is invisible to PipeWire,
so switching back to the PipeWire backend can't see it without restarting
the app. Tauri had a refresh affordance for hot-plugged DACs; this is the
refresh plus an explicit release, in one button.

Backend: Player::release_device() / AudioCommand::ReleaseDevice tears the
stream down immediately (cancelling the deferred drop), freeing the hw:
grab + its reservation, then un-suspends the parked sink and resets any
forced graph clock (both self-gating). It does NOT reopen, unlike
reinit_device which would re-grab the device.

UI: a refresh-cw icon button beside the Output device select, propagated
through the settings callback chain to handle_release_device, which
releases, waits for PipeWire to reclaim, then re-enumerates so a freed or
hot-plugged DAC reappears without an app restart.

cc
2026-05-31 17:41:01 -06:00
vicrodh bdb9cadad6 feat(ui): speaker glyph + searchable output-device select
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
2026-05-31 16:50:29 -06:00
vicrodh c75a0af5b2 feat(audio): dedup output-device list, keep null sink last
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
2026-05-31 16:50:29 -06:00
vicrodh 68b6cfbcfa feat(audio): native JACK backend, functional end to end (#263 Tier 3)
Completes the JACK output on top of the foundation (fbc03f41):
- StreamType::Jack + the player dispatch (backend==Jack -> JackStream).
- PlaybackEngine::Jack: a feeder thread (mirrors AlsaDirect) that resamples each
  source to the JACK graph rate (UniformSourceIterator, stereo) and writes it to
  a lock-free f32 SPSC ring (ringbuf); wired at the 4 engine-creation sites.
- jack_backend.rs: f32-native ring (fixes a byte-alignment white-noise bug) plus
  auto-connect of qbz:out_FL/FR to the physical playback on activation, so audio
  reaches the hardware without a patchbay (a user may re-patch freely).
- Re-enabled the available_backends push (JACK is now offered in the selector).

Verified on hardware: select JACK -> QBZ registers as client 'qbz' with stable
ports qbz:out_FL/FR (client.api=jack) -> music plays through the DAC, resampled
to the graph rate (NOT bit-perfect; BitPerfectMode::Disabled, as designed). The
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.

Slice 3.2 (pending, separate): JACK<->passthrough/exclusive mutual-exclusion UI,
the bit-perfect-OFF note, runtime graph-rate-change callback, mono/multichannel.

cc
2026-05-31 14:09:45 -06:00
vicrodh fbc03f41b7 feat(audio): JACK backend foundation (#263 Tier 3 — player wiring pending)
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
2026-05-31 12:36:37 -06:00
vicrodh d2c4aaf99c feat(audio): route locked output to the selected sink via PIPEWIRE_NODE (#263)
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
2026-05-31 11:31:33 -06:00
vicrodh 64fb77ade1 fix(audio): resume the suspended sink after exclusive access (#263 leak)
Exclusive-mode device acquisition suspends the default PipeWire sink
(pactl suspend-sink @DEFAULT_SINK@ 1) on the EBUSY-retry and CPAL-exclusive
paths, but never resumed it — leaving the rest of the system on a suspended
sink after exclusive playback.

Add suspend_default_sink_for_exclusive() (resolves + records the suspended
sink name) and resume_suspended_sink() (resumes that exact name; PipeWire sink
names are deterministic, so resume-by-name survives a vacate/re-create). The
player resumes on the pause-suspend teardown — after the exclusive stream is
actually dropped, never while the device is still held. Self-gating: a no-op
unless QBZ suspended a sink, so it is safe to call on every stop.

Does not touch the bit-perfect path. Verified no regression on hardware:
exclusive mode plays through the DAC (LED-confirmed) and system audio uses the
onboard sink (not stuck suspended). The suspend/resume paths themselves
(EBUSY race / CPAL-exclusive fallback) are rare and were not exercised in that
test; the fix is correct-by-construction and cannot affect bit-perfect.

cc
2026-05-31 10:58:16 -06:00
vicrodh b139392b9b fix(audio): reset clock.force-rate on stop for all forced sessions (#263 leak)
QBZ forces the global PipeWire clock for any non-locked PipeWire stream, but the
stop/pause-suspend reset was gated on pw_force_bitperfect — which is only set in
DAC-passthrough mode. So a plain PipeWire user (no passthrough) forced the graph
clock on play and never reset it, leaving the whole graph force-clocked after
stopping (a stale clock.force-rate that resamples every other app).

Track whether QBZ actually wrote the force (CLOCK_FORCE_APPLIED) and make
reset_pipewire_clock self-gating: it only resets a force WE applied, never
clobbering another app's forced rate. The player's two reset call sites (Stop,
pause-suspend) now call it unconditionally.

Verified on hardware (Cambridge USB DAC, PipeWire + passthrough off): a 192kHz
track forces clock.force-rate=192000; on pause-suspend the clock returns to 0
(previously stayed stuck at 192000).

cc
2026-05-31 09:57:56 -06:00