On large windows the panels left big empty space; on small they were fine.
Make the art/covers grow to fill while keeping small-window behavior.
- AlbumReactivePanel: art cap 460->660, width frac 0.55->0.62 (grows more,
center-anchored pulse untouched).
- StaticPanel: cap 460->640, width 0.55->0.62 (fills large windows).
- CoverflowPanel: center cover cap 440->560, width 0.42->0.52; wrap the fan +
track-info in ONE center-aligned VerticalLayout so the composition is
vertically balanced (kills the "covers clustered at top, empty bottom" look
on large windows). Persistent-element model / dual repeaters / animated
scroll / z-order all untouched — only sizing + the wrapping layout changed.
- New shared ImmersiveTrackInfo.slint (Now Playing indicator + EqualizerBars +
title/artist/album/quality badge), standardized on Static's look; all three
panels use it, removing three duplicated inline copies (they can't drift).
cc
refresh_now_playing_meta cleared ImmersiveState.bg-image (set default) on every
track change; the async 300px decode+blur then re-set it a beat later, so the
immersive atmosphere blanked then reappeared. Natural advances hid it (warm
art), but a click (cover or next button) showed the flicker.
Stop clearing bg-image on track change: the previous blurred ambient bg now
persists until load_now_playing_artwork_large swaps in the new one — a brief
stale blur is imperceptible; a blank is not.
cc
Slint 1.16 has no per-element z-index (paint order = model/child order), so a
single Repeater painted the right (upcoming) side near->far = far covers on top
(disordered), and it also drew a ghost center card under the dedicated one.
- Split the fan into a LEFT Repeater (forward coverflow-tracks: paints
far-left->near-left, near on top) and a RIGHT Repeater over a new reversed
model coverflow-tracks-rev (walks far-upcoming->near-upcoming, near on top).
Both compute offset from the original flat index so geometry matches and the
±0.5 hand-off during a slide is seamless. Center band owned solely by the
dedicated top card -> no ghost.
- coverflow-tracks-rev is built only under the same id-sequence-hash rebuild
gate (no churn on advance) by cloning+reversing the forward QueueItems, so a
track's element holds the SAME decoded image on both lists: no source
reassignment on advance (no spike), and the animated `scroll` slide is intact.
- 3 past + center + 3 upcoming, mirror POSITIONS (scale/dim by |offset| only;
no reflection). Nav mapping (play-history / play-coverflow-upcoming) preserved.
cc
The Coverflow rebuild added an ATMOSPHERE_LAST_URL dedupe to skip re-blurring
the same cover. But the track-change path clears bg-image (set_bg_image default),
and the dedupe then SKIPPED the regen (URL recorded as unchanged) -> bg-image
stayed empty -> ImmersiveAtmosphere fell back to the raw (sharp) cover instead
of Codex's blurred ambient background. Affected ALL immersive modes.
Removed the dedupe entirely (static + atmosphere_url_changed fn + the track-clear
reset). load_now_playing_artwork_large now ALWAYS regenerates bg + glow + spectrum
colors from the decoded cover, as the original code did. The regen is the same
modest 300px-decode+blur it always was; it was never the CPU spike (that was the
coverflow re-decode, fixed separately).
cc
The album art appeared to drift sideways while pulsing; Tauri's art only scales
in place (transform: scale, intrinsically center-anchored) like a vibrating
loudspeaker cone, with no translate.
Cause: the Slint port animated `x, y` alongside `width, height` on the art +
glow. x/y are the centering offset (parent.width - self.width)/2; putting them
on their own 100ms easing timeline desynced the centering from the size, so the
box eased off-center mid-pulse - the reported lateral movement.
Fix: animate only `width, height`; x/y stay plain bindings that recompute in
lockstep with the animated size, so the art grows/shrinks from a rock-solid
center. Faithful to Tauri's center-anchored scale (scale source kept on
global-energy, not bass - bass only drives the glow in Tauri). Atmosphere,
other panels, player, and audio backend untouched.
cc
Rebuild that finally kills the per-click CPU spike, using the classic
coverflow architecture (the owner's reference: Addy Osmani / iPhone-2007).
Root cause of the spike (now removed): every navigation re-ran refresh_async
which REPLACED the coverflow VecModels, so Slint dropped + rebuilt all 7 bound
Image elements and re-uploaded/re-decoded every cover on each click.
New architecture:
- A single STABLE flat cover model, QueueState.coverflow-tracks =
[history oldest..newest, NOW, upcoming...], + an int coverflow-index. An
order-sensitive id-sequence hash gates the model setter: a pure track advance
(same sequence, pointer moves) does NOT rebuild the model - only the index
updates. So the Repeater is never rebuilt and visible covers never re-decode.
Rebuild happens only on a real content change (new queue / shuffle / add /
remove).
- CoverflowPanel: a Repeater over coverflow-tracks; each card's transform is a
pure function of offset = index - scroll. `scroll` is a float bound to
coverflow-index with `animate scroll { 320ms }` - GPU-composited slide that
repaints only during the transition then idles (no animation-tick / Timer, so
idle = no continuous render). Flat scaled cards (no rotateY/skew/reflection),
per-depth black-overlay dim, center painted last + non-interactive, ~7 visible.
- Windowed lazy decode (+-4) reusing the DECODED_PIXEL_CACHE; at most the one
newly-entering cover decodes per advance.
- Side cards navigate via queue-wide play_history / play_coverflow_upcoming.
- Bonus: atmosphere bg de-dupes by URL (ATMOSPHERE_LAST_URL) so a track change
no longer re-blurs the same cover. [coverflow-perf] debug timers added.
cc
Owner rebuild: cheap "rocola" of flat scaled cards, and eliminate the CPU spike
on every navigation click.
CPU spike (root cause): each queue navigation re-ran refresh_async, whose
to_item reset EVERY rebuilt row's artwork to Image::default(), discarding the
already-decoded handles for the 6-of-7 on-screen covers; each was then
re-decoded (the disk cache stored only compressed bytes, so even a hit ran a
full image::load + thumbnail). Dozens of full JPEG/PNG decodes per click.
Fix: (A) artwork.rs decoded-pixel cache (DECODED_PIXEL_CACHE keyed by url+size;
fetch_and_decode_ref consults it before any disk read/decode); (B) queue.rs
to_item_reuse + per-list prior-art id-diff carries unchanged decoded handles
across the model rebuild (no blank, no re-decode); load_artwork short-circuits
on a decoded-cache hit. Net: at most the one newly-revealed cover decodes.
Pyramid (CoverflowPanel.slint rebuilt): 7 flat cards (3 history left + center +
3 upcoming right), center declared last (paints on top), step-down scale
(1.00/0.80/0.64/0.50) + per-depth black-overlay attenuation on an inner Rectangle
(0/0.22/0.40/0.55). NO rotateY/skew/perspective, NO reflection, NO far-edge
gradient, NO root transparency. Removed the 500ms x/width/height reflow (it
re-laid-out clipped images every frame); only a 120ms GPU opacity cross-fade on
the dim overlay remains. Center drop-shadow static. 132px player clearance kept.
Correctness fix (review blocker): the coverflow upcoming cards index the
UNFILTERED next-3 (coverflow-upcoming), so routing their click through the
paged/filtered play-upcoming played the wrong track on a paged/searched sidebar.
Added QueueState.play-coverflow-upcoming -> queue.play_coverflow_upcoming, which
plays the queue-wide upcoming index directly via play_upcoming_at. History
already indexes queue-wide via play-history.
cc
The bars used a fixed cyan->purple default. Now they take contrast colors from
the album artwork, with a negative-fallback for dark covers (a near-black
Metallica album would vanish against the black background -> invert so the bars
pop).
- immersive.rs: spectrum_colors() extracts the most saturated sample as primary
+ a hue-distinct secondary (channel-rotated if none distinct); if the cover is
too dark/desaturated it uses the NEGATIVE of the average. ensure_visible_on_black()
inverts a too-dark color and lifts a dim one so both stops read on black.
- ImmersiveState gains spectrum-primary / spectrum-secondary (defaults cyan->purple
until art resolves); set in playback.rs from the same decoded artwork as
glow-color / bg-image.
- SpectrumPanel binds the bar gradient to those (base = primary, tip = secondary).
cc
The cubelet port instantiated ~80 Rectangles per column x2 mirror x14 bins
(~2240 retained scene-graph nodes) whose `visible` flipped every frame off an
animated `shown-cubes`. Audio updates at 30fps kept that animation from ever
settling, so ~2240 visible-bindings re-evaluated per frame and femtovg
re-rasterized hundreds of tiny rects - ~5fps and a large CPU spike. Tauri is
cheap because it is immediate-mode Canvas2D (fillRect), not a retained tree.
Fix: one solid gradient bar per column (2 nodes/pair, ~28 total) with an
animated height. Same geometry (28 mirrored columns, baseY 0.85H, maxBarHeight
0.7H, widths/offsets), same data binding (VisualizerState.bars, bounds-safe),
same cyan->purple default gradient (now a vertical fill instead of per-cube).
Dropped the discrete-cubelet texture for the perf win; it can return cheaply as
a static stripe overlay if wanted. Removed the unused cube-indices array.
cc
Replaces the provisional S2 ModeBar with the polished segmented mode pill,
copying the compact player's visual language (owner instruction).
- Pill tokens copied verbatim from ImmersivePlayerBar: background #00000080,
1px #ffffff2e border, drop-shadow blur 32 / #00000066 / offset-y 8 (the
shadow is the main upgrade - the provisional bar had none), radius 12.
- 4 tabs (icon + label): Album Reactive / Static / Coverflow / Spectrum, with
existing icon assets. States per chrome.md: idle #ffffffb3, hover #ffffff1f
bg / #fffffff2, active #ffffff33 bg / #ffffff; 13px, weight 500->600;
150ms background animate. Leading panel-left glyph + 1px divider (decorative).
- Mount site unchanged: same chrome-visible auto-hide opacity (on the instance,
not the root), same select(idx) => mode + wake() wiring, no second timer.
- The 4 mode foregrounds, player, volume fix, atmosphere, responsive sizing,
and ESC are untouched.
Logged nits: border alpha matches the player (0.18) not the Tauri header tabs
(0.15) - intentional for one design language; leading panel icon is inert
(Tauri's is a functional split/focus toggle, out of scope).
cc
New SpectrumPanel.slint: faithful port of VisualizerPanel.svelte (the 16-bar
spectrum, not SpectralRibbon/Oscilloscope/LineBed).
- 16 source bars, bins {1,15} excluded -> 14 active -> 28 mirrored columns.
Geometry exact to source: bar-gap 4, total-bar-width W/28, baseY 0.85H,
maxBarHeight 0.7H, xLeft/xRight per spec.
- Discrete 6px+2px cubelets with per-cubelet gradient (gradientRatio
min(c/20,1), primary->secondary). GPU path (ADR-011); CPU flat fallback not
ported (Slint is GPU by default).
- Heights driven by VisualizerState.bars (bounds-safe clamp, 30fps len-16
VecModel) + a short animate settle - NO Timer. Bars already smoothed+clamped
in qbz-audio (no double-EMA). Now-playing card reuses the sibling pattern.
- Mounted on mode==3; modes 0/1/2 untouched.
Logged minors: MAX_CUBES=80 clips full-amplitude bars on very tall / 4K
windows (raise as a cheap follow-up); two-color gradient ships the Svelte
default cyan->purple (no album-derived secondary color wired yet); smoothing
is the Rust asymmetric EMA, not the Svelte symmetric 0.6 (deliberate, shared
pipeline).
cc
New CoverflowPanel.slint: honest pure-Slint 2D rendition of CoverflowPanel.svelte.
The true rotateY/perspective 3D tilt is impossible in declarative Slint and is
deferred to a future wgpu/Skia session - NO fake tilt (owner decision).
- 5-slot fan: now-playing centered + painted on top; QueueState history fanned
left, upcoming right. Depth via scale (center / *0.68 / *0.54) + per-depth
drop-shadow + distance dim overlay + far-edge shade gradient. Zero
rotate/perspective/skew (grep-confirmed).
- ~500ms reflow on track change via pure-Slint animate x/width/height/opacity
(no queue.rs change, no changed-geometry watchers).
- Side covers navigate via QueueState play-history / play-upcoming; center is
not clickable (matches Tauri). Empty/unresolved-art guarded with visible:.
- Responsive: base derived from viewport-height (reserve 364) + width*0.42 cap,
offsets clamped inside the window; song card under the fan reuses the
Static/AlbumReactive pattern. Mounted on mode==2; modes 0/1/3 untouched.
Deferred/cosmetic (logged): cover image pops on rebind instead of physically
traveling (needs a CoverflowState slide); no reflection sheen; no explicit
badge; no CPU-mode animation degrade.
cc
New StaticPanel.slint: 1:1 port of StaticPanel.svelte. The calm, centered
now-playing card over the moving atmosphere. NON-reactive (no VisualizerState
energy, no glow halo, no breathing) - the only motion is the fixed-period
4-bar equalizer (animation-tick, shown only while playing), faithful to source.
- Responsive art reused from Album Reactive (height-driven + width*0.55 cap +
clamp 180..460), reserve 364px = pad-top 52 + spacing 20 + info ~160 +
player clearance 132 (no *1.25 since Static doesn't breathe). Metadata never
clips under the player.
- Metadata: now-playing indicator / title 28/700 / artist 18 / italic album /
centered QualityBadgeFull - matching the sibling and the Svelte source.
- ImmersiveView mounts it on ImmersiveState.mode == 1 (replaces the placeholder).
Modes 0/2/3 untouched.
Accepted parity drops (match the shipped sibling + state model): explicit
badge omitted (no NowPlayingState.explicit field), no hover lift, label not
uppercased.
cc
The art was sized min(52vh, 460px) with only an 88px bottom pad, so on shorter
windows the art-block + metadata overflowed and the quality badge was clipped
under the floating player bar.
- base-art now derives from available height: it sizes the whole group
(art*1.25 growth + 40px spacing + ~160px info + 132px player clearance) to
fit viewport-height, with a viewport-width*0.55 cap for narrow windows,
clamped to 180..460px.
- padding-bottom 88 -> 132 (clears the 90px player + 24px margin + gap);
spacing 60 -> 40. Reserve constant kept in sync with base-art.
The Static panel (S3) will reuse this same responsive sizing.
cc
Builds the foreground layer on Codex's accepted atmosphere+player base.
- ImmersiveState.mode (int, default 0): 0 Album Reactive / 1 Static /
2 Coverflow / 3 Spectrum. Compact 0..3 enum for the scaffold (Tauri has no
single integer mode; it uses viewMode + activeFocusTab strings).
- ImmersiveView layer stack: atmosphere (bottom) -> mode foreground ->
auto-hide player -> ModeBar selector. Modes 1/2/3 mount no foreground yet
(atmosphere-only placeholder, TODO S3/S4/S5).
- Provisional ModeBar selector (top-center, auto-hides with chrome-visible),
to be replaced by the polished segmented bar in the header session.
- New AlbumReactivePanel.slint: faithful port of AlbumReactivePanel.svelte
(387 lines), NOT the rejected breathing-art proof. Exact maps:
global-energy = avg(energy[0..5]); bass = avg(energy[0..2]);
art-scale 1+global*0.25; glow-spread 15+bass*200; glow-opacity
min(0.1+global*1.5,1); 4-bar now-playing equalizer driven by
animation-tick() (not a Timer). Album-derived glow from the already-wired
ImmersiveState.glow-color; energy already published by visualizer.rs.
- main.rs immersive arm sets mode=0 on open (deterministic).
Flagged minors (deferred, see overnight log): glow tint not reset on
track-change until the new cover decodes; raw energy + 100ms animate vs the
Svelte asymmetric EMA; glow drop-shadow-blur only (no spread term); explicit
badge omitted (no NowPlayingState.explicit field); clamp art-scale energy to
avoid a clip at the block boundary on loud transients.
cc
The immersive compact player's volume bar never respected the volume lock and
diverged from the Tauri VolumeSlider UX. Units were already correct (0..1
end-to-end); the bug was missing lock gating + a bare progress strip.
- Add `vol-locked` to ImmersivePlayerBar, byte-identical to PlayerBar.slint:
(backend-is-alsa && alsa-plugin-is-hw && !is-remote) || volume-locked.
Import SettingsState.
- Gate the mute button and the volume bar's changed handler on vol-locked;
dim the bar to 0.4 when locked (mirrors PlayerBar). The bottom seek bar is
left ungated (correctly).
- Bring the volume control up to Tauri shared/VolumeSlider.svelte UX: a new
VolumeBar with a hover-revealed thumb, drag/hover percent readout, and
locked-state fill pinned to 100%. Kept the custom primitive (QbzSlider is
integer 0..100 / QbzVerticalSlider is vertical; neither fits the float 0..1
white-on-glass immersive look). Centered play disc and seek bar untouched.
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
1:1 port of the Tauri TrackInfoModal + AlbumCreditsModal to the Slint UI.
The Track Info invocations were already wired (TransportControls info
button, SongCard title, TrackContextMenu item) and only needed the modal
plus the fetch; the backend (core get_track/get_album, qbz-qobuz performers,
models) already carried all the data.
- qbz-qobuz/performers.rs: group_credits_ordered (matches the Tauri
getGroupedCredits ordering: Composer/Lyricist first, MainArtist last, rest
alphabetical, deduped) + format_role_label backed by the 147-entry English
performerRoles map.
- qbz-slint/info_modals.rs: fetch + map + apply controllers (mirror of
navigate_album); track/album quality strings match Tauri exactly.
- state.slint: TrackInfoState / AlbumInfoState + InfoCreditRow / InfoCreditPair
/ AlbumCreditPerformer / AlbumCreditTrack + Actions; re-exported in app.slint.
- TrackInfoModal.slint + AlbumCreditsModal.slint: scrim modals (FocusScope +
Timer for Esc, ListScrollbar), 3-col metadata + 2-col credits grids,
expandable track rows with play-on-hover, Credits/Review tabs gated on a
review. Album modal is content-sized with a capped max height.
- AppShell mounts both; AlbumPageView (i) button wired (Qobuz albums only);
main.rs adds the ("track","track-info") + ("album","info") arms and the
modal action callbacks (close/tab/play/artist/label/musician).
cc
The Connect/Disconnect button always closed the flyout, so clicking Connect
dismissed it before any renderer could appear. Now it closes ONLY on Disconnect;
clicking Connect keeps the flyout open and shows a LoadingSpinner ("Looking for
devices…") while renderers are discovered asynchronously (they populate
QconnectDevState.devices via the event sink), resolving to the device list or
the existing "no devices" empty state. There is no `discovering` flag in the
state, so the spinner is bounded UI-side by a `qc-connecting` property + an 8s
Timer — no QConnect engine / Rust changes. Applied identically to PlayerBar
(New/Classic) and PlayerBarSmall (Small), where the flyout is duplicated.
cc
- New mode shell ui/shell/NowPlayingBar.slint switches on ShellState.npb-mode
(0/1 -> existing PlayerBar untouched except a mode-aware height, 2 -> new
PlayerBarSmall). AppShell mounts it; npb_mode "small" persists in ui_prefs.
- PlayerBarSmall: one 42px row (the album art defines the height, no bump) — a
3px full-width top seekbar (SeekBar gains a show-times prop) + a symmetric
3-column controls row that mirrors PlayerBar's responsive side-frac so the
transport is window-centred. Compact SongCard (art border + flush-left +
text-center + smaller fonts), a tabular 2-row time column (monospace), an
inline 2-row AudioStamp (no format icon), and the right cluster
(ViewMode/Connect/Cast/Lyrics/Settings-flyout/Volume/Queue). Responsive
volume: inline QbzSlider when wide, an upward QbzVerticalSlider flyout when
narrow.
- New ui/primitives/QbzVerticalSlider.slint.
- Floating album-art hover preview (1:1 Tauri): NowPlayingState.artwork-large
(300px, loaded via the same source-aware artwork funnel) + a global
ArtPreviewState rendered by the new ArtPreviewOverlay (an overlay, NOT a
PopupWindow, so it never steals hover -> no flicker). New/Classic/Small.
- Fix: settings.rs handle_bool now refreshes SettingsState on the non-cascade
path so the bar's Normalization/Gapless toggles reflect their real state
(shared path, also helps New/Classic). Protected audio backend untouched.
- Context icon turntable -> layers (Tauri parity, all now-playing modes).
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
- TrackRow: Korean/CJK titles render via layout-assigned width+height
(SongCard parity, never femtovg-measured); explicit "E" badge hugs the
title text; duration + position number dropped to 13px so they read
smaller than the 14px title.
- AlbumView: label/awards rail 214->160px with smaller card/icon/padding
and centered headings in the compact rail; removed the dark right gutter
(full-width Flickable, content clears the scrollbar via the 32px page
padding); column headers homologated to Playlist casing (Title/Duration/
Quality), same in LocalAlbumView.
- ArtistView: network sidebar auto-collapses on ShellState.content-
constrained (the AlbumView signal) and no longer re-opens on artist->
artist navigation (reset_network_sidebar respects the constraint).
- Hero titles homologated to Typography.section (18px) across MyQBZ detail,
Musician, Label releases, Artists-by-location, Offline Manager.
- Lyrics: Qobuz || LRCLIB parallel fetch, next-track prefetch, source
footer, asymmetric line fade.
cc
Opening the lyrics panel on a synced track panicked the main thread
(Recursion detected, i-slint-core properties.rs:583). Root cause: a
changed callback's WATCHED expression is eagerly evaluated at instance
creation, and repeated rows are created from INSIDE the parent layout's
layout-cache binding evaluation - so the active row's changed y/height
handlers read the locked layout-cache and re-entered it. The handlers are
gone: rows report on is-active flips plus a one-shot recenter-epoch bump
(16ms timer per view mount) covering the born-already-active case (panel
opened mid-song); geometry is read only inside notify bodies, where the
lazy pull yields settled post-relayout values. Wrap/resize drift
re-centers on the next line change - Tauri parity (it also only scrolls
on activeLineIndex changes). Footgun documented in the view header.
cc
Sidebar header gains the controls flyout (auto-follow, font size on the
280px-adapted scale, dimming, active color, uppercase, copy lyrics,
reset). Every setting persists per user via the new lyrics_prefs store
(loaded on all session activation paths, applied into LyricsState,
Tauri defaults). Settings gains the lyrics cache row with honest per-user
stats and clear (the F1 fix shipped in qbz-lyrics). Parity and @tr sweep
over the sidebar surfaces per the review inventory.
Closes the S1-S5 implementation run of the Lyrics epic: Qobuz-first
word-synced lyrics (real per-word timestamps - richer than Tauri's
line-level estimation), LRCLIB/lyrics.ovh fallback, shared per-user cache
with additive wsync column, 30Hz sync engine with word-anchored karaoke
fill, auto-center scroll, and QConnect peer-following.
cc
S4 of the lyrics epic: ms-precision position source, 30Hz sync engine,
word-anchored karaoke fill, auto-centered ladder scroll, QConnect peer
following.
- qbz-player: read-only SharedState::current_position_ms() derived from
the existing playback_start_millis/position_at_start anchors (Q1; no
stream/seek/format path touched) + unit test
- qbz-lyrics: new sync module porting the Tauri active-line math (binary
search active index, per-line progress with the 0.99 snap) plus the
word-anchored clip fraction for Qobuz wsync lines (char-proportional
word width approximation, Q2); unit tests
- qbz-slint: lyrics_sync engine - adaptive slint::Timer (33ms while
panel open + synced + playing, 250ms gate polling otherwise),
equality-guarded pushes, fill animation zeroed across discontinuities
(line change / backward seek); kick() on doc commit and panel open
- QConnect (Q7): the poll loop controller branch publishes the raw
renderer anchor; the engine extrapolates it per tick so lyrics follow
the peer (Tauri freezes); local fallthrough clears the anchor
- LyricsLinesView: karaoke fill via clipped bright overlay text over the
dim base (rect-clip approximation, D5); auto-center scroll from
layout-relative active-row geometry, smooth for <=2-line moves and
instant for jumps/initial sync, animated via a separate scroll-target
property so user wheel input is never animated
- LyricsState: line-progress + fill-anim-ms properties
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
Local-surface track menus gain Go to Album / Go to Artist (owner
improvement - Tauri omits them on local rows), routed by source: local
rows navigate via their album_group_key and artist name to the local
album view and LocalLibrary Artists; plex rows resolve the content-hash
plex album key (local_queue_track parity); qobuz_download rows recover
their real Qobuz album/artist ids through get_track and open the Qobuz
pages, falling back to the local destinations when the resolve fails so
the click always lands. Entries hide when keys are absent (ephemeral,
file:/broken:/unresolved rows). Playlist-detail sidecar rows opt in with
the same routing, and the global go-to arms divert snapshot local/plex
rows before the catalog parse (a local row id would have been mis-typed
as a Qobuz catalog id).
Playlist header drops its local CircleAction copy (transparent + 1px
border ring) for the shared user-verified primitive (solid primary disc,
translucent fills, dim-on-disabled) - all seven actions restyled,
callbacks untouched.
cc
Per-row Remove from playlist lands (danger styling, owner-gated by design
- Tauri renders it un-gated and lets the API reject; we port the intent,
commented) working across qobuz/local/plex rows through the namespace
split. Per-row Play next / Add to queue route by source: local rows
enqueue their snapshot QueueTrack instead of mis-parsing row ids as
catalog ids, and plex rows enqueue too (deliberate deviation from Tauri's
silent no-op - the machinery exists). Hero Play next/later of mixed
playlists merge the sidecar before enqueueing (Tauri T2 fix-forward: their
hero arms drop source and crash plex auto-advance). Bulk bar reaches
parity: select mode on followed playlists, source-aware bulk Play next /
Add to queue / Add to playlist (mixed selections send Qobuz rows to the
catalog picker, all-sidecar selections to the local picker), Remove stays
owner-only. The offline-cache action is now cache-state aware app-wide
(Make available / Refresh / Remove with a sequenced recache arm), and
Track info hides on this surface until the feature exists.
cc
The online Qobuz playlist detail now reads the local/plex sidecar tables
and renders the merged list with Tauri's exact absolute-slot interleave
(sidecar rows claim stored positions, Qobuz fills in server order, gaps
collapse, leftovers append, contiguous display numbers), via a shared
reader extracted from the D11 offline branch - online and offline render
identical row identities. Fix-forward over Tauri: same-slot collisions
emit all claimants local-first instead of silently dropping rows, and the
real plex glyph renders (T4).
Source-aware play/shuffle route mixed lists through the merged snapshot
with a Qobuz-only playback context; the QConnect all-or-nothing admission
path was verified untouched. Sidecar adds now position at the merged end
via MAX(position)+1 across both tables (fixes the live 0-based collision
writes from the picker/drag) with on-load collision healing (stable claim
order, only colliding rows renumber, every move logged; 9 new tests).
Multi-select removal splits by namespace - local and plex rows hit their
sidecar removers, Qobuz rows resolve catalog ids to playlist_track_ids
before the API call (fixing the live bulk-remove bug that shipped track
ids in the ptid parameter). Custom order upgrades to (track_id, is_local)
keying with Tauri's plex-rewrite wart replicated and documented. Rows
gain per-source favorite/download gating with a grid-preserving
placeholder column.
cc
Closes the slice-4 'plex rows render basic' deferral: local-playlist plex
refs bulk-resolve from the plex cache by rating key (new qbz-plex
by-keys getter with the cached-track shape) into full-metadata rows -
real titles, duration, PlexThumb artwork, playable through the existing
plex path (induced offline allowed). Cache misses render an honest
'Unavailable Plex track' fallback and legacy garbage refs (plex synthetic
ids stored as qobuz ids by the pre-typed-drag flow) surface as removable
unavailable rows instead of being D11-hidden forever.
Still-live writers of that garbage are fixed: the shared add-to-playlist
arm routes plex:<key>/local refs through the local picker and refuses
synthetic ids with a toast; row_drag_track no longer falls through plex
rows to the Qobuz arm; and add_qobuz/add_drag blocking writers refuse ids
at or above the plex synthetic floor as a last gate. main.rs also carries
the context-menu mixtape/favorite arms from the previous commit.
cc
Per the reviewed spec (qbz-nix-docs/handoffs/2026-06-10-locallibrary-
trackmenu-1to1-spec.md): Add to Mixtape/Collection unhidden for local
surfaces via an independent mixtape-action flag; force-local-menu kills
the dead 9-entry Qobuz block on qobuz_download rows (SourceGlyph
untouched); favorite entry on qobuz_download rows only (Tauri's Add to
Library ported minus its local-id-to-Qobuz-API bug, spec 3.2); Add to
Playlist stays visible offline (deliberate deviation, our local playlists
accept tracks offline, spec 3.3/D7-D8); right-click opens the same menu
at the pointer (PointerEventButton.right, suppressed in multi-select,
Tauri parity). Qobuz surfaces byte-identical (flags default to the old
gate). Rust arms ride the next commit (shared main.rs).
cc
The detail view's artist click forwarded only the artist NAME, and the
top-level router's heuristic (numeric = Qobuz, name = local) sent every
click - Qobuz items included - to the LocalLibrary artist page. The
callback now carries (source, name, artist id): Qobuz items navigate to
the Qobuz artist page (same recorded-history path as AlbumView's artist
button), local/plex items keep the LocalLibrary destination. The resolved-
item model gains artist_id hydrated from the first resolved track (live-
pushed when the resolve lands; a click before hydration is ignored with a
warn instead of navigating wrong). Both click sites updated (list/expanded
subtitle link + expanded inline rows); grid cards render plain text as
before.
cc
image::open resolves the format from the path extension only (no content
sniffing in image 0.25), so every notification artwork served from the
shared image cache - files named {md5}.img since slice 3b/B11 - failed as
Unsupported and the toast fired icon-less on online Qobuz plays. Decode
via fs::read + image::load_from_memory (magic bytes, extension-agnostic),
which also heals mislabeled downloads in the md5 cache. file:// paths now
percent-decode (cached_file_url_for builds them via Url::from_file_path),
fixing the latent spaces/non-ASCII fall-through to an HTTP GET of a
file:// URL. Offline semantics unchanged.
cc
The B11 local-first push handed MPRIS the shared image-cache file://
URL on every online cache hit - but those files are named {md5}.img and
freedesktop mime maps *.img by extension to a disk-image type, so
GIO/KIO-backed widgets reject the artUrl (and the deferred upgrade task
replaced a working https push with the same dead URL within a second).
Online MPRIS art returns to the pre-B11 https passthrough (Tauri's
production contract); offline keeps the slice-3b file:// semantics; the
upgrade task is removed (re-introducing it requires cache files with
real image extensions). The (track id, art) metadata dedupe stays.
cc
Integrations buttons rebuilt to the settings standard (160x34
SecondaryButton, body/medium/text-secondary, enabled-dim) so every
connect/finish/disconnect lands on the settings right-edge line; Offline
status green standardized to the settings token (#3fae6a, not the player
chrome green) and Check now dims while checking; the create-modal offline
hint indents onto the label grid; the favorites-rail header moves to the
canonical section-heading scale flush with the cards.
The extracted standard (SettingRow trailing controls, fixed button
widths, GroupHeader idiom, modal footers, status-line tokens) is codified
rule-per-line with citations in
qbz-nix-docs/guides/native-ui/ui-control-alignment-standard.md.
cc
The drag payload was an untyped Vec<u64>, so dropping a LOCAL album row
onto a local playlist stored its library row id as a qobuz_track_id - the
D11 resolver could not resolve the fake id and hid the row (the
empty-playlist report). New DragTrack {Qobuz|LocalRow|Plex} typed per
originating view; the drop arm stores source-aware refs (qobuz_download
rows keep their real Qobuz ref, plex rows their key, local rows their
path) for both local and Qobuz playlist targets.
Add-to-playlist had collapsed to play-track in LocalAlbumView (entry
rendered, action ignored) and was an unhandled arm in the Tracks tab; both
now route to the source-aware picker, Plex rows gain the entry everywhere
(gate was excluding them), folder-detail rows route through the shared
track-action arm, and bulk arms stop skipping Plex.
D11 nuance: a local-path row missing from the library index but present on
disk renders with a filename fallback instead of hiding; only genuinely
missing files stay hidden (logged distinctly). main.rs also carries the
recovery handler hunks for the sign-in flow fix (Login screen first,
browser OAuth after, no unlocked-empty shell).
cc
login_via_system_browser's start-of-flow offline_session pre-clear (added
when the gate still blocked login) unlocked an empty shell while the
browser OAuth was pending. The pre-clear is gone: the auth endpoints are
gate-exempt since c207f232, runtime activation is purely local, and the
one remaining gated dependency - the COLD bundle fetch in try_init_api -
is handled by the new ensure_api_initialized helper, which lifts the flag
only around that init and restores it immediately (no-op on the normal
warm-token path). The flag now ends up false exclusively on a completed
login; restore_saved_session goes through the same helper, fixing a latent
cold-bundle recovery failure. Handler wiring rides the next commit.
cc
Re-implements the parked scrobblers-wip stash intent on the current tree
(the stash no longer applied; it remains untouched in the stash list) with
three corrections: the Last.fm offline queue uses the SHARED per-user
offline_settings.db scrobble_queue (byte-identical SQL to Tauri - queues
flush from either frontend) instead of a private table; the ListenBrainz
cache opens Tauri's per-user path so LB creds and listen_queue are truly
shared (a Tauri LB sign-in pre-seeds Slint); and the queue flush is driven
by the offline engine's online edge (plus initial flush), not login-only.
New frontend-agnostic qbz_app::settings::scrobblers store (master enable,
Last.fm session/username, LB token/username; tests) and the shared
scrobble-queue API on OfflineModeStore. Player wiring hooks the de-duped
track-change edge inside the QConnect remote gate: now-playing fires on
change (skipped offline), the scrobble arms at min(50%, 240s) with a
generation counter cancelling stale timers, all sources scrobble (Tauri
parity incl. the #360 version-enriched title). Offline or call failure
queues; Last.fm flush honors the 50-batch and 14-day cutoff.
Settings gains an Integrations section (Plex-section idiom): Last.fm
browser-auth connect/finish/disconnect, ListenBrainz token field +
validate, per-service enable toggles. Known gap documented: Last.fm creds
cannot be shared with Tauri (webview localStorage); LB creds and both
queues are shared.
cc
Qobuz playlist snapshot, captured opportunistically with zero new API
traffic: every online sidebar/manager list load upserts id+name+owner+
track_count for the user's playlists; opening a playlist detail online
full-replaces its membership track ids (refusing playlists never listed,
so viewed public playlists don't land). Offline consumers: synthesized
sidebar/manager entries resolve the persisted name (killing the cold-start
'Playlist (N local)' fallback when a snapshot exists), and the mixed-
playlist offline detail now renders the snapshot tracks that are playable
(snapshot intersect cached intersect grace) from the offline-cache index -
snapshot block first in snapshot order, sidecar local/plex block after.
D11 availability widens: a playlist with zero sidecar rows but cached
snapshot tracks now shows offline. 4 repo tests.
Offline favorites rail: the Favorites placeholder gains an 'Available
offline (N)' block - disk-cached favorite ids intersected with the
offline-cache READY index and LocalLibrary qobuz_download copies, compact
rows with the offline cover chain, click plays the rail as a queue through
the cache tier. Shared OfflinePlaceholder gains an @children slot; only
the Favorites mount injects the rail.
Single commit: the two features interleave in main.rs/state wiring and
ship together to keep every commit compiling.
cc
Reorder: repo gains reorder(from, to) with remove-then-insert semantics
and contiguity preservation (5 tests); the local detail wires the existing
per-row chevron path (shown under Default sort - the repo order IS the
editable order; the Custom sort entry hides for locals since the custom-
order sidecar is u64-keyed) with optimistic model swap and off-thread
persistence.
Organization: additive favorite/hidden columns on local_playlists
(pragma-guarded migration + 2 tests incl. pre-migration shape), manager
cards render real heart/eye state and locals participate in the
visible/hidden toolbar filter, hidden locals drop from the sidebar while
staying reachable through the manager's hidden filter. Folder membership
for locals stays out of scope (folder tables are Qobuz-keyed).
main.rs wiring for these actions rides the next commit (shared file with
the in-flight favorites-rail batch).
cc
While offline, cached items resolve their badges from the offline-cache
index (stored tier, bit depth, sample rate via the shared quality
formatter - the same rule the LocalLibrary offline-source rows use)
instead of hitting the gate-refused Qobuz resolve and falling back to
empty defaults. Release-type classifies over the stored track count with
the READY count as fallback. Online resolution is byte-identical (single
is_offline gate); the per-item fetch warn drops to debug only offline.
cc
Remote artwork refs now check the shared disk image cache before reaching
MPRIS/notifications online too (slice 3b only did it offline): a cache hit
hands desktop widgets a file:// URL instead of making them re-download
what the app already fetched. On an online miss the remote URL passes
through and a bounded re-resolver (500ms x 20) upgrades to file:// once
the now-playing bar's own fetch lands in the cache - pushed at most once
via a new (track id, art URL) metadata dedupe that also stops identical
re-pushes on resume/seek. Offline miss semantics unchanged.
cc
Upload to Qobuz now attaches Plex sidecar rows via the existing
add_plex_track_to_playlist helper (positions after the local block), and
the local entity is deleted only when the sidecar attach succeeds - a DB
failure keeps the playlist with an upload-incomplete toast instead of
silently dropping it.
Playlist manager: local playlists interleave into the active sort with the
same comparators as the Qobuz set (name/tracks real interleave; playcount
sorts locals as zero, custom as after-the-positioned-set, recent after the
API order - documented). u64-keyed mutators verified safe with local: ids.
Offline covers: cached_tracks.artwork_path turned out to have zero writers
in history, so CachedTrackInfo gained artwork_path + file_path and a
frontend-agnostic resolve_cover_path chain (index path, CMAF cover.jpg,
v1 sibling cover.jpg). The offline manager album rows and the local-
playlist cached rows now resolve covers through it.
cc
The host owns no retry timer (backoff + 60s idle rearm live inside the WS
transport), so the gate lands at the SessionLoopHost hooks: the
keep-idling branch of on_reconnect_exhausted and bootstrap_after_reconnect
now park on the offline engine's watch channel until the online edge
instead of churning per rearm cycle. Event-driven, lock-free parking; the
D5 watcher's disconnect aborts the parked future via the event-loop abort.
Converting the bootstrap skip into a park also closes the dormant-session
hole where a reconnect that subscribed while offline stayed joined but
never bootstrapped. No auto-reconnect on the online edge (D5); transport
internals, watchdogs and queue flows untouched.
cc
Recovery banner replaced by a tri-state header badge left of the menu
button (Offline hard amber / Offline manual accent / Logged out gray,
hidden when online and authenticated) with a flyout carrying the full
status line, a Sign in button for the logged-out state (enabled unless
connectivity is CONFIRMED down - it was born dead behind the Unknown
verdict; action fires before the popup closes) and a quick Offline-mode
toggle for the manual state (Settings shortcut dropped per owner).
Discover/Library nav entries are hidden offline instead of disabled, on
all three nav surfaces.
Sidebar flyout incremental drift root-caused by a headless harness against
slint 1.16.1: an opacity binding on SidebarNavRow's ROOT (left over from
the disabled-gating approach) forces an inlined opacity wrapper that makes
absolute-position double-count self.y - one row-pitch of drift per row,
which the in-layout banner had masked as its own bug. Binding removed;
anchors measure exact in all states. Same-class latent hazard flagged in
NavTab/CompactNavBtn (cancels today, self.x=0).
Header MyQBZ dropdown rows carried no route (documented no-op): Collections
and Mixtapes now navigate from both the full tabs and the compact rail.
The offline-edge watcher also reacts to connectivity-only changes so the
network-folder browse set refreshes in place during a logged-out session.
cc
Root cause of the empty Albums tab: transposed positional booleans from
the Plex-port rewrite (a45840d1) left exclude_network_folders=true and
include_qobuz_downloads=false unconditionally on every browse query since
2026-06-07. The owner-approved model replaces it: network-folder content
hides ONLY under hard offline (link confirmed down - no link means no LAN
mounts either); a logged-out session or induced offline with the link up
shows everything (the Qobuz verdict says nothing about NFS/SMB). The
show-network-folders toggle is retired from Settings (DB column stays,
Tauri-compatible).
Playback lock instead of hiding: local tracks get an existence check at
the play seam and in the advance walk (FileMissing verdict, 'is the drive
mounted?' toasts, never on the audio thread - D-state caveat documented).
Scan-cleanup guard: tracks under a network folder whose mount is
unreachable are never treated as missing (a refresh pressed before
remounting used to hard-delete the folder's whole index). mount_info: nfs4
and smb3-style version suffixes now classify as network (its red test is
green) and the longest-mount match respects path boundaries.
cc
The offline short-circuit in fetch_cached_http gated ALL its consumers,
including ArtworkRef::PlexThumb - the LAN artwork of local-library Plex
rows. Misses were guaranteed across surfaces because the server-transcode
size is part of the URL cache key (queue 96 / song card 160 / grid 264),
so covers cached by the grid still missed everywhere else while offline.
fetch_cached_http now takes a gate_offline flag: Remote (Qobuz CDN) keeps
the offline gate, PlexThumb bypasses it (LAN is not the internet; a dead
LAN attempt is bounded by the 10s client timeout).
cc
Logging out of an offline session left offline_session set and the gate
closed, refusing the very OAuth exchange that could end the offline state
(user-reproduced: sign-in failed with OfflineMode after logout). teardown()
now ends the session-scoped offline state (offline_session and the cached
induced flag reset; the persisted preference reloads on the next
activation) and recomputes, reopening the gate when connectivity allows.
Two regression tests added.
The three login methods (login, login_with_oauth_code, login_with_token)
are exempt from the offline gate: user-initiated sign-in is explicit
intent to reach Qobuz; the gate governs services. Verified none of them
reach gated sub-calls; bundle cold-start stays gated on purpose. New unit
test pins the exemption. The recovery sign-in falls back to the full
browser OAuth flow when the saved token is gone or rejected, and
offline_session is lifted only on success paths.
cc
Offline, unavailable content is filtered out rather than shown broken:
sidebar, manager and folder popups list only local playlists and mixed
playlists with local sidecar rows (100% Qobuz hidden; empty folders
skipped); a mixed playlist opens an offline-subset detail rendering only
its local sidecar rows (plex rows under induced only, read-only, same row
machinery); MyQBZ grids and details retain only available items (qobuz =
cached album/track within grace, plex = induced only, local always) and
collections with zero available items drop from the grids.
Mixed-playlist names offline: kept from the session cache on a mid-session
flip; honest 'Playlist (N local)' fallback on a cold offline start (names
are not persisted anywhere locally - noted as a future membership-snapshot
roadmap item).
D12: an offline session entry roots navigation at LocalLibrary, and an
engine watcher auto-navigates there when the mode flips to offline while a
blocked Qobuz view is open (re-filtering open MyQBZ views in place). No
navigation on the offline-to-online edge.
cc
New local_playlists + local_playlist_tracks schema in the shared per-user
library.db (ids 'local:<uuid>', sources qobuz/local/plex, position-compacted,
8 repo tests in qbz-library). PlaylistRef routing at the navigation seam
makes local ids unrepresentable in Qobuz playlist calls by construction
(all take u64). Local playlists appear in sidebar, manager and picker
(picker hides the Qobuz set while offline); detail view reuses the existing
playlist row machinery and renders offline; creation modal gains the
offline-only toggle (forced on while offline - creating offline always
produces a local playlist, D8) and Edit can flip it later; explicit
Upload-to-Qobuz conversion for non-offline-only locals (confirm dialog,
local rows attach via the existing sidecar, entity kept on failure).
Never-to-Qobuz guard: queues built from an offline-only playlist carry a
core-level stamp cleared by every queue replacement; the QConnect sync
skips the cloud push and play-on-peer falls back to local playback while
the stamp is set.
Deferred (logged in the slice report): plex-row attach on upload, custom
reorder for local playlists, folder/favorite/hide on local items, manager
sort interleaving, offline cover thumbs for cached qobuz rows.
cc
New OfflinePlaceholder (mode-aware copy + Open Settings shortcut for induced
mode) mounted at the AppShell routing seam over the 12 Qobuz-only content
views; the matching nav rows (sidebar, header tabs, compact rail) render
disabled while offline instead of hiding (layout stability). Playback gains
the offline playable rule (local always, plex only under induced, qobuz only
when cached and within the subscription grace): explicit plays fast-fail
with an honest toast (distinct copy when the grace expired), manual and
auto advance skip unavailable tracks bounded by 5 consecutive (gapless
target structurally exempt), gapless prefetch skips unplayable successors,
and the network prefetch warmer pauses offline.
cc
QConnect is unavailable in any offline flavor: connect() refuses while
offline (toast + same reset path as the api gate), a watcher subscribed to
the engine force-disconnects through the exact manual-disconnect path on
every offline edge (badges and lifecycle clear identically), an end-of-
connect re-check closes the flip-while-connecting race, and the session
loop host skips its controller re-join while offline. No auto-reconnect on
the online edge by design. The shared remote stream feeder stays ungated
(it also serves Plex, which must survive induced offline).
main.rs carries the wiring for all three slice-3 pieces: the offline
force-disconnect spawn, the offline-gated save-as artwork fallback, and
the favorites disk seed in both shell entries.
cc
Hoists Tauri's FavoritesCacheStore into qbz-app::settings::favorites_cache
(same per-user favorites_cache.db filename and schema, file shared across
frontends; 4 store tests). Slint's fav_cache now binds the store per user,
seeds the in-memory id set from disk at session activation (online,
restore, recovery and offline entry - fixing the start-offline cold-hearts
gap Tauri never closed), writes through on every favorite mutation, and
full-replaces the disk mirror on network refreshes. Queue hearts read the
shared cache instead of re-fetching from the network. All track-favorite
mutation paths are gated with a toast while offline (read-only hearts).
Part of offline-mode slice 3; main.rs wiring rides the slice-3 wiring
commit.
cc
Shared artwork HTTP client gains the missing 10s total timeout (half-up
networks could pin the 16-permit semaphore). fetch_cached_http short-
circuits while offline: disk hits serve, misses return the placeholder
immediately with zero network and no negative caching. Now-playing meta
resolves MPRIS art to a cached file:// URL (or none) instead of an
unfetchable https URL while offline; system notifications skip their
blocking artwork download offline via an injected flag, keeping
qbz-media-controls frontend-agnostic.
Part of offline-mode slice 3; main.rs wiring rides the slice-3 wiring
commit.
cc
New OFFLINE MODE group above the cache group on the Offline settings page:
read-only tri-state status row (online / no connection / offline mode
enabled, with captive-portal hint) plus an honest Check now that forces an
actor re-probe; Enable Offline Mode toggle driving the shared engine (issue
#279 stream_first_track snapshot/restore included, Playback panel re-synced
after); Show Network Folder Content toggle mounted only while induced
offline is on. Deliberate deviations from Tauri per the approved spec: no
probe gate on disable, no dead cast/scrobbling toggles.
cc
New OfflineState global mirrored from the shared engine by a status
forwarder task. Login screen: start-offline link gated on a previous
session existing, connectivity-down callout pointing at it (with captive
portal hint), init-error callout fed from the restore flow. Shell: D2
recovery banner (connection back during an unauthenticated offline
session) with one-click token sign-in that lifts the offline gate for the
login attempt and re-enters the full online shell.
Double-init hardening for the recovery path re-running enter_shell over a
live shell: synchronous guard on the Linux tray init (its OnceLock was set
asynchronously, leaving a duplicate-tray window) and a start guard on the
playback poll loop (a second loop would double-advance the queue).
cc
Start offline on the login screen now enters a full offline session instead
of an empty shell: session scaffolding at the last user, local library DB +
mixtape migrations, offline cache + cached-ids warm, per-user pref stores,
tray and media controls, playback poll loop, settings snapshot - mirroring
Tauri's activate_offline_session checklist. Refuses when no previous session
exists (never opens the empty user-0 profile).
New offline_mode glue binds the shared engine + connectivity actor for the
app lifetime and owns the per-user SubscriptionStateStore: mark_valid on
every successful login, mark_invalid only on an explicit IneligibleUser
verdict, binary 30-day-grace playback predicate, and the Tauri-mirrored
purge-at-activation consumer (previously the Slint build never opened the
store, leaving the grace model inert).
Token-survival fix: a network-failed session restore no longer clears the
saved OAuth token; only an explicit auth rejection does. login_with_token
now preserves the typed ApiError (CoreError::Api) so callers can tell the
two classes apart.
cc
All 23 request sites in QobuzClient now obtain the reqwest client via the
private http() choke point, which fails fast with the non-transient
ApiError::OfflineMode while offline mode is active (D3: zero Qobuz services
offline). Warm-start init still serves cached bundle tokens offline and only
skips the background refresh; the cold bundle fetch fails fast instead of
waiting out network timeouts. Behavior with the gate open is unchanged.
Known bypass left for the QConnect slice: get_http() feeds the qws
createToken POST in qbz-slint; QConnect gets its own connect()-level gate
per D5 (never available offline).
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
The DetailRow had a full-row `row-ta` TouchArea (clicked -> open the item/album)
declared AFTER the cell layout. In Slint a later-declared element paints ON TOP,
so row-ta was frontmost over the whole row and captured every click -- including
the ⋯ button -- so the menu never opened and the row just opened the album. Moved
row-ta to be declared BEFORE the cell HorizontalLayout, so it sits behind the
per-cell TouchAreas (checkbox / play / title / subtitle / ⋯) and only catches the
empty gaps. The ⋯ menu (and the play overlay / title) now receive their clicks.
cc
- Detail row ⋯ menu (and the hero overflow + filter popups) were PopupWindows
with no explicit height, so they collapsed and the rows below painted over them.
Gave each an explicit height (+ close-on-click) like every other app menu, so
they paint above the rows.
- Row menu Remove now actually removes: it was a no-op ("remove" wasn't a RowMode)
-> wired to remove the item (repo::remove_item by position, reload). Add-to-queue
was also silently broken (emitted "play-later", unparsed) -> now "add-to-queue".
- Sidebar playlist right-click menu: added "Add to Mixtape/Collection" (opens the
picker with the playlist payload).
- Album -> Collection: an album can now be added to ANY collection kind incl.
artist_collection (removed the ArtistCollection exclusion in load_rows; Tauri
allows adding an album to an artist discography).
Offline in the row menu is net-new (Tauri's collection row menu has none; it would
be a batch resolve-then-cache) and is NOT added here. Backend crates untouched.
cargo check -p qbz-slint green.
cc
The ToolBtn row used an absolute `x: 8/10px` offset together with
`width: preferred + padding`, which pushed the icon (and label) off-centre —
the Filter and Select buttons read as misaligned. Replace the x offset with
symmetric padding-left/right inside the HorizontalLayout and size the button to
the layout's natural width, so the glyph is centred deterministically.
cc
Per the user's 2nd visual review:
- Local/Plex album art now renders in the detail rows: resolve_items backfills
empty-art rows from the first resolved track (source-aware dispatch ->
spawn_local_or_plex_loads with live Plex creds); to_item re-hydrates from cache.
- Type/source FILTER homologated: a LABELED filter button (FavGenreButton idiom,
accent-fill active) + a checkbox popup; the "Select" toggle is now ICON-ONLY
(square-check-big, no text) matching Favorites/LocalLibrary.
- New primitives/SelectionCheckbox.slint (the canonical CIRCULAR 13px selection
checkbox, accent fill, check / minus for indeterminate). Replaced all 5 square
MyQBZ checkboxes (detail list+grid, filter popup, builder per-row + tri-state
select-all). KindRadio dots stay round radios (single-select, correct).
- Quality column shows a LoadingDots skeleton while resolving (not empty).
- Album view header buttons: deleted AlbumPageView's private divergent CircleAction
copy -> the shared primitive, so it matches every other view's thickness.
CollectionsView kind-filter stays QbzSelect (single-select group-by = the app's
group-by idiom). Backend crates untouched. cargo check -p qbz-slint green.
cc
Per the user's visual review. Replaces the reinvented MyQBZ controls with the
app's existing primitives and resolves the per-item data left unresolved.
Cross-cutting (all MyQBZ views):
- Removed the per-view "Back" header (the app uses the global < > nav).
- 32px L/R content padding so the ListScrollbar never rides over rows/cards.
- Homologated page padding + Typography tokens to Favorites/LocalLibrary.
- No more blue accent-filled pills / non-app colors.
Component homologation (deleted the reinvented locals + dead code):
- Grid/list + detail view-mode toggle -> the app ViewToggle (ToggleButton,
surface-hover active, never accent). Builder ORDER-BY segmented de-blued too.
- Sort/filter -> QbzSelect (sm); search -> ExpandableSearch (sm); "+ New" ->
NewActionBtn (surface-elevated circle, single centered glyph). Header "+ New"
hidden when empty so the empty-state CTA is the only one (no duplicate).
- Deleted PrimaryCta / MyQbzSearch / ControlBtn / ViewSeg / ViewModeGroup /
SortItem / ColMenuItem + the hand-rolled sort/filter popups + reset.
Data (resolveItems, was deferred):
- Quality column populated from the resolved tracks (QualityBadgeFull).
- New ui/primitives/SourceGlyph.slint: full-color Qobuz/Plex glyphs + a
text-muted local hard-drive -- distinguishes Local vs Plex, no accent blob.
Reused in the detail + builder rows.
- Type column shows the real release type (ALBUM/EP/SINGLE), not always ALBUM.
- Local/Plex album art now loads in the detail rows (split artwork dispatch).
- Collection grid card renders custom_artwork_path.
- Builder rows: 44px + a hairline so quality badges read separated.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12e (final fidelity touch-up).
- src/myqbz_view_prefs.rs: per-user JSON store (users/<id>/collection_view_prefs.json),
keyed by collection id, persisting the five §18 fields (view_mode, sort_by, sort_dir,
type_filter, source flags qobuz/plex/local). searchQuery + selectMode stay transient.
Restore-on-open with a PREFS_HYDRATED gate (early setters can't clobber the load);
persist on every toolbar change (sort/type/source/reset/view-mode); clear on delete.
3 unit tests. 1:1 with Tauri's collection-view-prefs.{id} + prefsHydrated.
- myqbz_play::shuffle now persists play_mode='album_shuffle' (best-effort set_play_mode +
reload so the overflow play-mode label flips) ONLY when not already album_shuffle --
mirrors handleShuffle; normal Play never persists.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12d.
- Discography Builder: uppercase eyebrow + ORDER BY + 6 column labels (BUILD ARTIST
COLLECTION / YEAR/TYPE/ALBUM/TRACKS/SOURCE/QUALITY); Create now disables on a
whitespace-only name (name-trimmed-empty computed in the controller).
- Expanded-mode: per-item inline-track cache keyed by the stable source|source_item_id
(survives filter/sort/search re-derive instead of re-fetching), cleared on open.
- Inline tracks: go-to-album passes the parent item_type (playlist parent routes to
the playlist view); duration 0 -> "--:--"; track number from the resolver order.
- Detail bulk bar: completed the §13.1 set (Add to queue, Play next, Add to playlist,
Add to Mixtape/Collection, Remove, Clear) via MultiSelectBar slots + bulk_enqueue
(reuses the resolver) + the existing playlist picker; no invented primitive.
- Stale comments corrected (dj-mix wired; mix-modal primary = Add to queue).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12c (user directive: use the app's circular controls, no invented
buttons; fix the glyph-stuck-left bug).
- Hero §3.6: deleted the local ActionCircle; the 4 hero controls are now the
shared CircleAction (Play primary = filled white disc + black glyph; the loading
spinner is now available = more 1:1). Added `enabled` to CircleAction (additive).
- All MyQBZ modal footers (Create / Edit / Mix / AddTo / Discography) now use the
shared QbzPrimaryButton (+ a new `destructive` variant for Delete) and a new
shared SecondaryButton (promoted from AppearanceSettings' inline copy).
QbzPrimaryButton auto-sizes as a footer chip while staying full-width in login
(verified: login stretches it in a VerticalLayout; footers take preferred width).
- Kept legitimately-local icon+label controls (grid "+ New" CTA, AddToMixtape
picker chips, toolbar sort/filter/view-mode pills) but verified every glyph is
centered.
- Fixed 3 glyph-stuck-left bugs (DiscographyBuilder checkboxes used non-snapped
centering -> the Math.round(.../1px)*1px crisp idiom).
Backend crates untouched. cargo check -p qbz-slint green.
cc
Phase-2 Slice 12b.
- Expanded view-mode: replaced the "Inline tracks coming soon" placeholder with
real per-item inline track expansion. Matches Tauri 1:1 (no chevron -- every
album/playlist row auto-shows its tracks). fetch_item_tracks reuses the shared
ProdItemResolver (display-only; spec §8 routing matrix); renders the app's
TrackRow indented; inline-track media actions (play/play-next/play-later/go-to).
- skip-to-item: headless myqbz_play::skip_to_next_item/skip_to_previous_item over
the qbz-core queue using the crate's next_item_index/previous_item_index (3s prev
rule). UI trigger DEFERRED -- Tauri has zero frontend callsites for skip-to-item
(no skip-album button exists), so wiring one would invent UI; the global next()/
previous() are untouched.
Known polish (S12 fidelity pass): expanded-mode per-item cache should key on
source|source_item_id (survive filter/sort) + viewport-gate; inline go-to uses
item_type; duration "--:--". Backend crates untouched. cargo check green.
cc
Phase-2 Slice 12a. Per the user's decision (DQ3), the "My QBZ" rename/change-icon
lives in Settings > Appearance, not a sidebar context menu:
- src/myqbz_prefs.rs: per-user {label, icon_path} persisted to
users/<id>/myqbz_branding.json (scoped like plex_settings/tray_settings);
init_for_user/seed/set_label/pick_icon(rfd)/reset_icon. Blank label coerces to
"My QBZ"; a missing/deleted custom icon falls back to the default my-qbz.svg
without crashing (path preserved). 4 unit tests.
- ui/settings/AppearanceSettings.slint: a "My QBZ" SettingRow (icon preview +
label LineEdit + Change icon... + Reset).
- ui/shell/Sidebar.slint: the My QBZ row binds label/icon to MyQbzBrandingState
(raw-icon flag renders a custom user image untinted; default glyph stays tinted).
- Seeded on enter_shell.
Backend crates untouched. cargo check -p qbz-slint green.
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 8. Adds "Add to Mixtape/Collection" everywhere "Add to playlist"
lives, so collections can be populated:
- ui/myqbz/AddToMixtapeModal.slint: the picker (search + collection list with
kind icon/name/count + "Already added" via item_exists + kind tag) and the
create-new-inline panel (name + kind), per spec 21. Mounted once at root.
- src/myqbz_add.rs: list_collections (kind-restricted: track/playlist->mixtapes,
album->mixtapes+collections, never artist_collection; recency-sorted); add via
add_item_with(allow_duplicate=false) counting added/skipped-duplicates + toast;
create_and_add reuses create_collection. All via with_db -> with_connection.
- Entry points wired: per-track (universal via TrackContextMenu/TrackRow -> search,
favorites, album rows, local tracks, now-playing), Qobuz album, local/plex album,
playlist (PM), bulk local tracks. Replaced the PlaylistManager add-to-mixtape no-op.
Qobuz bulk omitted by design (spec 50 negative-space).
Note: adversarial reviewers were blocked by a session limit; verified manually
(cargo green, confined to qbz-slint, backend untouched, ADR-005/006, coverage per
spec 50).
cc
Phase-2 Slice 4. The grid "+ New" buttons open a create modal (name + kind
radio: mixtape/collection, artist_collection excluded), create via the shared
crate, and drop the user into the new collection's detail (1:1 with Tauri's
submitCreateModal -> openMixtapeDetail). The new row prepends to its kind.
- ui/myqbz/CreateMyQbzModal.slint (mirrors CreatePlaylistModal/CreateFolderModal
idiom; spec 30 create-modal geometry).
- MyQbzCreateState/MyQbzCreateActions in state.slint; mounted in AppShell.
- src/myqbz.rs create_collection via with_db -> with_connection ->
qbz_mixtape::repo::create_collection (Manual, 80-char cap).
- Un-stubbed on_create_mixtape/on_create_collection; trim + busy guards; toast.
Backend crates untouched. cargo check -p qbz-slint green.
cc
Drop the `&& !MyQbzState.loading` gate on the zero-items empty states so
"No mixtapes yet" / "No collections yet" shows during load instead of a
blank, matching the Tauri/Svelte behavior (empty array -> empty state
flashes until the load resolves). Per user decision (DQ: replicate).
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
- Replace the folder list with a compact table (sticky header: FOLDER / LAST SCAN /
STATUS + actions, internal scroll).
- Per-row sync (scans only that folder) and per-row delete (removes the DB entry +
indexed tracks, never the files); delete no longer relies on rfd/zenity (which
fails-closed on zenity-less systems) — direct removal + a toast.
- Fix the row event order so the per-row buttons receive their clicks instead of
toggling selection.
- Fix mis-centered button glyphs (Stop, change-path, scan, tag-editor search): an
inner layout filled its parent so the centering collapsed to x:0 — use explicit
padding.
cc
Album view (local/Plex):
- Rebuild LocalAlbumView as a near copy of the Qobuz AlbumPageView (whole-page
scroll, 224px cover, action row, toolbar with quality badge + track search,
column header, source-aware rows) minus the label/awards sidebar and Qobuz
context menus. Add a per-album client-side track search.
Recently Played (Home) — D:
- Carry the track source (qobuz|plex|local) through the recently-played store so
the cards resolve source-aware artwork (PlexThumb / local file), the album-card
play routes to local/Plex playback instead of the empty Qobuz path, and the
card click opens the right album view.
- Fix a regression from the Plex version split: local_queue_track now derives the
content-hash plex_album_key for album_id (navigation) while album_group_key keeps
the per-edition split key, so "go to album" and recent Plex albums open correctly.
Local artist navigation — B:
- Implement open_artist (was a TODO): latch the requested artist and select it once
the Artists tab loads. The shared open-artist is now source-aware (numeric id ->
Qobuz artist; name -> LocalLibrary Artists tab). Wire the LocalAlbum header artist
link and the now-playing "Go to artist" for Plex/local.
Folder delete -> Recently Played cascade:
- Removing a Local Library folder now also prunes its albums/tracks from the
recently-played store (album_keys_in_folder + recently::prune_albums).
Covers — C:
- Robust on-disk folder cover lookup (cover/folder/front/art/<album>.jpg, any image
as a last resort), shared by the Folders subcards and now-playing/album rows.
cc
A Plex album_key is a hash of artist+album, so two distinct Plex albums
that share a title (e.g. a matched album plus loose files added as a second
album) collapsed into one cache album and their tracks interleaved
(1,1,2,2,...) in the album view with no way to pick a version.
Capture parentRatingKey (the real per-album Plex id) on sync, store it in a
new plex_cache_tracks column, and key the album-view version grouping on it
so each Plex album becomes its own version in the existing header picker
(best quality first). Pre-resync rows lack the column and fall back to the
old single-group behaviour; a re-sync backfills it.
cc
The shared open-album callback always recorded a Qobuz Album entry, so a
Plex/local item (Home "Recently played", the now-playing bar's "Go to
album") opened an empty Qobuz album view. Detect the Local-Library/Plex
metadata group key (album|artist, a folder path, plex:, or the
__unknown_album__ sentinel — none of which appear in numeric Qobuz ids) and
route those to the LocalAlbum view instead.
cc
Wire the per-history-entry scroll memory (added for Local Library) into
every top-level view: Home/Discover, Album, Local Album, Artist, Musician,
Label, Label Releases, Location, Favorites, Mix, Playlist, Playlist Manager,
Offline Manager, Search, Settings.
Each view's primary scroll container reports its viewport-y and restores it
on back/forward via NavState.restore-scope (scope_for in main.rs maps each
entry to a stable id; tab views carry the tab, e.g. "fav:albums"). The
container restores once per arm, only after content lays out, then clears
the scope. A `changed sr-armed` trigger covers views that persist across
sub-tab navigation (Home's Discover tabs, Favorites' shared list, Playlist
A->B->A) where no fresh mount occurs.
ScrollView-based views (Offline Manager, Settings) use visible-height for
the overflow check; ListView/Flickable views use height. Favorites' shared
albums/artists/playlists/labels Flickable matches the active tab's scope
dynamically.
cc
Scroll-position restore was never implemented (only a TODO in nav.rs);
back/forward tracked which page was visited, not where it was scrolled.
Add per-history-entry scroll memory: the mounted scroll container reports
its live viewport-y via NavState.report-scroll, so every navigation (fresh
record + back/forward) stamps the outgoing entry without touching the ~30
record call sites. go_back/go_forward hand the saved scroll back to the
shell, which arms NavState.restore-scope + scroll-restore before switching
the view. A container restores only when its scope matches, once per mount,
and only after content has laid out (viewport overflows) to dodge the
clamp-to-0 on async data load; it clears the scope once consumed.
Wired the four Local Library lists (albums, tracks, folders, artists rail).
Tracks is paginated, so its restore is best-effort within loaded pages.
cc
Two Plex-port fixes (both in the Plex play/data path):
1. Progressive streaming — play starts in ~1s instead of ~10s. play_plex_audible
no longer downloads the whole file before play_data; it resolves only the
part URL (new additive qbz_plex::plex_resolve_part_url, no body GET) and feeds
it into the player's streaming source (play_streaming_dynamic) via a shared
feeder module (remote_stream.rs, extracted from the QConnect renderer's HTTP
feeder — both now share it). Bit-perfect preserved: the original /file bytes
are streamed (no transcode), device init derives from the decoded stream; the
protected audio backend is untouched. Cast/DLNA keep the whole-file path.
Falls back to whole-file play_data on a non-direct part / streaming-setup error.
2. Re-sync no longer wipes hydrated quality — the sync did plex_cache_clear()
(full wipe) BEFORE the per-section save_tracks, defeating save_tracks' COALESCE
hydration carry-over, so already-hydrated albums reverted to a bare FLAC badge
on every settings-open / background-refresh / section-toggle. Replaced the full
wipe with a new additive plex_cache_prune_sections(keep) that removes only the
DE-selected sections, keeping the selected sections (and their hydration);
save_tracks then re-applies its per-section carry-over.
cc
A play can take seconds before audio starts (esp. Plex, whose resolve downloads
the whole file) with no feedback today. Add a source-agnostic loading indicator:
- A loading flag keyed to PENDING_PLAY_ID, raised at the top of play_audible
(before the Plex/Qobuz/local resolve — single chokepoint, covers auto-advance)
and cleared by the poll loop when the requested track's audio actually starts
(position > 0). Cleared on every play-error early-return and force-cleared on
queue-finished / peer-controller / ephemeral-stop so it never sticks.
- Watchdog: force-clear after 45s if the engine accepted a play that silently
never advances (undecodable-but-valid file) — otherwise the spinner is eternal.
- Spinner on three surfaces driven by NowPlayingState.loading: now-playing bar
(cover overlay + play/pause button), the active track row's play cell, and the
album big play button (gated to the album actually being launched).
Audio backend / playback routing untouched. The spinner only communicates the
wait; the underlying Plex fetch latency is fixed separately (progressive streaming).
cc
The Plex artwork-perf slice added urlencoding/url to qbz-models; the lockfile
sync only landed in crates/Cargo.lock. src-tauri depends on qbz-models, so its
lockfile needs the same entries (no behavior change).
cc
Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
refresh of all badge surfaces — album-card grid (re-read aggregate + patch
every model + the raw filter source), album-detail header + rows, now-playing
stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).
Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
(tier, detail, tooltip); the album card, album-detail header and track rows
all route through it (kills the per-surface duplication that produced
"96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
"24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.
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
A Plex queue track's artwork was file://-prefixed onto its raw /library/ thumb
path, breaking the cover on all three surfaces. Approach (b) from the funnel
review: keep the raw path in QueueTrack.artwork_url (token never persisted) and
resolve to a fresh tokenized PlexThumb from live plex_settings creds at each
surface.
- qbz-models: additive QueueTrack::artwork_ref_with_plex(base_url, token) — a raw
/library or /photo path + creds => ArtworkRef::PlexThumb, else falls back to
artwork_ref() (http/file:// unchanged). 4 regression tests.
- playback.rs: local_queue_track keeps the raw path for Plex rows (no file://
poison); refresh_now_playing_meta uses artwork_ref_with_plex -> fixes the
now-playing bar, MPRIS (to_mpris_url), and the desktop notification.
- queue.rs: load_artwork threads creds + a PlexThumb branch for the queue panel.
- Local/Qobuz/ephemeral artwork byte-for-byte unchanged.
cc
Both tabs were Plex-blind. Loader-level merge (no qbz-library change):
- qbz-plex: additive PlexCachedArtist + plex_cache_get_artists() (aggregates
the flat plex_cache_tracks by normalized artist) + plex_cache_count_tracks().
- Artists tab: fold Plex artists into the local set before merge_artists
(same-name local+Plex collapse, summed tracks); artist-detail album grid +
counts switch to the Plex-aware get_albums_metadata_page; portraits fall back
custom/Qobuz -> Plex thumb -> album cover, dispatched via spawn_local_or_plex_loads.
- Tracks tab: prepend the bounded Plex set (<=5000) once at offset 0; later
pages stay pure-local so the virtualized 16K-row LIMIT/OFFSET path is intact.
- Tracks badge now includes the Plex count (matches albums/artists badges).
- All gated on plex_settings enabled (Plex OFF = byte-for-byte local-only).
Plex track id namespaced into [2^40, 2^41) so it cannot collide with local
track ids (< 2^40) or ephemeral ids (>= 2^48) in the merged Tracks tab — the
string rating_key is preserved in file_path, so playback is unaffected.
cc
Plex tracks produced no audio: local_queue_track had no "plex" arm so they were
enqueued as source="local", and there was no Plex resolve+play path.
- local_queue_track tags source="plex" and carries the string rating_key in
source_item_id_hint (the numeric queue id is a hashed/parsed form; the resolve
needs the original key); persisted in the session queue store.
- play_audible gains a "plex" arm -> play_plex_audible.
- play_plex_audible: qbz_plex::plex_resolve_track_media(base, token, rating_key)
-> player.play_data(bytes, id), mirroring the local-file seam. Bit-perfect:
play_data drives device init from the DECODED bytes; Plex metadata is
display-only. Covers album play, single track, and auto-advance.
cc
Albums tab only (Artists + flat Tracks tab remain Plex-blind — next slice):
- Albums loader switches to get_albums_metadata_page(.., Some(plex_cache_path))
so the plex_aggregated union surfaces Plex albums alongside local; gated on
plex_settings enabled (None path = local-only, no regression). seed_counts
uses the page total so the badge includes Plex.
- Album-detail branches group_key "plex:" -> qbz_plex::plex_cache_get_album_tracks,
mapped to LocalTrack (rating_key in file_path, source=plex, album name into
album_group_title for the detail header).
- Source-aware grid + album-detail-header covers via spawn_local_or_plex_loads:
a /library|/photo path with creds becomes ArtworkRef::PlexThumb, else LocalFile.
Queue/now-playing/MPRIS artwork untouched (that is slice 4).
- Truthful module/Albums-tab docs.
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
Ephemeral folders: open / browse / play a folder without indexing it —
album-grouped pane, CUE seek fast-path (no full re-decode per virtual track),
folder-path persistence + rehydrate, "already playing" add-vs-replace choice
dialog (ephemeral-only), and now-playing / queue / menu gating so an ephemeral
track exposes no persistence actions.
Folders tree brought 1:1 with Tauri:
- Rail header: select-mode, collapse-all, open-ephemeral, tree search.
- Resizable rail (default ~20% narrower; drag handle, min 50% default /
max 50% of the content area).
- Subfolder cover grid + grid/list toggle, recursive track count, name search;
compact icon bulk bar (folder-check / play-next / queue / playlist / clear).
- Tree is the default mode; circular checkboxes homologated with TrackRow;
compact rows (smaller type + icon, no ambiguous trailing count).
- Flat/Tree icon toggle (disc-album / folder-tree), floated right.
Per-source TrackRow context menu (logic, not a blanket trim): Qobuz/offline =
full; local = play/next/queue + add-to-playlist; plex/ephemeral = play/next/
queue only.
App-wide standard: Math.round pixel-snap on icon centering across the UI to
kill sub-pixel blur (CircleAction, header back/forward/collapse, and the rest).
NowPlayingBar context icon -> turntable (frees disc-album for the Folders
toggle). New assets: folder-check, folder-tree, turntable.
cc
Lift the in-memory ephemeral-folder library (synthetic-id store + folder
scan / metadata / CUE handling) out of the Tauri binary into a
frontend-agnostic qbz-library module (ADR-006), so both the Tauri and Slint
frontends consume identical logic. src-tauri/ephemeral_library now re-exports
it as a thin shim. Adds tracks_snapshot() / current_folder_path() for the
Slint consumer.
cc