The Slint-side seam over the qbz-cast crate, modeled on qconnect_service.rs.
cast_service.rs (singleton):
- discovery (mDNS + SSDP) with a 2s device-refresh loop and 15s scan window;
- connect/disconnect: snapshot local playback, stop the local backend, suspend
QConnect (remember + restore), connect, then re-cast the current track
passing its REAL source (fixes the Tauri resume-source bug) + deferred seek;
- cast_track: source-routed (qobuz via the Phase-0 shared resolver at
UltraHiRes; local via register_file streaming + rich MIME; plex = TODO),
one shared lazy MediaServer, per-device source-IP URL, Chromecast auto-play
vs DLNA two-step load->play;
- 1s position poll mirroring position/duration/elapsed to the bar + CastState,
with the verbatim track-end heuristic (Chromecast ->IDLE, DLNA ->STOPPED/
NO_MEDIA_PRESENT) driving auto-advance;
- delivered-quality (D3) pushed to CastState; shutdown() for logout/exit.
state.slint: CastState global + CastActions + CastDevice (tagged record with
DLNA capability flags); re-exported in app.slint.
main.rs: init + CastActions wiring (open/close/refresh/connect/disconnect) and
transport gating — play/pause/next/previous/seek/volume now route Cast >
QConnect > local.
Compiles clean (only stop_if_cast/shutdown unused pending the stop callback +
logout/exit wiring, next).
cc
Slint 1.16's femtovg-wgpu renderer configures the wgpu surface via
get_default_config() and leaves the composite alpha mode at Auto, which
resolves to an opaque surface on KWin/Wayland — a borderless transparent
window then shows a black rectangular backing behind the rounded card.
WGPUConfiguration exposes no surface/alpha hook, so the only fix is a
renderer patch.
Vendor i-slint-renderer-femtovg-1.16.1 with a preferred_alpha_mode() that
picks a supported non-opaque mode (PreMultiplied / PostMultiplied /
Inherit, guarded by the surface's advertised alpha_modes, else Auto) and
wire it into set_surface(). Pulled in via [patch.crates-io]; Slint stays
pinned =1.16.1. The main window is unaffected (it paints opaque).
cx
The wgpu underlay spike validated the GPU-shader path, but femtovg-wgpu has a
high per-frame CPU cost on macOS/Metal: continuously-animating views
(ImmersiveView atmosphere, Discover) hit 100-120% CPU on a Mac mini M2 vs
0.5-4% for the same view on Linux. Skia is Slint's mature Metal renderer and
does not have this cost (immersive ~55%, never 100%); text renders correctly.
Static views stay lazy/idle on both renderers.
Choose the renderer per-platform: Linux/Windows keep renderer-femtovg-wgpu
(cheap per-frame there + the femtovg text fixes #5177/#11335); macOS (Intel and
Apple Silicon, CICD included) uses renderer-skia. Both keep unstable-wgpu-28 and
resolve to a *-wgpu renderer, so the shader underlay (require_wgpu_28 +
Image::try_from) works unchanged on both.
Cargo.lock updated with the Skia dependency graph.
cc
Swap the winit renderer femtovg(GL) -> femtovg-wgpu (+ unstable-wgpu-28,
slint pinned =1.16.1) and render a WGSL plasma fragment shader into a wgpu
texture composited under ImmersiveView. The shader is fed by the existing
30fps visualizer drain (sub-bass energy + decayed transient) and toggled
in-view with the 'g' key via ImmersiveState.shader-mode; when active it
replaces the atmosphere + mode panels as the bottom layer, chrome stays on
top. Device/queue captured from Slint's rendering notifier so Image::try_from
shares Slint's wgpu device.
Phase 0 spike: validates the app-wide renderer swap with no font/UI/tray
regression. Pending macOS Metal smoke before the swap lands on slint-mvp.
cc
Foundation for the ImmersiveView Slint port. Authored by Codex and accepted by
the owner after live visual iteration; committed here so the panel work builds
on top of it. The Album Reactive foreground was intentionally left out (a prior
attempt was rejected) so it can be rebuilt cleanly on this base.
Audio-transport lift (slice 1):
- qbz-audio: VizFrame/VizSink trait + spawn_visualizer_thread; FFT producer
lifted off tauri::AppHandle. Protected audio path untouched (seam =
RingBuffer.snapshot).
- src-tauri: fft_processor gutted to a thin TauriVizSink emitting byte-identical
viz:* events.
- qbz-app: AppRuntime::with_visualizer + visualizer_tap accessor.
- qbz-slint: SlintVizSink + install() publishing VisualizerState
bars/spectral/energy/waveform/transient at 30fps, zero IPC.
Immersive base (slice 2a):
- immersive.rs: host-side atmosphere generation from decoded artwork
(8x8 sample -> upscale -> blur -> saturate/contrast/normalize -> vignette),
mirroring Tauri texture-loader.ts::generateAtmosphere; plus glow_color.
- ImmersiveAtmosphere.slint: reusable animated atmosphere (4 counter-drifting
copies via animation-tick); usable static for Album/Artist headers later.
- ImmersiveView.slint: base overlay (atmosphere + auto-hide Tauri-shaped compact
player only). Foreground panels are rebuilt on this base.
- AppShell mounts the overlay; ViewMode menu opens Immersive in PlayerBar/Small.
cc
Add-to-playlist (Slices 0-3):
- qbz-core: check_playlist_duplicates + pure compute_playlist_duplicates
(reuses qbz_models::PlaylistDuplicateResult) + unit tests.
- PlaylistDuplicateConfirmModal (Add all / Add new only / Cancel); the
duplicate check runs on the Qobuz->Qobuz picker branch only (local/Plex
add directly, mirroring Tauri); the track-id Vec lives Rust-side in a
stash, never in Slint.
- Inline create-and-add in the picker (online create_playlist, offline ->
local playlist per D8) + client-side filter (Slint 1.16 has no string
contains, routed through a Rust callback) + success toasts.
Playlist importer (1:1 Tauri port):
- New headless crate qbz-playlist-import (Spotify/Apple/Tidal/Deezer
scrapers, Qobuz matcher, importer, progress sink, shared http client;
copy-not-move from src-tauri, zero tauri deps; 40 unit tests green).
- PlaylistImportModal (two-step fetch-preview -> import with live progress
bar / log / summary) + controller + sidebar PLAYLISTS menu entry point.
Reco logging staged behind TODO(reco-v1) markers (golden rule: playlist
recommendations ship in Slint v1 once the reco engine is extracted to a
shared crate).
cc
S3 of the Lyrics epic: the qbz-lyrics engine reaches the Slint shell —
per-user cache binding, the track-edge fetch rider, the LyricsState
global, and the real sidebar body replacing the placeholder.
- Glue (src/lyrics.rs, offline_mode/fav_cache template): process-global
LyricsService over the core client lock (read at call time, never
cached); init_for_user on ALL session activation paths (browser login,
saved-session restore, offline entry) binding the SAME per-user
lyrics/lyrics.db Tauri uses; teardown on logout.
- Fetch rider on the NOTIFY_LAST_TRACK dedupe edge in
refresh_now_playing_meta, next to scrobble/notify — prefetch on every
track change regardless of panel visibility (Tauri parity,
lyricsStore.ts:545-565) and deliberately NO skip-if-remote (Q7: lyrics
follow the QConnect peer). Track->null resets the state. Request
carries source kind (qobuz_download counts as Qobuz, real catalog id)
+ the offline engine verdict; raw title to the engine (F4).
- Stale guard (F2): responses echo the request identity and are dropped
unless they still match the latest requested track. Duplicate-fetch
guard mirrors Tauri's lastFetchedTrackId (skip only while loaded).
Parsed doc (native wsync words) held Rust-side for the S4 engine.
- LyricsState global: line model (text, time-ms, end-ms, has-words),
status (idle/loading/ready/no-lyrics/error/offline), synced, provider
+ attribution label, header meta, active-index placeholder (-1), and
panel-opened (fetch-on-open via conditional-mount init).
- LyricsLinesView (shared, miniplayer-ready): 280px-adapted typography
per the owner Q3 spec - 13/15px reduced scale, 0.2px letter-spacing,
6px side padding, full-width wrapped text; dimming ladder on the INNER
Text (never a root opacity binding); centering spacers from the
Flickable's own height; plain mode = static top-aligned at 0.85;
fallback-provider attribution line (Qobuz first-party = none).
- Sidebar 1:1 header (mic tile 36px, LYRICS overline 11px/1.5px per the
alignment standard, title-artist meta, sliders trigger for the S5
flyout, close kept) + state bodies incl. the typed NotAvailableOffline
as a translated string (F3). All new strings @tr()-wrapped.
Sync engine (position, active line, auto-center, karaoke fill) is S4.
cc
S2 of the Lyrics epic: new shared crate crates/qbz-lyrics (headless,
frontend-agnostic per ADR-006), the whole fetch/parse/cache engine in one
testable home for Slint, Tauri and the TUI.
- Domain model: LyricsDoc/LyricsLine/Word (native Qobuz wsync word stamps
preserved) + the Tauri-wire-compatible LyricsPayload (camelCase serde);
LyricsProvider gains the qobuz variant and round-trips it.
- LRC parser ported from the Tauri TS semantics (gap markers as end-of-vocal,
8s MAX_SUNG cap, median last-line end) with the F5 defects fixed forward:
multi-stamp lines split per stamp, [offset:] honored. Emitter renders wsync
line bounds as LRC-with-gap-markers (Q5 persistence form, lossy at word
level only).
- Providers ported verbatim from src-tauri/src/lyrics/providers.rs: LRCLIB
search-first + scorer and lyrics.ovh, request shapes byte-identical.
- Cache: same per-user SQLite schema/path Tauri uses (WAL per ADR-002,
no TTL) plus the additive qobuz_wsync_json column (amended Q5) with an
idempotent in-place migration for Tauri-era DBs; readers prefer wsync.
Stats read entries AND size from the per-user path (F1 fix).
- Orchestrator LyricsService: cache probe with uniform plain-only soft miss
(Q4) -> Qobuz primary (wsync authoritative; plain held while LRCLIB is
probed for synced, the no-sync-regression rule) -> LRCLIB with one retry
on transport error -> lyrics.ovh; offline = cache-only with typed
NotAvailableOffline (F3); in-flight dedupe via shared futures (F6);
request-key echo on every response for the caller stale guard (F2).
- 55 unit tests: parser/emitter round-trips, scorer matrix, cache lifecycle
+ schema migration, chain priority/offline/dedupe with mocked providers.
cc
Add /track/lyricsUrl (signed RPC, method name "tracklyricsUrl") plus
the CDN document fetch through the offline choke point. Wire shapes
were validated against live captures (2026-06-10) and diverge from the
inferred yaml: numeric track_id in the step-1 envelope, CDN document
wrapped in {translation_langs, publishers, writers, original}, "wsync"
word-synced discriminator instead of "lsync" (per-word timestamps),
plain lines as objects, and miss = HTTP 404 error body mapped to a
typed Ok(None) instead of an error. Unit tests deserialize fixture
excerpts of the captures; examples/lyrics_probe.rs is the read-only
live probe used for the capture.
Also expose qbz_credentials::load_oauth_token_from_file so a stale
keyring entry cannot shadow the authoritative encrypted token file.
cc
New qbz_app::offline_mode (frontend-agnostic, ADR-006): three-state mode
machine (Online / RealOffline session-scoped / InducedOffline persisted),
Tauri-compatible offline_settings.db store with the issue #279
stream_first_track snapshot, and a layered connectivity actor (default-route
signal on Linux, audio liveness window, IP-literal + vendor-diverse strict
probes, asymmetric hysteresis with confirmation burst, captive-portal and
suspend/resume handling).
qbz-qobuz gains the process-wide offline_gate and ApiError::OfflineMode (the
single Qobuz choke point; HTTP funnel wiring lands next).
SubscriptionStateStore gains offline_playback_allowed: binary 30-day-grace
playback gate for the offline cache, never a preview-degradation path.
cc
Phase-2 Slice 10.
- ui/myqbz/MyQbzMixModal.slint + src/myqbz_mix.rs: the DJ-mix "random queue"
modal. Open resolves the collection in-order + shuffle::unique_track_count
(deterministic) for the slider max (buildSizeOptions). Shuffle re-resolves,
runs dedup_by_similarity + hybrid_sample with a thread RNG kept in a SYNC scope
(dropped before the next .await -- !Send confinement), then replace-plays +
stamps queue-source + touch_play; toasts "Playing N of M" when the per-album cap
shrinks the pool. Wired on_dj_mix.
- Detail bulk bar (MultiSelectBar, bottom-docked): add-selected -> AddToMixtapeModal
with the selected payloads; remove-selected -> repo::remove_item (highest position
first so compaction does not invalidate pending positions) -> reload + clear
selection + toast.
Deferred to polish: the bulk bar's queue / add-to-playlist / clear-X groups (§13.1).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 2 of the MyQBZ port. Wires the mixtape DB + the first
visually-reachable surface in Slint (read-only):
- Migrations: run qbz_mixtape::schema::run_mixtape_migrations in enter_shell
(via library_db with_db -> with_connection), best-effort, on session open —
same per-user library.db the Tauri build migrates.
- Glue: new src/myqbz.rs mirrors playlist_manager.rs — list_collections via the
shared crate (items hydrated), model/sort/filter builders, mosaic artwork jobs,
navigate. NavEntry::Mixtapes/Collections + back/forward + scroll-restore scopes.
- UI: ui/myqbz/{MixtapesView,CollectionsView,CollectionMosaic,MyQbzShared}.slint,
1:1 with specs 10/11 (208px grid, mosaic custom->2x2/3x3->kind-glyph, toolbar,
both empty states, ICU plural). MixtapeCardItem + MyQbzState/MyQbzActions in
state.slint; mounted in AppShell. My QBZ sidebar entry routed to the two grids
(branded mask-tinted icons), matching the port's existing flyout-nav idiom.
Read-only boundary: create/open-detail are logging stubs; titlebar My QBZ
dropdown and the Settings>Appearance rename (DQ3) are deferred to later slices.
Backend crates untouched. cargo check -p qbz-slint green.
cc
New crate crates/qbz-mixtape holding the Mixtapes+Collections backend, ported
1:1 from src-tauri/src/mixtape and re-typed against the real shared crates so it
runs headless (no tauri::State, no #[tauri::command] wrappers) per ADR-005/006:
- schema.rs / repo.rs / shuffle.rs copied verbatim (SQLite schema + migrations,
CRUD repository, DJ-mix similarity sampler). enqueue.rs re-typed to
qbz_qobuz::QobuzClient, qbz_library::LibraryDatabase and qbz_plex, dropping the
src-tauri crate::api / crate::library shims.
- ItemResolver is frontend-agnostic: ProdItemResolver takes a caller-supplied
sync local-resolution closure so &LibraryDatabase (!Sync) never crosses an
.await. resolve_local_item centralizes the full Local dispatch matrix
(Album / Track i64-parse / Playlist-unsupported) so frontends cannot drift.
- src-tauri left untouched: Tauri is removed at the end of the Slint port, so the
temporary duplication is intentional and zero-risk to the working Tauri build.
Adds qbz-mixtape to the workspace members + rand/strsim workspace deps.
Phase-2 Slice 1 of the MyQBZ Slint port. 57 crate tests pass.
cc
Plex cover images were fetched full-res on every surface (Slint built the raw
/library/.../thumb URL), where Tauri requests a server-transcoded thumb sized to
the render target. On a real library this made the views feel heavy and starved
the LAN bandwidth that track playback needs.
- qbz-models: ArtworkRef::PlexThumb gains a size: Option<u32>; new plex_thumb_url()
emits the Tauri-format transcode URL when Some
(/photo/:/transcode?url={enc}&width=N&height=N&minSize=1&X-Plex-Token=), raw
tokenized path when None (album/MPRIS full-res preserved). urlencoding dep added
to match encodeURIComponent. Tests extended.
- qbz-slint: each PlexThumb construction site passes its surface decode size
(grid/detail 264, queue/now-playing-item 96, now-playing bar 160, MPRIS full-res);
fetch_and_decode_ref uses plex_thumb_url.
- artwork.rs: static LazyLock<reqwest::Client> reused in fetch_cached_http instead
of reqwest::get (per-image client) — keep-alive + no fd churn (EMFILE smell).
Audio path untouched; local/Qobuz artwork unchanged.
cc
Fresh Slint-only Plex integration foundation (Tauri untouched; the Tauri
code is slated for deletion at port end, so creds are not shared):
- slice 0: fix the false Plex docs in local_library.rs
- slice 1: add the qbz-plex dependency to qbz-slint
- slice 2a: qbz-app/settings/plex.rs per-user store (10 fields, idempotent
4->11 column migration keeping creds, 8 tests) - frontend-agnostic,
no tauri::State (ADR-006)
- slice 2b: Settings > Local Library Plex section, 1:1 with the complete
Tauri feature - master toggle + collapse, LAN-only address gate, PIN auth
(2500ms poll, self-stopping on section-leave/expiry/disconnect),
manual-token fallback, ping + machine-id capture, multi-section pick
(default-all), disconnect vs clear-cache; once-per-session background
refresh (no cache_clear churn on section re-entry)
cc
Port the Tauri desktop-notification path to a frontend-agnostic crate so the
Slint app (and any future TUI/CLI) fires it from native Rust instead of a
webview invoke.
New qbz-media-controls::notify module:
- Linux: XDG notification portal via ashpd (D-Bus); album art passed as
Icon::Bytes after center-crop + downscale to <=512px + PNG re-encode (<=4 MiB).
- macOS: notify_rust with image_path (cover cached to disk).
- Windows: no-op (parity with Tauri).
- Self-contained artwork download + md5 cache + resize, matching the Tauri
helpers, with no dependency on the frontend (ADR-006).
Wired in playback::refresh_now_playing_meta (the same point that pushes the
tray tooltip + OS media controls):
- De-dupe by track id so only an actual track change fires (the refresh also
runs on resume/seek).
- Skip while a remote QConnect renderer drives playback (matches the Svelte
skipIfRemote). Fire-and-forget on a tokio task.
Enabled by default, matching the Tauri default; a settings toggle is a
follow-up.
cc
Replace the tray-icon/muda macOS backend with a hand-rolled NSStatusItem +
NSMenu built directly on objc2 0.5 / app-kit 0.2 (the same objc2 generation
winit 0.30 uses), so the menu lives in winit's NSApplication runtime and the
target-action dispatches. tray-icon's bundled muda collided with the muda
Slint's winit backend already registers and never fired the menu items. This
drops the tray-icon dependency and its whole GTK tree from the lockfile.
- Menu-bar icon pinned to 18pt via NSImage::setSize; the 44px PNG was
rendering at native pixel size (giant icon).
- The status button gets its own target-action firing on left AND right
mouse-up: left-click toggles the window, right/control-click pops the menu
transiently (setMenu -> performClick -> clear, the non-deprecated
replacement for popUpStatusItemMenu:). The menu is no longer permanently
attached, so a left-click no longer opens it.
- Add the NSEvent feature to objc2-app-kit.
cc
Port the Tauri normalizeCoverUrlForMetadata workaround as ArtworkRef::to_mpris_url
(qbz-models): remote Qobuz covers pass through as-is, local-file covers become a
percent-encoded file:// URI (MPRIS clients cannot read bare paths or asset://),
Plex thumbs become a tokenized HTTP URL. Wired into the Slint now-playing push so
the media widget shows album art. The app icon was already fixed via DesktopEntry.
cc
New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration
trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS
org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way
GNOME Shell resolves the app icon for its media widget (souvlaki, the old
cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient).
macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the
app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread
with its own tokio runtime; state arrives over an async channel.
Wired into qbz-slint via src/media_controls.rs: started on shell entry,
metadata/playback pushed from the playback paths (mirroring the tray), and
inbound control events (media keys, the desktop media widget, Now Playing)
dispatched to the player through the existing QConnect-aware helpers. Both
Linux and macOS compile clean.
cc
Add the NSStatusItem (macOS) / Shell_NotifyIcon (Windows) tray via the
tray-icon crate: the same 5-item menu, left-click window toggle, a themed
icon (auto uses a macOS template image that adapts to the menu bar), a
static QBZ tooltip, and live icon-variant switching. tray_icon::TrayIcon is
!Send and (on macOS) must be built on the main thread with NSApplication
running, so it lives in a thread_local created via invoke_from_event_loop;
its events are drained from a slint::Timer.
macOS close-to-tray reuses the cross-platform Slint hide/show path. The
opt-in Hide Dock icon toggle switches the NSApplication activation policy to
.accessory while closed to the menu bar (.regular by default, Spotify-style)
via objc2-app-kit.
cc
Port the Tauri tray to Slint on Linux via ksni: themed icon (auto/mono/
color), left-click window toggle, middle-click play/pause, scroll volume,
the 5-item menu, and a live tooltip reflecting the now-playing track. Tray
actions call the playback controller directly (QConnect-aware) instead of
emitting webview events. Settings reuse the shared qbz-app tray store
(per-user) wired through AppearanceState.
Window hide/show uses Slint window.hide()/show() (winit set_visible is a
no-op on Wayland) and the loop runs via run_event_loop_until_quit so a
tray-hide keeps the app alive. close-to-tray is honored on both the custom
titlebar close and the WM close (on_close_requested). minimize-to-tray is
hidden until custom window chrome exists.
cc
Shuffle and Repeat now go to the CLOUD whenever connected (renderer OR controller),
gating on transport_connected like Tauri's v2_toggle_shuffle / v2_set_repeat_mode,
instead of the old peer-active gate that fell through to a LOCAL reshuffle when QBZ
was the active renderer — that did nothing visible AND risked a divergent local
order. QBZ sends only the seed + pivot; the cloud owns the order; the toggle reads
the cloud-authoritative queue.shuffle_mode. The local shuffle path is now reachable
only when NOT connected.
- shuffle/repeat button reflects the cloud queue flag (queue.shuffle_mode for the
controller branch, core queue state via refresh_transport_modes for the renderer
branch), not the per-renderer field the cloud never populates for a peer — fixes
the iOS-reflects / WebPlayer-doesn't asymmetry.
- pause/play to a peer renderer: send a BARE SetPlayerState (playing_state only).
iOS reports current_queue_item_id=null while playing, so resolving the qid landed
on queue_item_id 0 — the protocol's "unknown" placeholder — and iOS could not
anchor the pause, auto-resuming. A bare transport toggle is spec-sanctioned: the
renderer pauses/resumes its own current item in place.
- skip_serializing_if on the SetPlayerState request structs so a None field is
OMITTED, not serialized as null; and the mapper now treats a null current_queue_item
as absent. Without this the bare command failed to encode and was never sent.
- log the outbound SetPlayerState payload for controller diagnostics.
cc
Startup blocked on Qobuz's ~7 MB web bundle download with no cache or
timeout, so a slow CDN left the app on a blank screen for 25-30s before
the login screen appeared. Cache the extracted tokens on disk (keyed by
bundle version), apply them instantly on warm starts and refresh in the
background, and bound the cold fetch with a per-request timeout + retry.
The legacy api client shares the same cache. Emits BundleFetchStarted so
the UI can show a connecting state on the first run.
cc
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
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
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
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
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
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
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
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
- offline.rs: constructs + owns a per-user OfflineCacheState (same index.db +
library.db paths as Tauri, so the offline store is shared across frontends);
activated on login/restore, torn down on logout (hooks in auth.rs).
- playback.rs: play_audible + the gapless trigger now route through QbzCore's
play_track_resolved / fetch_for_gapless_resolved, so an offline-cached track
resolves from disk (decrypt -> play_data) before hitting the network. The
unlock-animation sink is None for now (padlock lands with the Slice 2 UI).
qbz-slint compiles clean. Offline PLAYBACK now works in Slint; the "Make
offline" trigger UI + per-row status + the padlock are next (Slice 2).
cc
Adds qbz_core::offline_resolve::resolve_offline_bytes — the OFFLINE tier of
the playback tier-walk, frontend-agnostic and player-untouched (the user's
locked Slice-1 decision): resolve a track's offline-cached bytes by Qobuz id
(v2 CMAF decrypt via the secret vault, optionally emitting UnlockStart/End
through a sink; v1 plain-FLAC read; None on miss).
qbz-core now depends on qbz-offline-cache (aligns with the long-intended
`offline` feature seam). Both frontends will slot this in before the network
tier: Tauri keeps its hardware/quality gate around the returned bytes; Slint
calls it before Player::play_track. Player's L1/L2 + network path is unchanged.
Next: QbzCore compose methods (play_track_resolved / gapless), wire Slint's
session to construct + own an OfflineCacheState, route the Slint play path
through the resolver, and point Tauri's offline branch at it.
cc
Completes the crate extraction (Slice 0 backend half):
- state: OfflineCacheState holder (db/fetcher/cache_dir/limit/semaphore/
library_db + dir-layout helpers + new/init_at/teardown). apply_persisted_limit
now takes Option<u64> (the caller reads it from offline_settings) instead of
borrowing src-tauri's OfflineState, so the struct is frontend-agnostic and
Slint can own one too.
- purge: purge_all_cached_files takes &Mutex<Option<LibraryDatabase>> directly
instead of src-tauri's LibraryState wrapper.
The crate is now self-contained (13 modules, no Tauri). Next: re-wire the
Tauri commands onto it with an app.emit-backed CacheEventSink and delete the
old src-tauri/src/offline_cache/ module.
cc
Moves the download pipeline into the crate and removes its Tauri coupling:
- event: new CacheEvent enum (Started/Progress/Completed/Processed/Failed/
UnlockStart/UnlockEnd) + CacheFormat + the CacheEventSink callback type.
- downloader: StreamFetcher + try_cmaf_offline_download + spawn_track_cache_download
now report through a &CacheEventSink instead of tauri AppHandle::emit. The
CoreBridge param is gone — both the CMAF raw download, metadata fetch, and the
legacy stream-url fallback unify on a single core qbz_qobuz::QobuzClient handle.
TLS uses the workspace rustls (same backend qbz-qobuz uses for all CDN traffic).
- playback: adds load_cmaf_bundle_with_ui_events that emits UnlockStart/UnlockEnd
through the sink (the pure load_cmaf_bundle is unchanged).
Crate compiles headless; 5 unit tests pass. Remaining for Slice 0:
OfflineCacheState + purge, then re-wire the Tauri commands with an
app.emit-backed sink and delete the old src-tauri module.
cc
Continues Slice 0. Moves the near-pure modules, re-pointing the src-tauri
client/library types to the core crates (the make-or-break for a mechanical
extraction — verified the field shapes match):
- metadata: fetch_complete_metadata now takes &qbz_qobuz::QobuzClient (was
crate::api::QobuzClient); body compiles unchanged against qbz_models.
- migration: crate::api::QobuzClient -> qbz_qobuz::QobuzClient,
crate::library::database::LibraryDatabase -> qbz_library::LibraryDatabase,
super::metadata -> crate::metadata.
- playback: the PURE load_cmaf_bundle fn (the UI-events wrapper stays out —
it becomes a CacheEventSink call in the next commit).
Deps added: qbz-models, qbz-library, tokio, reqwest, lofty. Crate compiles.
purge + OfflineCacheState + downloader (CacheEvent) remain.
cc
Slice 0 of the offline-cache port (extraction per ADR-006). Creates the new
qbz-offline-cache workspace crate and moves the tauri-free leaf modules out of
src-tauri/src/offline_cache/ verbatim:
- types: the shared serde DTOs (was inline in offline_cache/mod.rs)
- db: the SQLite cached_tracks index (super:: -> crate::types)
- secret_vault, cmaf_store, path_validator: moved verbatim
- maintenance: super:: refs re-pointed to crate::
src-tauri is untouched and keeps its own copy, so the production build stays
green; the re-point + delete happens at the end of Slice 0. Crate compiles
in isolation and all 5 unit tests pass.
Remaining for Slice 0: playback pure-fn split, metadata/migration/purge
(re-point crate::api/crate::library -> qbz_qobuz/qbz_library), the downloader
+ CacheEvent/CacheEventSink abstraction, the OfflineCacheState struct, then
re-wire the Tauri commands and delete the old module.
cc
Album-card hover overlay now shows the full release date as "MMM D, YYYY"
(e.g. "Nov 6, 2025") instead of the year alone. New dates::release_label
helper keeps the month/day/year order fixed but localizes the month via
chrono format_localized (the app ships 5 languages; current_locale() is the
single hook to thread the real UI language once Slint wires translations —
English for now). Applied to the Discover and Favorites album mappings.
Also fixes Favorites > Albums cards, which hardcoded an empty genre and a
year-only label: they now read album.genre and the full date, matching the
Discover cards 1:1. The data was already in the getUserFavorites response
(qbz-models Album parses genre + release_date_original) — no backend change.
cc
The catch-up merge staged the pre-release lock (--theirs) before cargo
regenerated it, so the committed lock dropped qbz-slint's GUI dependency
tree (slint / accesskit / winit / skia / ab_glyph, ...). Commit the
regenerated lock so the checked-in slint-mvp lock matches the workspace.
cc
qbzd was a premature daemon experiment that never shipped (gated off
behind QBZD_ENABLED, no user access). Its ratatui 0.29 dependency pinned
unicode-width =0.2.0, which conflicts with slint's annotate-snippets in a
shared workspace. Remove it entirely; it will be reimplemented from
scratch later on the clean, UI-agnostic shell.
- Delete crates/qbzd daemon crate + workspace member (drops ratatui,
crossterm and the other qbzd-only deps from the lock).
- Delete src-tauri qbzd_discovery (mDNS) + its commands/state wiring in
lib.rs; drop the direct mdns-sd dep (Chromecast keeps its own via
qbz-cast).
- Delete remoteApi/remoteEvents/playbackTargetStore; neutralize
commandRouter to local-only dispatch (isRemote/skipIfRemote are now
always-false no-ops so the ~100 guard call-sites keep compiling).
- Strip qbzd branches from playbackService and the remote banner from
NowPlayingBar; excise qbzd from CastPicker (Chromecast/DLNA kept).
- Remove qbzd i18n keys (5 locales), README daemon node, gitignore note.
cargo check (crates + src-tauri) and svelte-check all green.
cc
cpal 0.17.3 requires alsa 0.11, which in turn pulls alsa-sys 0.4. The
`links = "alsa"` constraint permits only one alsa-sys in the dependency
graph, so qbz-audio's direct alsa stack is raised in lockstep:
alsa 0.10 -> 0.11 and alsa-sys 0.3 -> 0.4. Both workspace lockfiles
(crates/ and src-tauri/) are updated to cpal 0.17.3 / alsa 0.11.0 /
alsa-sys 0.4.0.
No source changes required: alsa_direct.rs uses only the stable
high-level alsa API (pcm, HwParams, Format, Direction, ValueOr, mixer),
and the snd_lib_error_set_handler FFI in alsa_error_handler.rs is
unchanged in alsa-sys 0.4. Both workspaces pass cargo check.
cc
Extracts two self-contained, framework-agnostic modules out of
src-tauri so they can be reused by other clients without duplicating
logic:
- ImageCacheService / ImageCacheStats move into the qbz-cache crate
(LRU disk cache, MD5-keyed, SQLite index).
- The credential store (keyring + AES-256-GCM encrypted file for Qobuz
credentials and the OAuth token) moves into a new qbz-credentials
crate.
src-tauri/image_cache.rs and src-tauri/credentials/mod.rs become thin
re-exports, keeping their Tauri managed-state wrappers. The Tauri app
is behavior-identical; both modules were already self-contained (no
Tauri and no crate:: references).
cc
Adds a framework-agnostic composition root for non-Tauri UI shells
(Slint, TUI, headless) as Task 1 of the Slint POC readiness audit
(session-20-slint-poc-readiness-audit).
AppRuntime<A> owns an Arc<QbzCore<A>> plus the runtime state machine,
built with the same recipe as the Tauri CoreBridge but with no Tauri
dependency. Scope is composition and accessors only; per-user store
ownership and session activation are deferred to Task 2.
qbz-app now depends on qbz-core, qbz-player, qbz-audio, and qbz-models.
The Tauri app does not consume this module; it is purely additive.
cc