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