Commit Graph
5226 Commits
Author SHA1 Message Date
vicrodh 6d06a8aa83 feat(slint/discover): qobuzPlaylists row on Home and Editor's Picks
Add the missing Discover "Qobuz Playlists" row, present on the Home and
Editor's Picks tabs (never For You), 1:1 with Tauri's PlaylistCardLite:
single cover (rectangle || covers[0]), name only, body=open / overlay=play.

- home.rs: PlaylistCardData + map_playlist (drops owner/duration/count);
  HomeData + TabSections playlist fields; capture containers.playlists
  (capped 18) in load_home; playlist_to_item (single-cover SearchPlaylistItem,
  mirrors label.rs); playlist_artwork_jobs; select_tab swaps the active tab's
  row + concatenates its artwork jobs; apply_home caches both sets + pushes Home.
- state.slint: HomeState.playlists: [SearchPlaylistItem].
- PlaylistCarousel.slint: forward a new body-opens prop to PlaylistCard.
- HomeView.slint: mount PlaylistCarousel after the section loop, gated on
  active-tab != forYou && playlists.length > 0, body-opens: true.
- artwork.rs: HomePlaylistCover { idx } variant + apply arm (cover1).
- main.rs: reload_home extends jobs with the active tab's playlist covers.

cc
2026-06-15 12:55:28 -06:00
vicrodh a053f0d67f fix(slint): wire ("playlist","play") media-action arm
The on_media_action chain had no ("playlist","play") arm, so the
PlaylistCard play-overlay button and context-menu Play (which emit
"play") fell through to the dead _ => {} no-op everywhere they were
used (Search, Label, and now Discover).

Add a playback::play_playlist(id) that fetches the playlist fresh,
interleaves the mixed-playlist sidecar rows (same as enqueue_playlist),
replaces the queue and starts at the first track (set_queue + context),
then wire the new ("playlist","play") arm to it. Unlike the stateful
play-all arm (which reads the open PlaylistView state), this serves a
cold card play from any carousel.

cc
2026-06-15 12:52:26 -06:00
vicrodh 2a37f2ad91 feat(discover): add per-tab section-prefs core store (configurator model)
Frontend-agnostic port of Tauri discovery-v2/sectionPrefs.ts (ADR-006):
the ordered per-tab {id,enabled} model for the Discover configurator
(Home / Editor's Picks / For You). Pure model logic (default_prefs,
migrate with the legacy-array / object / garbage branches, reconcile_list
with drop-unknown + first-wins dedup + append-missing, toggle, move_section
with boundary clamp, reset_tab, is_enabled, enabled_count, enabled_ordered)
plus a per-user SQLite store (dedicated discover_prefs.db, single JSON-blob
column in the Tauri-compatible shape). 10 headless tests green.

Lives in qbz-app::settings::discover_prefs so any frontend (Slint, Tauri)
drives the configurator from one source of truth.

cc
2026-06-15 12:45:14 -06:00
vicrodh 454a6d70af perf(slint): load For You progressively and in parallel
Replace the sequential ~9-round-trip load_for_you + monolithic
apply_for_you with a progressive, parallel loader. The recently-played
sections paint instantly (local, no network); the independent API calls
fan out into concurrent branches (release-watch, favorite-artists,
favorite-albums, album-suggest) and each section applies the moment its
data resolves via upgrade_in_event_loop. The loaded flag is latched only
after every branch resolves, so the ensure_for_you_loaded re-entry guard
can never strand a partially-loaded tab.

Artists to Follow keeps byte-identical dedup/cap semantics (concurrent
per-seed fetch via join_all in seed order, then sequential dedup + cap).
album-suggest runs concurrently in the common case (recent-album seed),
falling back to the first favorite album only when there is no history.
The For You skeleton now hides as soon as the first section paints.

cc
2026-06-15 12:37:25 -06:00
vicrodh 8d26b64297 Merge feature/wgpu-underlay-spike into slint-mvp
wgpu fragment-shader underlay spike for ImmersiveView (validated Linux KDE/Wayland + macOS Metal). Renderer swap renderer-femtovg -> renderer-femtovg-wgpu (Linux/Windows) + renderer-skia on macOS (femtovg-wgpu pegs CPU on Metal). Slint pinned =1.16.1, unstable-wgpu-28.

cc
2026-06-15 11:41:51 -06:00
vicrodh 1ea784bb14 chore(slint): add slint-run.sh dev helper (run prebuilt binary directly)
Builds in release then execs crates/target/release/qbz-slint directly instead of
via 'cargo run', so process monitors show it as 'qbz-slint' (a cargo-run child
inherits CARGO_* env and shows as 'cargo'), cleanly separate from the build
processes. Same release flags as slint-dev.sh.

cc

(cherry picked from commit 41269aabbe)
2026-06-15 02:08:51 -06:00
vicrodh dc1764e09b fix(slint): immersive spectrum colors use the album's real palette
spectrum_colors derived the secondary gradient colour as dom_hue + 55deg, which
fabricated a hue the cover does not contain (a mono pink/magenta cover became
pink->orange). Use real album colours: a second genuine hue cluster when the
cover has one (>=45deg from the primary, >=35% of the peak mass), otherwise the
same dominant hue deeper + more saturated. Mono-colour covers now read as a
one-hue light->deep gradient.

cc

(cherry picked from commit 6aa8d3b3d1)
2026-06-15 02:08:51 -06:00
vicrodh eef8d965b5 fix(slint): SongCard title elides at exact available width in every NPB layout
The now-playing title elided early (the wrapping Rectangle content-sized to the
meta line) and, when forced to width:100%, overflowed into the audio stamp.
Replace both with an explicit `title-avail` width = card width - cover - art-gap
- (in-card stamp + 4px, when present), mirroring the inner row's width formula.
The title now elides at precisely the free space in New / Classic / Small at any
window size (the formula is parametric on root.width + has-stamp + show-art, so
Big inherits it), leaving the intended ~4px gap before the stamp and never
overlapping it.

cc

(cherry picked from commit 2acf53ed20)
2026-06-15 02:08:51 -06:00
vicrodh 41269aabbe chore(slint): add slint-run.sh dev helper (run prebuilt binary directly)
Builds in release then execs crates/target/release/qbz-slint directly instead of
via 'cargo run', so process monitors show it as 'qbz-slint' (a cargo-run child
inherits CARGO_* env and shows as 'cargo'), cleanly separate from the build
processes. Same release flags as slint-dev.sh.

cc
2026-06-15 02:08:34 -06:00
vicrodh 6aa8d3b3d1 fix(slint): immersive spectrum colors use the album's real palette
spectrum_colors derived the secondary gradient colour as dom_hue + 55deg, which
fabricated a hue the cover does not contain (a mono pink/magenta cover became
pink->orange). Use real album colours: a second genuine hue cluster when the
cover has one (>=45deg from the primary, >=35% of the peak mass), otherwise the
same dominant hue deeper + more saturated. Mono-colour covers now read as a
one-hue light->deep gradient.

cc
2026-06-15 02:08:34 -06:00
vicrodh 2acf53ed20 fix(slint): SongCard title elides at exact available width in every NPB layout
The now-playing title elided early (the wrapping Rectangle content-sized to the
meta line) and, when forced to width:100%, overflowed into the audio stamp.
Replace both with an explicit `title-avail` width = card width - cover - art-gap
- (in-card stamp + 4px, when present), mirroring the inner row's width formula.
The title now elides at precisely the free space in New / Classic / Small at any
window size (the formula is parametric on root.width + has-stamp + show-art, so
Big inherits it), leaving the intended ~4px gap before the stamp and never
overlapping it.

cc
2026-06-15 02:08:34 -06:00
vicrodh 62449c757d fix(slint): remove animated idle cat in SongCard that forced continuous repaint
SongCard lives in the always-mounted now-playing bar. Its idle (no-track) state
drew a sleeping cat with two animation-tick()-driven drifting "z"s, which forced
the whole window to repaint at the display rate forever while idle — ~4% CPU on
Linux and ~55% on macOS/Metal on a static Discover screen (Metal's higher
per-frame cost made it egregious). Replace with a static music glyph placeholder
(matches TrackPlayCell). Idle is now lazy (~0 fps).

cc
2026-06-15 00:19:44 -06:00
vicrodh 43c89a978f fix(slint): remove animated idle cat in SongCard that forced continuous repaint
SongCard lives in the always-mounted now-playing bar. Its idle (no-track) state
drew a sleeping cat with two animation-tick()-driven drifting "z"s, which forced
the whole window to repaint at the display rate forever while idle — ~4% CPU on
Linux and ~55% on macOS/Metal on a static Discover screen (Metal's higher
per-frame cost made it egregious). Replace with a static music glyph placeholder
(matches TrackPlayCell). Idle is now lazy (~0 fps).

cc
2026-06-15 00:19:43 -06:00
vicrodh fb0aa619a7 fix(slint): use Skia renderer on macOS (femtovg-wgpu pegs CPU on Metal)
The wgpu underlay spike validated the GPU-shader path, but femtovg-wgpu has a
high per-frame CPU cost on macOS/Metal: continuously-animating views
(ImmersiveView atmosphere, Discover) hit 100-120% CPU on a Mac mini M2 vs
0.5-4% for the same view on Linux. Skia is Slint's mature Metal renderer and
does not have this cost (immersive ~55%, never 100%); text renders correctly.
Static views stay lazy/idle on both renderers.

Choose the renderer per-platform: Linux/Windows keep renderer-femtovg-wgpu
(cheap per-frame there + the femtovg text fixes #5177/#11335); macOS (Intel and
Apple Silicon, CICD included) uses renderer-skia. Both keep unstable-wgpu-28 and
resolve to a *-wgpu renderer, so the shader underlay (require_wgpu_28 +
Image::try_from) works unchanged on both.

Cargo.lock updated with the Skia dependency graph.

cc
2026-06-14 23:52:33 -06:00
vicrodh e46e85546a feat(slint): wgpu fragment-shader underlay spike for ImmersiveView
Swap the winit renderer femtovg(GL) -> femtovg-wgpu (+ unstable-wgpu-28,
slint pinned =1.16.1) and render a WGSL plasma fragment shader into a wgpu
texture composited under ImmersiveView. The shader is fed by the existing
30fps visualizer drain (sub-bass energy + decayed transient) and toggled
in-view with the 'g' key via ImmersiveState.shader-mode; when active it
replaces the atmosphere + mode panels as the bottom layer, chrome stays on
top. Device/queue captured from Slint's rendering notifier so Image::try_from
shares Slint's wgpu device.

Phase 0 spike: validates the app-wide renderer swap with no font/UI/tray
regression. Pending macOS Metal smoke before the swap lands on slint-mvp.

cc
2026-06-14 10:59:56 -06:00
vicrodh b03c8fcb5c feat(slint): spectrum bar colors track the cover's dominant hue (vivid gradient)
The original RGB "most saturated sample" extraction produced a near-grey color
with no perceptible gradient on dark/metallic covers. Rework in HSL: bin
chromatic pixels by hue, pick the most ABUNDANT hue (coverage), force it vivid
(sat 0.85, mid-bright) so it reads on the black bg, and rotate +55° for the
secondary stop -> a clear gradient. True B&W covers fall back to a default
duotone. (Hue selection can still surprise on some covers; further tuning of
which tone wins is deferred.)

cc
2026-06-14 04:13:01 -06:00
vicrodh bc22362dc0 feat(slint): immersive responsive sizing + shared track-info block
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
2026-06-14 04:13:01 -06:00
vicrodh 25512d0d00 fix(slint): no background flicker on click-driven track change
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
2026-06-14 04:13:01 -06:00
vicrodh 421e84c571 fix(slint): immersive Coverflow z-order + ghost (left/right repeaters, no per-element z)
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
2026-06-14 03:04:33 -06:00
vicrodh d00ec1fa71 fix(slint): immersive atmosphere always regenerates (remove broken art-URL dedupe)
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
2026-06-14 03:04:33 -06:00
vicrodh c57437ce59 fix(slint): immersive Album Reactive - center-anchored speaker pulse, no lateral drift
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
2026-06-13 21:34:41 -06:00
vicrodh 5ded7b1610 perf(slint): immersive Coverflow - persistent elements + animated scroll (CSS/iPhone model)
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
2026-06-13 21:34:30 -06:00
vicrodh 763df2eab5 perf+feat(slint): immersive Coverflow jukebox pyramid + kill the per-click CPU spike
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
2026-06-13 11:34:35 -06:00
vicrodh 5146acf286 feat(slint): immersive Spectrum bar colors derived from album art (negative fallback)
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
2026-06-13 11:08:29 -06:00
vicrodh 03dfd66101 perf(slint): immersive Spectrum - collapse ~2240 cubelet nodes to ~28 gradient bars
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
2026-06-13 10:34:41 -06:00
vicrodh fa7aa46ce1 feat(slint): immersive header - segmented mode bar in the compact-player style
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
2026-06-13 09:19:11 -06:00
vicrodh 29e0ac75f7 feat(slint): immersive Spectrum Visualizer mode (mode 3) - 16-bar mirrored cube spectrum
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
2026-06-13 09:07:20 -06:00
vicrodh c067d23770 feat(slint): immersive Coverflow mode (mode 2) - faithful 2D fan, no fake 3D
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
2026-06-13 08:54:00 -06:00
vicrodh 2f89bbb207 feat(slint): immersive Static mode (mode 1) - faithful, responsive
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
2026-06-13 08:40:19 -06:00
vicrodh 786301a2e4 fix(slint): responsive album art in immersive Album Reactive so metadata never clips
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
2026-06-13 08:17:14 -06:00
vicrodh 92052a3c2a feat(slint): immersive Album Reactive mode + mode scaffold + provisional selector
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
2026-06-13 07:48:53 -06:00
vicrodh 9546656734 fix(slint): immersive player volume control - lock gating + Tauri-faithful UX
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
2026-06-13 06:38:07 -06:00
vicrodh 3adb517236 feat(slint): ImmersiveView base - reusable atmosphere + compact player + audio-transport lift
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
2026-06-13 04:11:53 -06:00
vicrodh 4e8703d660 feat(slint): Track Info + Album Info (Credits/Review) modals
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
2026-06-12 19:20:55 -06:00
vicrodh cf88516cb3 fix(slint): Qobuz Connect flyout stays open on Connect + discovery spinner
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
2026-06-12 13:30:17 -06:00
vicrodh 8fa03a8c03 feat(slint): NowPlayingBar Mode C "Small" + mode shell + floating album-art preview
- 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
2026-06-12 13:18:54 -06:00
vicrodh 3689a5fda8 feat(slint): add-to-playlist duplicate modal + playlist importer port
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
2026-06-11 22:03:46 -06:00
vicrodh 999ded11ff feat(slint): post-lyrics UI polish sweep + correctness fixes
- 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
2026-06-11 22:03:46 -06:00
vicrodh 67a0e39841 fix(slint): lyrics panel crash - never watch layout geometry in changed handlers
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
2026-06-10 18:47:20 -06:00
vicrodh eea336fd49 feat(slint): lyrics controls flyout, per-user prefs, cache settings row
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
2026-06-10 18:00:29 -06:00
vicrodh 5f9fd5bdd6 feat(slint): lyrics sync engine with karaoke fill and auto-center scroll
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
2026-06-10 17:14:37 -06:00
vicrodh 5a4dbd87f6 feat(slint): lyrics wiring + static sidebar rendering
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
2026-06-10 16:46:13 -06:00
vicrodh d12039371b feat(lyrics): qbz-lyrics engine crate with Qobuz-first chain, LRC parse/emit, providers, cache
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
2026-06-10 16:22:57 -06:00
vicrodh 46c15f507b feat(qobuz): track lyrics endpoint with live-verified wire DTOs
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
2026-06-10 16:03:44 -06:00
vicrodh 70fe3effb7 feat(slint): go-to album/artist on local track menus; playlist header buttons homologated
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
2026-06-10 13:26:34 -06:00
vicrodh 065e6be7f4 feat(slint): playlist detail track menus 1:1 (remove, source routing, bulk parity)
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
2026-06-10 12:15:25 -06:00
vicrodh 58b464ea71 feat(slint): mixed playlists - sidecar interleave in the Qobuz detail (carrete)
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
2026-06-10 11:53:22 -06:00
vicrodh 47c315251c fix(slint): plex rows resolve in local playlists; catalog-id guards
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
2026-06-10 10:09:11 -06:00
vicrodh 603a2725fb feat(slint): LocalLibrary track-row context menu homologated 1:1 with Tauri
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
2026-06-10 10:09:11 -06:00
vicrodh 016869e21b fix(slint): MyQBZ row artist links route by item source
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
2026-06-10 09:43:52 -06:00