Commit Graph
6014 Commits
Author SHA1 Message Date
vicrodh 6c81eaab27 fix(playlists): refresh the sidebar name optimistically after a rename
A renamed playlist kept its old sidebar row until app restart or the
manual refresh button, even though the rename handler already reloaded
the sidebar on success: the refetch (get_user_playlists, no client-side
cache) can return the pre-rename name — Qobuz playlist/list
read-after-write lag.

New sidebar::rename_entry(window, id, name) patches the sidebar CACHE
(Qobuz + local lists + the NAME_DESC session cache) and re-renders via
the same rebuild() apply() uses. Both rename success arms (local +
Qobuz) now call it FIRST, with the full reload still running after to
reconcile. Failure paths unchanged.

km
2026-07-20 08:09:43 -06:00
vicrodh ce1086b908 fix(ui): anchor the album-card "more" menu at the cursor
The card overlay's context menu used a fixed x:26/y:56 anchor, which put
the menu's vertical MIDDLE on the cursor (menu is 196x208, the button
sits at ~(152,142) of the artwork box). Natural context-menu behavior is
top-left at the click point. The OverlayButton clicked callback carries
no pointer coordinates, so anchor at the more-button's center (the
cursor proxy), converted into the artwork box's coordinate space via
absolute positions (artwork container gets an id for that).

km
2026-07-20 08:09:24 -06:00
vicrodh f0ae95b431 refactor(ui): unify the track context menu into one shell-level instance
Popup windows are always inlined by the Slint compiler, so every
`track-menu := TrackContextMenu` site — TrackRow in ~15 repeaters plus
SearchTrackHero — generated its own full copy: 18 structs, ~22k lines of
the 1.9M-line generated module, for one logical menu.

Now a single TrackContextMenu lives in AppShell, fed by the new
TrackMenuState global. Openers (TrackRow right-click / "..." gutter,
SearchTrackHero more button) write every gate input and bump
`open-token`; the shell instance watches the token and shows at the
pre-computed window coordinates (same scroll-safe absolute-position math
as the drag ghost). Gate expressions and dispatch ported 1:1 from the
former per-site instances — local-album view routes play/play-track +
track-menu-action, everything else hits the central media-action handler.
Menu closes on item click and on navigation.

QueueSidebar's queue menu and TrackCard's card menu are different item
sets and stay local (single instances, no multiplication).
Spec: qbz-nix-docs/context-menu-unification/2026-07-17-single-context-menu-spec.md

km
2026-07-20 01:05:30 -06:00
vicrodh 0283a372d7 feat(discover): ArtistGridCard in the Scene view, genres as subtitle
Replace the bespoke SceneArtistCard in ArtistsByLocationView with the
app-wide ArtistGridCard (follow / play / pin / context menu, homologated
with the other artist carousels). The scene model rides the shared
SlimItem (LocationArtistItem deleted): genres land in the subtitle — the
card's second row under the name, as requested. Follow and pin states
seed from the disk-backed caches at map time; mark_artist_followed now
walks the scene model too, so toggles from any surface flip the chips
live. ArtworkTarget::LocationArtist writeback updated for the SlimItem
artwork field; media-action routed through AppShell. Cards at 200x246
(AlbumCard parity).

km
2026-07-19 23:30:54 -06:00
vicrodh 13a583dc06 fix(discover): don't wipe the persisted artist follow set on a failed fetch
search_all called set_all_artists(favs.unwrap_or_default()) even when
favorite_artist_ids() failed (network/403/timeout), replacing the cache
with an empty set. Since set_all_artists now also syncs to the per-user
disk store, a failed fetch corrupted the follow set ACROSS restarts:
the Home/ForYou Pinned carousel seeds from it at build time and showed
every artist as not-followed after a restart, while For You (built from
a live fetch) looked fine. Gate the cache replace on a successful fetch;
a failed one still maps the page with an empty set (transient).

km
2026-07-19 23:30:03 -06:00
vicrodh 0a93363ae8 feat(qbzd): service-file generator for systemd / openrc / runit
Add `qbzd service [INIT] [--user U] [--bin PATH] [--system]`, a pure-local
generator (no daemon, like `completions`) that prints a ready-to-install service
definition for the host init — auto-detecting systemd/openrc/runit when INIT is
omitted. systemd is the standard (and ships a user unit); this also covers the
inits the packaging can't: OpenRC (supervise-daemon + command_user) and runit
(chpst), plus a systemd --system unit.

The thing those non-user services get wrong by default is the AUDIO ENVIRONMENT:
dropping to a user loses that user's session env, so PipeWire (XDG_RUNTIME_DIR)
and the config/token roots (HOME) go missing. Every system-level template sets
both explicitly, resolved for the target user at generation time (getent/id;
the live XDG_RUNTIME_DIR for the current user), and notes the elogind/linger +
`audio` group requirements. Binary path defaults to the running executable.
Verified on a live OpenRC box (auto-detect + resolution correct). 287 tests.

cc
2026-07-19 23:27:40 -06:00
vicrodh f746f1a496 feat(qbzd): GET /api/artwork/current 302 + route qbzd art through it
Add a stable current-cover redirect so a daemon-only client (a plasmoid, a bar
applet, an `<img src>` in a dashboard) can point at
`http://<host>/api/artwork/current` and always get the live cover — no need to
know the Qobuz CDN URL. It reads queue state and 302s to the art URL already
stamped on the current track (unauthenticated CDN link, no Qobuz session), or
404s when nothing is playing.

`qbzd art` becomes the route's shipped caller (§3.1.4): it now issues a
redirect-none request and reads the 302 Location (bare `art` prints it; `--save`
follows to download), instead of parsing `/api/now-playing`. It no longer
depends on auth state, so its exit set drops the needs-auth 4 (now 0 · 3 · 6).
P1_ROUTES → 27 with the route-table test updated.

283 tests green. This completes the CONSOLE surface (search/play/browse/discover/
radio/fav/playlist/queue/shuffle/repeat/art/lyrics/resolve/reco + scrobbling +
MPRIS + watch/SSE + artwork).

cc
2026-07-19 23:01:24 -06:00
vicrodh 7f2898396d feat(qbzd): live event stream — GET /api/events (SSE) + qbzd watch
Add a Server-Sent Events feed so a client (a plasmoid, a bar applet, a script)
reacts to playback/queue/library changes without polling — the last CONSOLE
surface.

Server (api/sse.rs): `GET /api/events` streams one SSE frame per CoreEvent
(`event: <type>` + the tagged `data:` JSON). Because the control-plane serve
loop is single-threaded, an open stream would starve it, so the route is moved
onto its OWN thread (Request is Send) after the same origin/token gate every
route runs; the bus is drained with `blocking_recv()` there. Bulky/internal
events (search payloads, loading/download/navigation hints, diagnostics) are
filtered out; everything else — playback, queue, volume, auth, favorites,
playlists, errors, device changes — is emitted. ApiState gains the bus sender.

Client (`qbzd watch`): a bespoke reqwest client with NO read timeout (only the
connect is bounded); default output is newline-delimited JSON (one data payload
per line, `qbzd watch | jq`), `--raw` passes the SSE frames through verbatim.
P1_ROUTES → 26 with the route-table test updated (caller: `qbzd watch`).

283 tests green.

cc
2026-07-19 22:58:22 -06:00
vicrodh 102bbba9c7 feat(qbzd): make MPRIS a persisted, TUI-toggleable setting (default on)
MPRIS enablement moves from env-only to a persisted daemon pref, exposed as the
canonical `playback.mpris` setting key so it round-trips through `qbzd settings
show|set`, the settings bundle, AND a toggle in the setup-TUI:

- daemon_prefs gains `mpris_enabled: bool` (default true; unknown-field guard
  updated).
- `playback.mpris` added to the settings KEY_TABLE (ApplyClass::None), with the
  read (`settings show`) and write (`settings set`) arms; the per-canonical-key
  round-trip test now covers it.
- setup-TUI Playback screen: a new "MEDIA CONTROLS" group with a System media
  controls toggle, carrying a description line ("MPRIS: KDE/GNOME media widget +
  media keys · restart to apply") since it applies at next daemon start.
- `mpris::enabled(roots)`: QBZD_MPRIS env still wins when set (deploy override);
  otherwise the persisted toggle decides. Default stays ON.

280 tests green.

cc
2026-07-19 22:51:20 -06:00
vicrodh 88af1b248b refactor(qbzd): scrobble CLI verbs use the canonical ScrobblerSettingsStore
Migrate `qbzd scrobble login|status|enable|disable` off the interim custom
`crate::scrobbler` (scrobbler.json) onto the canonical
`qbz_app::settings::scrobblers::ScrobblerSettingsStore` (SQLite at the daemon
data root) — the SAME store the scrobble-on-play engine reads and the settings
bundle export/import already carries, so credentials are consistent across the
daemon, the desktop store format, and bundle transport. Session keys / tokens are
registered with `qbz_log::register_secret` before they touch the store.
enable/disable now refuses a not-connected provider up front (`not_connected`)
and status reads `lastfm_is_authed()/active()` from the store snapshot.

This was the orphaned tail of the store migration the scrobble-on-play engine
and TUI screen already build on; committing it closes the gap.

cc
2026-07-19 22:25:41 -06:00
vicrodh bdbdf4e0a1 feat(qbzd): publish MPRIS media controls (KDE/GNOME widget + media keys)
Add a daemon MPRIS service (CONSOLE ext) via qbz-media-controls (mpris-server on
Linux, incl. the DesktopEntry that lets GNOME/KDE resolve the app icon). A KDE
Plasma media widget, a plasmoid, or hardware media keys now drive the daemon
with NO custom client code — the marquee third-party-integration surface.

Two halves in crate::mpris:
- OUTBOUND: a CoreEvent-bus subscriber pushes now-playing metadata, play/pause,
  live position, and volume into the OS controls (seeded once from live state).
- INBOUND: the qbz-media-controls callback maps MediaEvent (Play/Pause/Toggle/
  Next/Previous/Stop/Seek/SetPosition/SetVolume) onto core transport commands;
  seek/volume honor the DSD-direct guard; Next/Previous fire the full advance
  ritual off the D-Bus thread; Raise/Quit are ignored (headless).

The inbound callback holds only a Weak<AppRuntime> (upgraded per event) and the
updater task upgrades a Weak once to seed then drops it, so the integration never
pins the runtime — the #521 audio-release-before-drop(booted) ordering is
unaffected. Spawned next to the scrobbler in run(); torn down (updater abort +
handle drop) in shutdown. On by default where a session bus exists; QBZD_MPRIS in
{0,false,off,no} disables it, and spawn returns None gracefully on a headless
box with no D-Bus. 280 tests green.

cc
2026-07-19 22:24:41 -06:00
vicrodh 903acdb044 feat(qbzd): persist + drain ListenBrainz offline queue in scrobble engine
A failed `submit_listen` now writes the listen to the SHARED
`ListenBrainzCache.listen_queue` (daemon-root `cache/listenbrainz_v2.db`, the
same schema the desktop uses) instead of being lost. The scrobble task's loop
becomes a biased `select!` between the CoreEvent bus and a 120s drain interval
(the interval also fires once at task start), so a queue built up while offline
is retried oldest-first and cleared on reconnect, stopping at the first failure
and resuming on the next tick. Mirrors `qbz::scrobble::flush_listenbrainz_queue`:
the rusqlite Connection is opened inside a `spawn_blocking` per op and never held
across an await. The daemon uses its own `qbzd` cache root — it never touches the
desktop's dirs.

cc
2026-07-19 20:17:19 -06:00
vicrodh 09a46b694d feat(qbzd): add Scrobbler setup-TUI screen (Last.fm / ListenBrainz)
Add an eighth setup-TUI section (`qbzd setup`) for scrobbler auth, mirroring
the Account browser-login methodology: the App suspends the alt-screen and runs
the CLI connect flow on the plain terminal, then reloads the screen's settings
snapshot on resume. Last.fm prints an authorize URL and waits for Enter (the CLI
verb owns that); ListenBrainz prompts for a pasted user token, then hands it to
the CLI verb. Both write the canonical ScrobblerSettingsStore, so enable/disable
stays a CLI verb and the screen only shows connection state.

Scrobbler is appended LAST in SCREENS so no existing section index or number-key
jump shifts; the number-key jump range widens 1-7 -> 1-8. The D7 six-screen cap
was already lifted for FB4's Wizard; this is the second owner-sanctioned break.

cc
2026-07-19 20:13:39 -06:00
vicrodh 2c91d0360f fix(ui): drop cache-rendering-hint from the Sidebar root
The hint belongs on static subtrees; the Sidebar root toggles by
animating its width every open/close, so its cached layer texture was
perpetually invalidated and the layer blit ended up pointing at a dead
texture — sidebar content invisible with layout/hit-testing intact
(repro: open/close sidebar; miniplayer->theme->sidebar journey).
HeaderBar/NowPlayingBar keep their hints (static-mounted, no size
animation). The Sidebar's per-frame cost is covered by the femtovg
partial-render fork (#617) instead.

km
2026-07-19 19:23:09 -06:00
vicrodh 1c5c44b904 feat(qbzd): scrobble-on-play engine — Last.fm / ListenBrainz (CONSOLE)
The daemon now scrobbles what it plays. A CoreEvent-bus subscriber
(scrobble_engine, spawned in run() next to queue-persist):
- on TrackStarted -> "now playing" to each active provider (LastFm
  update_now_playing / ListenBrainz submit_playing_now);
- scrobbles ONCE when the position crosses the threshold from
  qbz_app::scrobble_timing::scrobble_delay_secs (Last.fm's half-or-4-min rule)
  -> LastFm scrobble / ListenBrainz submit_listen, best-effort + logged;
- re-reads the canonical ScrobblerSettingsStore on each track, so `qbzd scrobble
  login/enable/disable` take effect on the next track with no reload signal;
- holds no Arc<AppRuntime>; aborted on shutdown alongside queue-persist.

Album is dropped when empty / "Unknown Album". Pure helpers (due, album_opt)
are unit-tested. The ListenBrainz offline queue (ListenBrainzCache) is a
follow-up — this slice submits best-effort.

Verified: cargo test -p qbzd --bin qbzd -> 278 passed, 0 failed.

cc
2026-07-19 19:22:50 -06:00
vicrodh 2d747368bf fix(renderer): invalidate layer/image caches on canvas reset
After a Wayland surface re-creation (tray hide/show, miniplayer cycle),
reset_canvas swapped in a fresh canvas but kept graphics_cache and
texture_cache entries whose textures belong to the OLD canvas's image
store. A cache-rendering-hint layer (Sidebar/HeaderBar/NowPlayingBar
since e3257631) then blits a dangling image id: the subtree goes
invisible while layout and hit-testing keep working — exactly the
reported 'sidebar content does not render' after a
miniplayer->theme->sidebar journey. set_window_adapter and
clear_graphics_context already clear both caches; reset_canvas now does
too (layers re-render, images re-upload once per re-creation).

km
2026-07-19 19:11:51 -06:00
vicrodh 17de908225 feat(qbzd): scrobbling auth foundation — Last.fm / ListenBrainz (CONSOLE)
The connect flows, same methodology as `qbzd login`:
- `scrobble login lastfm`: LastFmClient::get_token prints an authorize URL, the
  user approves on last.fm, get_session exchanges it for a session key.
- `scrobble login listenbrainz --token <T>`: ListenBrainzClient::set_token
  validates a pasted user token (like `login --token`).
- `scrobble status` · `scrobble enable|disable <provider>`.

Credentials land in the CANONICAL scrobbler store
(qbz_app::settings::scrobblers::ScrobblerSettingsStore — SQLite at the daemon
data root), the SAME store the desktop uses and the one the settings bundle
export/import already carries, so scrobbler creds travel with `settings export`.
Secrets registered with qbz_log; a running daemon is nudged to reload.

- Cargo.toml: qbz-integrations path dep (already in-tree via qbz-core).
- cli/scrobble.rs: the verbs. main.rs: Scrobble subcommand group.

Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.

NOTE: lands CREDENTIALS + auth only. Scrobble-on-play (a daemon task on the
CoreEvent bus: update_now_playing on TrackStarted, scrobble past
scrobble_delay_secs, ListenBrainz submit + offline queue) is the next slice —
it wires the playback driver. The setup-TUI Scrobbler screen is also pending.

cc
2026-07-19 19:11:43 -06:00
vicrodh 620af9d75d feat(qbzd): add lyrics verb + GET /api/lyrics (CONSOLE)
- qbz-core: thin QbzCore::get_lyrics wrapper over QobuzClient::get_lyrics
  (additive; lets qbzd avoid the client() escape hatch).
- api/lyrics.rs: GET /api/lyrics?id=<TRACK_ID|current>; flattens the tagged
  QobuzLyricsDocument union to {track_id, synced, lines:[{time_ms?, text}]}.
  No-lyrics = 200 with empty lines, never 404. Auth-gated.
- cli/lyrics.rs: `qbzd lyrics [TRACK_ID] [--synced] [--json]`; --synced prefixes
  each line with its [mm:ss.cc] LRC timestamp.
- api/mod.rs: P1_ROUTES 24->25; route + test. main.rs: Lyrics subcommand.

Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.

cc
2026-07-19 19:04:14 -06:00
vicrodh cd4a0494a2 fix(discover): correct artist follow state in the Home/ForYou Pinned carousel
The pinned artist card's follow chip was seeded at build time from
FAV_ARTISTS — an in-memory-only set that was always empty at that moment
(shell entry warmed tracks/albums/awards, never artists; init_for_user
disk-seeded everything but artists) — and nothing re-seeded the pinned
model when the follow set later arrived. Every pinned artist showed
not-followed, and the toggle read the same empty set, so a second click
could genuinely unfollow an artist.

Wire artists into the existing disk lifecycle (the qbz-app store already
had the table + CRUD): init_for_user seeds from disk, set_all_artists
syncs to disk, set_artist mirrors add/remove. Add the shell-entry warm
favorite_artist_ids (skipped offline) and re-seed already-built pinned
artist rows when it lands (model walk, no rebuild/flicker).

km
2026-07-19 17:34:48 -06:00
vicrodh 92c59db0be merge: femtovg partial-render fork + cache-hint + trackrow fix (#617)
Lands the #617 line on pre-release:
- femtovg partial rendering, stages 1-4 (efc2db6d, ed897d8c) — persistent
  scene texture + dirty-region repaint, env-gated OFF by default
  (SLINT_FEMTOVG_PARTIAL_RENDERING; frozen-blit/visualize/log modes).
- Adversarial-review blocker fixes (1adf4e6f): femtovg set_size target
  desync root-fix, dirty-region collapse to bounding rect, visualize on
  the swapchain.
- cache-rendering-hint on static shell chrome (e3257631).
- Playing-row highlight/animation for offline-cache tracks (c6da4844).

Owner-smoked: visual matrix clean with visualize, idle CPU acceptable on
the owner's box; default behavior unchanged with the gate off.

km
2026-07-19 17:21:31 -06:00
vicrodh 7fc292de27 feat(qbzd): add playlist recommendations — POST /api/reco/playlist (CONSOLE)
Enables QBZ's Suggested-Songs engine headlessly and Slint-free. The
artist-vector store is a CACHE the engine builds on demand from MusicBrainz +
Qobuz — no listening history needed, so the personalized (history-based)
recommendations stay out of scope (they would need play-recording plumbing).

- Cargo.toml: qbz-reco path dep (already in-tree via qbz-core; direct dep only
  exposes the ArtistVectorStore name).
- daemon.rs: open ArtistVectorStore::open_at(daemon-data-root) after core init
  and set_artist_vectors — mirrors qbz/src/auth.rs, session-independent,
  best-effort (suggestions still work un-cached if the open fails).
- api/reco.rs: POST /api/reco/playlist {playlist_id} (server seeds the engine
  with the playlist's distinct artists + excludes its tracks) | {artists,
  exclude} | {limit}. generate_playlist_suggestions; auth-gated. Documents the
  single-thread cost (MusicBrainz-resolve is rate-limited).
- cli/reco.rs: `qbzd reco playlist <ID> [--limit --ids --json]`; --ids emits
  suggested track_ids (pipe into `queue add -` / `playlist add`).
- api/mod.rs: P1_ROUTES 23->24; route + test. main.rs: Reco subcommand group.

Verified: cargo test -p qbzd --bin qbzd -> 274 passed, 0 failed.

cc
2026-07-19 16:56:49 -06:00
vicrodh 1a50f4c1b1 feat(qbzd): add discover — Discover rails, no recommendations (CONSOLE)
GET /api/discover?section= exposes the editorial Discover rails so a third
party can replicate the QBZ Discover page WITHOUT the personalized
recommendation engine (per-user reco/play-count stores the daemon never opens):
  index · most-streamed (Qobuz "most played") · new-releases · press-awards ·
  qobuzissims · album-of-the-week · ideal-discography · playlists[?tag] · tags ·
  release-watch[?release_type=artists|labels|awards] · featured[?type]. Optional
  ?genre=ID,ID scopes a section by genre.
- api/discover.rs: one route over get_discover_index / get_discover_albums /
  get_discover_playlists / get_playlist_tags / get_release_watch /
  get_featured_albums; typed serde shapes verbatim; auth-gated.
- cli/discover.rs: `qbzd discover [SECTION] [--genre --tag --release-type
  --type --limit --ids --json]`.
- api/mod.rs: P1_ROUTES 22->23; route + test. main.rs: Discover subcommand.

Verified: cargo test -p qbzd --bin qbzd -> 274 passed, 0 failed (with the
playlist-CRUD commit below it).

cc
2026-07-19 16:56:49 -06:00
vicrodh df4f29a50e feat(qbzd): add playlist CRUD — create/edit/rm/add/remove (CONSOLE P2)
- api/playlist.rs: POST /api/playlist/create, /update, /delete, /tracks/add,
  /tracks/remove. tracks/remove takes PLAIN track ids and the daemon resolves
  them to per-playlist playlist_track_id ROW ids (the row-id trap) via
  get_playlist before remove_tracks_from_playlist — clients never touch row ids.
- cli/playlist.rs: `playlist create/edit/rm/add/remove` (rm requires --yes; add
  reads `-` from stdin; edit --public/--private mutually exclusive).
- api/mod.rs: P1_ROUTES 17->22; routes + test. main.rs: PlaylistCmd variants.

Pending: `cargo check -p qbzd` — box at 7GB with a qbz_ui build running;
compile deferred to avoid an OOM freeze (verified next in a memory window).

cc
2026-07-19 16:32:29 -06:00
vicrodh 4437133c49 feat(qbzd): add art verb — current-track cover art (CONSOLE)
`qbzd art` prints the now-playing track's artwork_url (already carried on
GET /api/now-playing — an unauthenticated Qobuz CDN URL); `qbzd art --save
PATH` downloads it (notification icons, terminal viewers). A pure-ish client
verb: no new route (reads the shipped now-playing). A daemon-served
GET /api/artwork/current (302) is deferred until a daemon-only client needs it.

Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.

cc
2026-07-19 16:28:03 -06:00
vicrodh 9ab41fbe8c feat(qbzd): add fav (list/add/remove) + playlist (list/show) (CONSOLE P1)
- api/fav.rs: GET /api/favorites (paged read), POST /api/favorites/add|remove.
  The contract is SINGULAR (track|album|artist) everywhere; the server
  pluralizes for get_favorites, hiding the plural-read/singular-write trap.
  `add {current:true}` favorites the now-playing track.
- api/playlist.rs: GET /api/playlists (collection), GET /api/playlist?id=
  (one playlist, full track list). Reads only in this slice; CRUD deferred.
- cli/fav.rs + cli/playlist.rs reuse browse's generic render/collect_ids
  (made pub(crate)); playlist list has its own id/name/count renderer.
- api/mod.rs: P1_ROUTES 12->17 (+favorites x3, playlists, playlist); routes +
  test. main.rs: Fav/Playlist subcommand groups.

Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.

cc
2026-07-19 16:28:03 -06:00
vicrodh c6da484481 fix(ui): highlight/animate the playing row for offline-cache tracks
Local track rows bind the local-library DB row id, but local_queue_track
publishes the Qobuz catalog id for offline copies (so the shared
resolver finds them), so NowPlayingState.track-id never matched the
row: no equalizer bars, no active highlight, and double-click restarted
the track instead of toggling (album view, Tracks tab, folder detail,
kiosk).

Carry the library row id in source_item_id_hint for qobuz_download and
re-publish it as NowPlayingState.local-track-id; TrackPlayCell,
TrackRow and KioskAlbum now match on either id — one alias, no
per-view hacks.

km
2026-07-19 16:08:02 -06:00
vicrodh 1adf4e6f9d fix(renderer): partial-render blockers from adversarial review (#617)
B1: femtovg Canvas::set_size queues SetRenderTarget(Screen) without
updating current_render_target — the scene set_render_target() was
silently undone and the Copy blit overwrote the frame with the empty
persistent texture (black window on the first env-gated frame).
Root-fixed in vendored femtovg (keep the field in sync with the queued
command — upstream-able) + re-assert the scene target after the
notifier block's set_size.

B2: BoxShadow/Transform/Opacity/Layer/Clip bypass the proxy's
filter_item and re-render every frame (core item_rendering.rs:209-217)
— with a multi-rect dirty region they re-blended over uncleared pixels
in the gaps between rects (progressive smear). Collapse the dirty
region to its single bounding rect so filter, scissor and clears all
agree on one rect.

M1: visualize outline now strokes on the swapchain after the blit
(drawn into the persistent texture it accumulated).

Also: case-insensitive env parsing; stale-comment corrections.

km
2026-07-19 16:08:02 -06:00
vicrodh ecc47b6d23 feat(qbzd): add shuffle/repeat + queue move/jump/stop-after (CONSOLE)
Completes transport + queue parity from the CLI.

- api/playback.rs: POST /api/playback/shuffle {on|off|toggle} and
  /api/playback/repeat {off|all|one} (set_shuffle / toggle_shuffle /
  set_repeat_mode; no auth — queue-mode toggles, state surfaces in now/status).
- api/queue.rs: POST /api/queue/move {from,to} (move_track); /api/queue/jump
  {index} (play_index + the shipped start ritual, auth-gated — a click-to-play
  row, never a bare cursor move); /api/queue/stop-after {track_id|current|off}
  (set_stop_after / clear_stop_after).
- cli/mode.rs: `shuffle`, `repeat`. cli/queue.rs: `queue move/jump/stop-after`
  (1-based positions translated to 0-based at the boundary, like `queue remove`).
- api/mod.rs: P1_ROUTES 7->12; routes + test. main.rs: subcommands.

Verified: cargo test -p qbzd --bin qbzd -> 264 passed, 0 failed.

cc
2026-07-19 15:36:28 -06:00
vicrodh f239596ed5 merge: cache-rendering-hint into the femtovg partial-render line (#617)
Combines the two complementary levers in one test build: the cache-hint
lowers per-repaint traversal cost (HeaderBar/NowPlayingBar/Sidebar
subtrees cached to textures); partial rendering shrinks the repaint
itself to the dirty region. Attribution stays intact via the env gate —
SLINT_FEMTOVG_PARTIAL_RENDERING unset = cache-hint-only behavior.

km
2026-07-19 15:18:59 -06:00
vicrodh ed897d8c14 feat(renderer): femtovg partial rendering, stages 2-4 (#617)
Stage 2 (frozen-blit): render the scene into a persistent femtovg image
target (image passes use LoadOp::Load — the buffer-age-infinite backing
store the wgpu swapchain cannot provide) and blit it whole to the
swapchain with CompositeOperation::Copy (no prior clear; exact alpha for
the transparent miniplayer). GLItemRenderer::new now takes the initial
render target so layer passes restore to the scene texture, not Screen.
Env-gated via SLINT_FEMTOVG_PARTIAL_RENDERING (default off = current
behavior; "frozen-blit" = this stage only).

Stage 3 (dirty regions): wrap GLItemRenderer in the core PartialRenderer
proxy and repaint only the dirty bounding rect of the persistent texture
(ReusedBuffer semantics). Canvas-level scissor on the main pass only,
set after the canvas reset — never a wgpu-pass-level scissor (it would
clip persistent layer/shadow clears and smear stale texels). Per-dirty-
rect clear_rect as the scoped clear (it ignores canvas scissor by
design). "visualize"/"log" modes, mirroring SLINT_SKIA_PARTIAL_RENDERING.

Stage 4 (lifecycle): mark_dirty_region override (popup-close repaints;
core default is a no-op), free_graphics_resources forwarding,
partial-cache clear on set_window_adapter/reset_canvas,
force_screen_refresh on texture recreate (resize/DPR/UI-scale), on
transformed frames that bypass the persistent texture, and on the
metrics collector's FullSpeed mode.

cargo check clean. Full behavior unchanged when the env gate is off.

km
2026-07-19 15:18:48 -06:00
vicrodh e32576316e perf(ui): cache-rendering-hint on static shell chrome (#617)
cache-rendering-hint: true on HeaderBar, NowPlayingBar and Sidebar roots so
femtovg caches those subtrees to a persistent texture instead of
re-tessellating them on every repaint. Sidebar root is safe: the Large-NPB
spectrum dock is an AppShell overlay, not a Sidebar child. SMOKE-WATCH:
playlists-list scroll (Flickable-inside-cache); narrow to nav-only if it
regresses.

Authored by Claude (cc) in the repaint-cache-hint worktree; committed
as-is by km so both #617 lines share one canonical commit.

km
2026-07-19 15:18:30 -06:00
vicrodh d819d94ab5 feat(qbzd): add radio (seed-and-go) + resolve (CONSOLE)
- api/radio.rs: POST /api/radio {artist_id|track_id|album_id} — qobuz mode
  (get_radio_artist/track/album, zero local state). Reuses the new shared
  play::start_resolved for the queue-replace + cold-start ritual (protected
  audio untouched). Smart local-pool mode deferred (needs RadioDb::open_at
  plumbing through QbzCore — a shared-core change).
- api/play.rs: extract pub(crate) start_resolved (shared by play + radio).
- cli/radio.rs: `qbzd radio artist:ID | track:ID | album:ID`.
- cli/resolve.rs: `qbzd resolve <URL>` -> kind:ID token; pure client, no
  daemon and no network (qbz_qobuz::link_resolver).
- api/mod.rs: P1_ROUTES 6->7 (+POST /api/radio); route + test. main.rs:
  Radio + Resolve subcommands.

Verified: cargo test -p qbzd --bin qbzd -> 264 passed, 0 failed.

cc
2026-07-19 15:06:38 -06:00
vicrodh ba2f626208 feat(qbzd): add browse reads — album/artist/similar/suggest (CONSOLE P2)
qbzd can now BROWSE the catalog from the CLI: open an album, an artist page /
top-tracks / releases grid, find similar artists or albums, and pull For-You
suggestions — reads that feed the composition pipeline (`--ids | queue add -`).

- api/browse.rs: GET /api/album (get_album + optional get_album_suggest);
  GET /api/artist?view=page|top|albums (get_artist_page / get_artist_tracks /
  get_releases_grid); GET /api/similar?artist=|album= (get_similar_artists /
  get_album_suggest); GET /api/suggest?seed= (get_dynamic_suggest — seeds from
  the current queue when omitted). Typed serde shapes verbatim; auth-gated;
  blacklist fail-open.
- cli/browse.rs: `album`, `artist`, `similar`, `suggest`; a generic items/tracks
  walk drives the human list and --ids; `--seed -` reads ids from stdin.
- api/mod.rs: P1_ROUTES 2->6 (+album/artist/similar/suggest); routes + test.
  main.rs: four subcommands.

Verified: cargo test -p qbzd --bin qbzd -> 262 passed, 0 failed.

cc
2026-07-19 15:02:19 -06:00
vicrodh f2f65b0fbf feat(qbzd): add play <content> + POST /api/play (CONSOLE P1 row 23)
qbzd can now START playback of a track / album / playlist / artist / Qobuz
URL from the CLI — the receiver becomes a source. Server-side materialization
and the shipped cold-start ritual; the protected audio path is untouched.

- api/play.rs: resolve selector (a URL via link_resolver wins over id fields),
  fetch tracks (get_album / get_playlist / get_artist_tracks / get_tracks_batch),
  map via the now-shared queue::track_to_queue_track, stamp album/artist/
  playlist provenance, then set_queue -> play_track_resolved -> save_session_now
  (never a bare cursor move). Auth-gated; 404 on unknown id / empty resolution;
  503 on start failure.
- cli/play.rs: `qbzd play [album:ID|track:ID|artist:ID|playlist:ID|URL|N]`;
  bare `play` delegates to transport::play (one resume implementation). Local
  exit 2 on a malformed selector.
- api/queue.rs: track_to_queue_track -> pub(crate); api/playback.rs:
  resolve_quality -> pub(crate) (both now shared with play).
- api/mod.rs: P1_ROUTES += POST /api/play; route + test. main.rs: Play gains
  an optional content arg (bare = resume, unchanged).

Verified: cargo test -p qbzd --bin qbzd -> 256 passed, 0 failed.

cc
2026-07-19 14:54:56 -06:00
vicrodh 23d6cc71ec feat(qbzd): add search verb + GET /api/search (CONSOLE P1 row 19)
First slice of Proposal A (CONSOLE): qbzd can originate discovery — search
Qobuz by name from the CLI, not only receive via QConnect or take track
ids by hand.

- api/search.rs: GET /api/search?q=&type=&limit=&offset=. Typed searches
  return SearchResultsPage<T> verbatim under a per-category key; type=all
  fans out to the four typed searches. Auth-gated (needs_auth); blacklist
  fail-open by design (documented GUI-parity delta).
- api/mod.rs: new P1_ROUTES table + p1_route_table test — the shipped-caller
  HARD RULE (§3.1.4) applied to the content-verb door; route registered.
- cli/search.rs: `qbzd search <q> [--type all|albums|tracks|artists|
  playlists] [--limit N] [--offset N] [--ids] [--json]`. --ids emits ids
  one-per-line (pipes into `queue add -`); frozen exit-code table.

Verified: `cargo check -p qbzd` clean (no warnings); `cargo test -p qbzd
--bin qbzd` → 248 passed, 0 failed (10 new + 238 existing). New files are
standard-rustfmt; existing files carry only the minimal logical edits.

cc
2026-07-19 14:20:25 -06:00
vicrodh efc2db6dbb feat(renderer): femtovg partial-render scaffold (stage 1)
Inert scaffold for #617 partial rendering in the vendored femtovg-wgpu
renderer. Mirrors the Skia renderer: add an ItemTransform to the render
State (logical space), accumulate it in translate/rotate/scale
(pre_translate/pre_rotate/pre_scale), implement current_transform() (the
core ItemRenderer trait default is todo!()), and add
impl ItemRendererFeatures { SUPPORTS_TRANSFORMATIONS = true }.

Nothing consumes current_transform() yet — the core PartialRenderer proxy
is wired in a later stage — so this is behavior-neutral. Not yet compiled;
validates at the stage-2 build.

cc
2026-07-19 13:39:35 -06:00
vicrodh 075582920a fix(qobuz): 403 circuit breaker + prefetch backoff; header tooltips
Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.

- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
  short-circuit without touching the network, with an exponential cooldown
  and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
  loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
  surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
  is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
  stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
  translated across all 8 locales.

cc
2026-07-19 10:01:37 -06:00
vicrodh 0c3b38f8b8 fix(library): read album/album-artist/date across all tags at scan (#447, #507)
The scanner read metadata from a SINGLE lofty tag (primary_tag().or(first_tag()))
with no per-key fallback to the file's other tags. Old, repeatedly-retagged
collections routinely carry the album / Album Artist / date only in a
secondary tag (ID3v1/APE/Vorbis) while the primary tag lacks them:

- #447: the missed album tag let the folder-derived backfill land in the
  album column and masquerade as metadata — folder name shown as the album
  title, folder-level grouping, and MIN(year) across the folder's mixed
  albums showing the oldest year. This is why the shipped #411 fix (prefer
  the album column) could not help: the column held the folder name.
- #507: the missed Album Artist left album_artist NULL, degenerating every
  COALESCE(album_artist, artist) to the per-track artist — albums with
  mixed track artists sorted into Various Artists.

extract() now falls back per key across tagged_file.tags() (primary first,
deterministic conflict policy) for title/artist/album/album_artist/genre/
catalog/track/disc/date. Folder backfills stay as-is for genuinely untagged
files. A debug log marks files where no tag yields an album (diagnostic).

Tests: 6 in-memory cross-tag extraction tests (metadata.rs) + 3 query-level
regressions (database.rs): album artist respected over mixed track artists,
album tag preferred over folder snapshot, per-album MIN(year) in a shared
folder. 73/73 pass. A full library rescan picks the fix up (no migration;
scans already re-extract every row).

km
2026-07-19 03:06:16 -06:00
vicrodh e81ed911f8 feat(library): ownership-aware playlist cards, Library All genre/sort, live fav/follow sweep
PlaylistCard now reflects ownership: owned playlists show favorite only;
foreign Qobuz playlists show follow/unfollow on the overlay plus follow +
copy in the context menu. Added is-owned/is-following/is-copied to
SearchPlaylistItem, seeded from the favorites owned/followed split and
owner.id == current_user_id elsewhere. Wired the actions id-scoped across
both routing families via shared helpers (copy/follow/favorite), so a card
toggles ITS playlist instead of the open detail.

Library "All" toolbar: genre filter (own "library-all" context over the
mixed feed; albums + tracks carry a genre, non-genre kinds hide when a genre
is selected) and PlaylistView-style sort menu (re-select the active field to
flip asc/desc). Tooltips on the four source toggles.

Live favorite/follow sweep across every card surface that was missed:
album favorite now reaches the Pinned carousel, the Recently Played "view
all" page and the artist "In library" grid; artist follow reaches Home Top
Artists, label-page artists and Pinned; new set_playlist_row_following flips
the follow chip on every playlist card (incl. Pinned) after subscribe.

cc
2026-07-19 02:44:43 -06:00
vicrodh 89d08c27b8 feat(playback): artist-card play — Popular tracks with studio-discography fallback
The artist grid/overlay play button emitted media-action("artist", id,
"play") but no Rust arm handled it, so it was a no-op. Add play_artist:
play the artist's Popular (top) tracks, and when the artist has none, fall
back to playing their studio discography — the "album" + EP/single buckets
in the page's section order, deduped by album id, excluding compilations,
live, and "other". Fetches the artist page once and decides from it; a
single unavailable album is skipped without a toast or aborting the rest.

cc
2026-07-18 23:38:20 -06:00
vicrodh e9d235f33e fix(renderer): reuse one wgpu instance/device across Wayland surface recreations
On Wayland every tray hide->show destroys the winit window and re-runs
the femtovg-wgpu set_surface, which with WGPUConfiguration::Automatic
created a brand-new VkInstance + VkDevice on each restore. The NVIDIA
driver segfaults on that churn: a null call inside libnvidia-glcore,
reached via libvulkan from set_surface before the first render
(reproduced under gdb, close-to-tray -> restore with playback starting
mid-restore; it crashed even while paused, ruling out the shader
drain path).

Create the wgpu instance/adapter/device/queue ONCE at startup and hand
it to Slint as WGPUConfiguration::Manual (Linux+Wayland only), so a
restore only re-creates the surface/swapchain on the SAME device. The
Automatic configuration stays as the fallback on other platforms and
on creation failure.

km
2026-07-18 20:43:06 -06:00
vicrodh 5ebe418805 fix(kiosk): dismiss on-screen keyboard on accept and tap-outside
The OSK opened on kiosk search/inputs but never closed: focus stayed on
the search field. Clear focus in the field's `accepted` handler and add a
full-bleed background TouchArea that clears focus on any tap outside the
input, so the on-screen keyboard dismisses.

cc
2026-07-18 19:50:27 -06:00
vicrodh a08d91eeef fix(kiosk): touch-scroll Settings via raw Flickable + ListScrollbar
The std ScrollView does not intercept a touch-drag that starts over the
dense SettingRow controls, so Settings did not finger-scroll on the Pi
kiosk. Replace it with a raw Flickable (intercepts the drag after a few
px) plus a ListScrollbar overlay for the desktop bar; scroll-position
restore now keys on the Flickable's own height.

cc
2026-07-18 19:50:27 -06:00
vicrodh 3b7b5a43a4 fix(library+tray): tray-restore crash via hide-clear; richer/cleaner All list rows
- Tray restore no longer crashes (RefCell panic on close / segfault on restore):
  clear ImmersiveState.shader-texture in tray::hide_window BEFORE the surface is
  destroyed (UI thread, no rendering-notifier RefCell borrow), instead of a
  deferred clear in the RenderingTeardown notifier that ran too late when the
  drain was parked (paused). The 30 fps drain repopulates it after restore.
- All list view: right-click opens the context menu AT THE CURSOR (window
  pointer minus the row's absolute position); the whole row is right-click
  sensitive (per-cell handlers), while the ⋯ button keeps anchoring the menu to
  itself. Hide the source column + grid source badges when show-local is off
  (all-Qobuz feed → no per-row Qobuz mark).
- Remove the now-unused FavArtistCard / FavLabelCard components (the Favorites
  Artists/Labels grids use ArtistGridCard / LabelCard).

cc
2026-07-18 17:41:39 -06:00
vicrodh 80986a02b4 fix(kiosk): immersive-exit returns to the pre-immersive shell; native-touch diagnostics
- Immersive exit now returns to the shell (kiosk or desktop) the user came
  from, and only drops fullscreen when it was NOT fullscreen before opening —
  so the kiosk appliance (which boots fullscreen outside immersive) is no longer
  dumped back to the windowed desktop shell on exit. Captured on the open edge
  in the fullscreen guard.
- Add a WindowEvent::Touch arm: a one-shot log confirming whether the compositor
  delivers native touch (RaspiOS/labwc mouse-emulation withholds wl_touch, which
  is why swipe/drag never works — a compositor-side fix, not an app bug), plus
  touch-point mirroring to ShellState so hover-revealed chrome reacts under
  native touch.

cc
2026-07-18 15:38:14 -06:00
vicrodh e1bdec5094 fix(library): proper All list rows, NVIDIA seam, close-to-tray crash, Go-to-album
- Rework the "All" list view to the aligned column layout of the mixtape detail
  list: artwork + hover play, title + clickable-artist subtitle, a TYPE column,
  a dedicated SOURCE column (SourceGlyph), the row QualityBadgeFull in a Quality
  column (was the uneven grid badge), favorite/follow indicator, and the per-kind
  context menu — no longer bare and out of tune.
- Fix the NVIDIA "wallpaper join" background seam: replace the float hash2 in
  ambient.wgsl + plasma.wgsl with a bit-exact integer (u32) lattice hash so the
  same cell corner hashes identically from adjacent cells on NVIDIA.
- Fix the close-to-tray "RefCell already mutably borrowed" panic introduced by
  the teardown texture-clear: defer the set via upgrade_in_event_loop so it runs
  after the window adapter releases its borrow.
- Add "Go to album" to the TrackCard context menu and the list-row menu (adds
  album-id to the feed model).

cc
2026-07-18 15:11:15 -06:00
vicrodh f484cc481f fix(library): tray-restore crash, local art/badges, LabelCard, live pins, rich list rows
- Fix the tray-restore wgpu crash (pre-existing, amplified by the app-wide
  background): clear ImmersiveState.shader-texture on RenderingTeardown so the
  first restored frame never hands a cross-instance stale texture to the new
  femtovg canvas.
- Local/Plex feed covers now decode: route the "All" artwork jobs through
  spawn_search_loads (http / local fs / Plex thumb) at all four dispatch sites;
  show the source badge on the feed's album/track cards (AlbumCard maps plain
  "qobuz" to the Qobuz logo).
- New card-sized LabelCard (200x246), used by the feed + the Favorites Labels
  tab (replaces the round FavLabelCard).
- Artist pin badge updates live in the Favorites Artists grid: add is-pinned to
  FavoriteArtistItem, stamp it, bind it in the ArtistGridCard, and flip the
  favorites artist models in set_artist_row_pinned.
- Rich "All" list view: type-aware subtitle, quality badge (album/track),
  source icon (local/Plex), favorite/follow indicator, and a per-kind context
  menu (⋯ button + right-click).

cc
2026-07-18 11:27:28 -06:00
vicrodh 8e0802768f fix(library): smoke-test fixes for All feed + local favorites + artist toggle
- All feed clicks were dead: the Albums/Artists/Playlists/Labels branch didn't
  exclude the "all" tab, so an empty Flickable overlaid the grid and swallowed
  every press. Exclude "all" from that branch.
- Local favorites never bound on the online session paths: init_for_user was
  only called in the offline shell. Bind it in login + restore, teardown in
  logout — so the heart flips and local items reach the "All" feed.
- Add the show-local source switch + the grid/list ViewToggle to the All
  toolbar; implement the compact list view.
- ArtistPage "In library" rows rendered blank: add ArtistLibraryTrack/Album
  artwork targets + dispatch cover jobs in navigate_artist.
- Move the catalog/library toggle into the artist header, floated right.
- Glass under the dynamic background: ToggleButton / ExpandableSearch /
  SegmentedTabBar go translucent when app-background-active.
- Library > Artists grid now uses the ArtistGridCard (flat + A-Z grouped).

cc
2026-07-18 02:59:12 -06:00
vicrodh 83df91439c feat(library): All mixed feed, TrackCard, artist playlists + library toggle, local favorites
- Library "All": mixed feed (tracks/albums/artists/playlists/labels) sorted by
  date added, reusing the existing cards; new TrackCard; toolbar (search + source
  switches + sort); "All" added to the Library nav surfaces.
- ArtistPage: restored the curated Playlists carousel (parsed but dropped in the
  Slint port); added the catalog/library toggle backed by a per-session
  per-artist index (favorites).
- Local-library favorites layer: new SQLite store (mirrors the pinned service),
  album-level; excludes the Qobuz offline cache at write time. Feeds "All" behind
  the show-local switch.
- i18n: 3 new strings across all 8 locales.

cc
2026-07-18 01:33:23 -06:00
vicrodh e84d1035f8 fix(cast): seek by fraction of the cast track duration
The seekbar derived the absolute position from the local core's playback
duration, which is 0/stale while casting (the local backend is stopped),
so fraction * duration collapsed to ~0 and every drag restarted the
track. Resolve the real duration from the cast track's catalog metadata
instead (new seek_fraction_if_cast). Fixes seek on both Chromecast and
DLNA.

cc
2026-07-17 21:22:36 -06:00