Consumer maps PlaybackError -> handleSkipForward for streamable/network/
not-found failures on the current queue item; toast otherwise. Predicate
shouldAutoSkipOnPlaybackError unit-tested. i18n keys added in 5 locales.
cc
Decodes PlaybackErrorMessage off the InboundPayloadBytes batch and emits a
PlaybackError app event; the Tauri event sink forwards it to qconnect:event
via its existing catch-all emit.
cc
Models the renderer per-track failure (TRACK_NOT_STREAMABLE/NETWORK_ERROR/...).
Envelope-vs-batch placement verified against transport bytes: the decoder
receives the batch QConnectMessages (CloudPayload.payload tag 7), and the
official Message record carries playback_error at tag 3, so the field is
attached to QConnectMessage inside messages[], NOT to the batch wrapper.
cc
Comparison seam joins the existing resync path. Local hash algorithm is a
documented BLOCKING open question; stub returns None so detection stays inert
(no spurious resyncs) until the Qobuz algorithm is verified.
cc
Decoder now reads field #100 (previously decoded-then-dropped) on QueueState
and carries it through to QConnectQueueState.last_server_queue_hash via the
QueueStateReplaced reducer path (full-state replace already clones it).
cc
105 carries only bare queue_item_ids (no track_id/context), unreconstructable
by the reducer; resync pulls the authoritative QueueState so autoplay-grown
tracks become visible. Matches the ShuffleModeSet/Reordered/Removed pattern.
cc
A playing active-peer renderer that goes silent left the controller UI
stuck on stale 'playing' (no equivalent of the webplayer's 12s
rendererLost timer). Add a generation-guarded 12s watchdog keyed on the
active peer renderer in the RENDERER_STATE_UPDATED handler: reset on every
update, arm only while playing_state==PLAYING, disarm on
pause/stop/active-change/disconnect. On expiry freeze the projection to
UNKNOWN and emit qconnect:renderer_unreachable; Svelte shows an error
toast (rendererUnreachable, all 5 locales). Reuses the
freeze-projection-to-unknown helper from the ACTIVE_DISCONNECTED path.
cc
The wire 'status' field on RENDERER_STATE_UPDATED was decoded into the
adapter payload (decoder.rs:801) then dropped — a graceful peer
disconnect was invisible and the controller kept showing stale 'playing'.
Add a RendererStatus enum (UNKNOWN/UNRECOGNIZED -> INACTIVE collapse) and,
on ACTIVE_DISCONNECTED for the active remote renderer (id != -1), freeze
the projection to UNKNOWN and emit qconnect:renderer_disconnected. Svelte
surfaces it as an error toast (rendererDisconnected, all 5 locales). Pure
consumer-side: no protocol change.
cc
resolve_qconnect_device_uuid() generated a fresh Uuid::new_v4() per
process via a OnceLock, so every launch registered a new renderer
identity cloud-side (zombie renderers) and broke reconnect continuity.
Persist it beside device_name in qconnect_settings.db: generate once on
first run, reuse thereafter, keeping the QBZ_QCONNECT_DEVICE_UUID env
override for tests. Mirrors load_persisted_device_name/persist_device_name.
cc
v2_qconnect_send_command_with_admission trusted a single frontend origin and
never inspected track_ids. Add track_origins[] to the request and
validate_track_origins_for_admission: re-evaluate every track via the core
gate, block if any is non-Qobuz or the list is empty. The per-track check is
only enforced when the caller actually ships per-track origins (bulk queue
loads); single-track play-next/play-later carry no track_origins and stay
gated by the command-level origin alone, so an empty list never over-blocks
them. The existing command-level check and admission_blocked emit are kept.
Frontend loadQconnectQueue now computes per-track origins from its tracks arg
via resolveQconnectTrackOrigin and ships them to the sender.
Defers the optional DB cross-check (LibraryState / has_local_track_by_qobuz_id
defense-in-depth) as a follow-up; this commit is the per-track-origins
validator + wiring + test only.
cc
The Jack backend variant was added to qbz-audio but the backend-name
match in commands_v2/settings.rs was not updated, leaving the lib crate
non-exhaustive (E0004) and uncompilable on this branch. Add the missing
arm and sync Cargo.lock with the jack/jack-sys deps. Pre-existing breakage
unrelated to qconnect; fixed here only to unblock the build and tests.
cc
The TransportConnected handler pushed the live queue via loadQconnectQueue
with no source check, shipping local/Plex row ids as Qobuz ids. The frontend
display queue (getQueueState) carries no `source`/`is_local`, so wire the push
to getBackendQueueState() whose tracks DO carry them (v2_get_queue_state maps
source per track in commands_v2/queue.rs). Route the ordered snapshot
([history, current, upcoming]) through assessQconnectQueueSync: refuse the
whole push on a mixed queue (no silent subset) and toast the user via the new
qconnect.mixedQueueNotCast key (all five locales). Lock the refuse-not-subset
contract with a unit test; page wiring covered by svelte-check + manual verify.
cc
The bulk-load hardcoded origin=qobuz_online and never looked at IDs, so the
Rust gate always accepted local/Plex ids shipped as Qobuz. Export the shared
isQconnectSyncEligibleTrack predicate, add partitionQconnectLoadableTracks,
and add an optional tracks[] arg to loadQconnectQueue that refuses the load
when any track is non-Qobuz.
cc
Cross-check test now pins admission to is_castable_to_qconnect (incl. OfflineCache)
instead of the streamable predicate. Dedupe the queuePlaybackService test builder.
Document is_castable_to_qconnect/from_source_str_strict/TrackOriginTag as the shared
QConnect-admission gate consumed by the Slint port.
cc
Three sites disagreed: model blocked OfflineCache, live gate allowed it, TS
sync filter blocked qobuz_download. Decision: ALLOW — an offline copy's queue
id is the real Qobuz track id. Flip the TS sync filter and fix the stale model
doc comment to match the live gate.
Also add Rust lock-in tests (offline_cache_is_castable / offline_cache_is_admitted)
and restore the missing $app/environment vitest mock the config already aliases,
so queuePlaybackService.test.ts can load.
cc
Admission must do the opposite of from_source_str: add from_source_str_strict
returning TrackOriginTag::ExternalUnknown so an untagged local/Plex row can
never be cast.
cc
is_qobuz_streamable was dead code. Add is_castable_to_qconnect as the
single source of truth and depend qconnect-core on qbz-models so the
admission gate and the cast predicate derive from one enum.
cc
Committed together because these changes share main.rs, playlist.rs and
PlaylistView.slint, and the new quality field is atomic across every
TrackItem builder, so they cannot compile as separate whole-file commits.
- Universal play-from-here: a single play_track_in_context dispatcher builds
the queue from the current view's VISIBLE list and starts at the clicked
track (playlist, album, artist, favorites, label, mix, search). Cached
views order by the visible model; re-fetch views (album / artist top
tracks) reorder to the in-page filter; the search "most popular" hero
prepends to the results queue. Drops the playlist-track / mix-track arms
that always started at track 1 (the clicked id was read from the wrong
media-action slot).
- Album-name links in track rows open the album page (the ("album","open")
media-action arm was missing and fell through to a no-op).
- Unified track-row quality badge: a new quality-detail field on TrackItem,
populated from bit-depth / sample-rate in every builder through a shared
crate::quality::detail, rendered with a bare (no chip background/border,
centered text) QualityBadgeFull. Restores the exact bit-depth / sample-rate
line that had regressed to a plain CD / Hi-Res tier.
- Playlist track list: reserve the favorite / download / context-menu columns
in the column header, match the row's 36px cover cell, and anchor the
scrollbar to the view edge (matches Local Library > Tracks).
cc
Insert a 20px spacer before the body row. The JUMP TO bar's natural-top tracks body-row.absolute-position.y, so it drops with the row while its sticky fix-on-scroll clamp stays at 0 (unchanged).
cc
set_tab updated the Rust view state and called refresh, but never pushed the new index onto the Slint QueueState.tab property, so the History body (gated on tab == 1) never appeared. Sync the property synchronously in set_tab and on every refresh.
cc
Mirror the Flathub manifest: replace --device=dri with --device=all so the
sandbox can reach /dev/snd directly, enabling bit-perfect / exclusive ALSA
output like native installs. Flatpak has no granular --device=snd; /dev/snd
is only exposed via --device=all (same as Strawberry, mpv, and other
bit-perfect audio apps), which already includes --device=dri (GPU) — so the
separate dri entry is dropped. Reduces PipeWire/audio friction in the
Flatpak build.
cc
After bit-perfect ALSA Direct grabs a DAC exclusively (hw: + D-Bus
reservation), the device stays sequestered and is invisible to PipeWire,
so switching back to the PipeWire backend can't see it without restarting
the app. Tauri had a refresh affordance for hot-plugged DACs; this is the
refresh plus an explicit release, in one button.
Backend: Player::release_device() / AudioCommand::ReleaseDevice tears the
stream down immediately (cancelling the deferred drop), freeing the hw:
grab + its reservation, then un-suspends the parked sink and resets any
forced graph clock (both self-gating). It does NOT reopen, unlike
reinit_device which would re-grab the device.
UI: a refresh-cw icon button beside the Output device select, propagated
through the settings callback chain to handle_release_device, which
releases, waits for PipeWire to reclaim, then re-enumerates so a freed or
hot-plugged DAC reappears without an app restart.
cc
QbzSelect badges now render a "BP" tag for bit-perfect outputs and a
speaker glyph (volume-2) for the rest (real outputs that play audio but
not bit-perfect) — an addition, not a replacement. Only the device select
passes badges, so other selects are unchanged.
Add opt-in `searchable`: long output lists overflowed at 1440p, so the
open list gets a case-insensitive filter box and a 360px scroll cap.
Filtering hides non-matching rows by visibility only (model order intact,
so selected(i) keeps the original index). Backed by a new Rust
TextUtil.contains-ci pure callback (Slint 1.16 has no contains builtin).
cc
The System/JACK (CpalDefaultBackend) and output_sinks enumerations dumped
CPAL's raw ALSA PCM list: the `null` discard sink plus one output repeated
across every plugin profile (8x/Nx duplicates that share a display name).
The precise pickers (ALSA /proc/asound, PipeWire pactl) were already clean;
only these CPAL paths lacked filtering.
Add device_filter::retain_real_outputs(): collapses entries that share a
display name (keeping the best-ranked id), and sorts the `null` discard
sink to the end instead of offering it as a first-class output. Pure,
host-agnostic (PipeWire node names pass through), unit-tested.
cc
Completes the JACK output on top of the foundation (fbc03f41):
- StreamType::Jack + the player dispatch (backend==Jack -> JackStream).
- PlaybackEngine::Jack: a feeder thread (mirrors AlsaDirect) that resamples each
source to the JACK graph rate (UniformSourceIterator, stereo) and writes it to
a lock-free f32 SPSC ring (ringbuf); wired at the 4 engine-creation sites.
- jack_backend.rs: f32-native ring (fixes a byte-alignment white-noise bug) plus
auto-connect of qbz:out_FL/FR to the physical playback on activation, so audio
reaches the hardware without a patchbay (a user may re-patch freely).
- Re-enabled the available_backends push (JACK is now offered in the selector).
Verified on hardware: select JACK -> QBZ registers as client 'qbz' with stable
ports qbz:out_FL/FR (client.api=jack) -> music plays through the DAC, resampled
to the graph rate (NOT bit-perfect; BitPerfectMode::Disabled, as designed). The
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
Slice 3.2 (pending, separate): JACK<->passthrough/exclusive mutual-exclusion UI,
the bit-perfect-OFF note, runtime graph-rate-change callback, mono/multichannel.
cc
Backend-side foundation for a native JACK output. The hard, RT-sensitive part
is done and compiles: jack_backend.rs holds a JackStream that opens a JACK client
'qbz' with stable ports qbz:out_FL/FR, a lock-free SPSC ring buffer, and an
allocation-free process callback (de-interleaves stereo f32 to the ports). The
feeder pushes interleaved f32 via write_f32().
Also: jack 0.13 dep (linux), AudioBackendType::Jack + its factory arm + the
qbz-slint label/bit-perfect match arms. NOT yet offered in the selector
(available_backends push commented) because the player wiring is still pending:
StreamType::Jack + the dispatch branch (backend==Jack -> JackStream) + a
PlaybackEngine::Jack feeder thread that resamples each track to the JACK graph
rate. JACK mode will be opt-in and NOT bit-perfect (one fixed graph rate); the
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
TODO(distribution): feature-gate jack for Flatpak/distro builds without libjack.
cc
Completes the "Lock output device" (skip_sink_switch) fix. The lock already
skips set-default-sink + (Tier 1) the global clock force, but targeting was
implemented AS set-default-sink — so with the lock on QBZ's stream followed the
current default, and if the selected DAC was not the default, locked audio went
to the wrong device.
In locked mode, set PIPEWIRE_NODE=<selected sink> before the cpal PCM open so the
pipewire-ALSA plugin routes this stream to the chosen node WITHOUT changing the
system default. A Drop guard restores the prior env value after the open.
Delivers the reporter's full comment-3 ask: output to the selected sink AND never
change the default/priority AND never force the graph clock. Verified on hardware:
lock on + DAC selected + system default = onboard -> QBZ plays on the Cambridge DAC
(alsa_playback.qbz-slint linked to the DAC sink) while the default stays onboard and
another app keeps using it. Bit-perfect is traded for routing freedom in locked mode
(the graph clock is not forced), as designed.
cc
Exclusive-mode device acquisition suspends the default PipeWire sink
(pactl suspend-sink @DEFAULT_SINK@ 1) on the EBUSY-retry and CPAL-exclusive
paths, but never resumed it — leaving the rest of the system on a suspended
sink after exclusive playback.
Add suspend_default_sink_for_exclusive() (resolves + records the suspended
sink name) and resume_suspended_sink() (resumes that exact name; PipeWire sink
names are deterministic, so resume-by-name survives a vacate/re-create). The
player resumes on the pause-suspend teardown — after the exclusive stream is
actually dropped, never while the device is still held. Self-gating: a no-op
unless QBZ suspended a sink, so it is safe to call on every stop.
Does not touch the bit-perfect path. Verified no regression on hardware:
exclusive mode plays through the DAC (LED-confirmed) and system audio uses the
onboard sink (not stuck suspended). The suspend/resume paths themselves
(EBUSY race / CPAL-exclusive fallback) are rare and were not exercised in that
test; the fix is correct-by-construction and cannot affect bit-perfect.
cc
QBZ forces the global PipeWire clock for any non-locked PipeWire stream, but the
stop/pause-suspend reset was gated on pw_force_bitperfect — which is only set in
DAC-passthrough mode. So a plain PipeWire user (no passthrough) forced the graph
clock on play and never reset it, leaving the whole graph force-clocked after
stopping (a stale clock.force-rate that resamples every other app).
Track whether QBZ actually wrote the force (CLOCK_FORCE_APPLIED) and make
reset_pipewire_clock self-gating: it only resets a force WE applied, never
clobbering another app's forced rate. The player's two reset call sites (Stop,
pause-suspend) now call it unconditionally.
Verified on hardware (Cambridge USB DAC, PipeWire + passthrough off): a 192kHz
track forces clock.force-rate=192000; on pause-suspend the clock returns to 0
(previously stayed stuck at 192000).
cc
When "Lock output device" (skip_sink_switch) is on, also skip the global
pw-metadata clock.force-rate writes (the pre-stream force and the hi-res
re-apply), not just set-default-sink. This stops QBZ from yanking the whole
PipeWire graph clock on every track-rate change, which broke external routing
(qjackctl/qpwgraph/Reaper) for users who enabled the lock — issue #263 comment-3.
The bit-perfect path is unchanged: with the lock off (including DAC passthrough)
the clock is still forced to the track's native rate. skip_sink_switch is
transitively mutually exclusive with dac_passthrough/pw_force_bitperfect, so this
never collides with a forced-rate bit-perfect session.
Verified on hardware (Cambridge USB DAC): lock on + 44.1k->192k leaves
clock.force-rate untouched; lock off + passthrough forces 44100/192000 with the
DAC running at the native rate.
cc
The album header had several dead icon buttons and a non-navigating label
card. Wire them:
- Shuffle -> play_album_shuffled (SystemTime-seeded xorshift Fisher-Yates,
same as the playlist shuffle, no rand dep) + ("album","shuffle") arm.
- Radio -> existing ("album","radio") album-radio path.
- Artist (user icon) -> open the album artist.
- Label sidebar card -> navigate to the label page; added label-id to
AlbumState (populated from album.label.id) and made the card clickable.
Still pending for AlbumView (next batches): multi-select (square-check +
MultiSelectBar), the "more" ellipsis menu, the digital-booklet viewer, and
add-to-mixtape. Awards pages deferred (1:1 Tauri port, separate session).
cc
Playlist cards (carousels, search, favorites) emitted ("playlist","queue")
and ("playlist","play-next") but no handler existed, so the menu items
were dead. Add enqueue_playlist (fetch the playlist's tracks by id and
append, or insert-next in reverse to keep order) and the two match arms.
The album/track enqueue paths already worked but gave no visible feedback
when the queue panel was closed, reading as "unwired" — add a success
toast (Added to queue / Playing next) to every enqueue path.
cc
With the sidebar fully closed there was no way to reach playlists. Add a
playlists button to the closed-sidebar header cluster that opens a flyout
over SidebarState's entries (folders + playlists), six rows visible with
the rest scrolling, plus an always-visible footer linking to the Playlist
Manager. Rendered at the AppShell level (SidebarPlaylistsPopup +
SidebarPlaylistsPopupState); header button needs no absolute-position
correction (not inside a Flickable).
cc
The drag-end relied on the row TouchArea's pointer-up, which only arrives
if the row still holds the grab. Inside a ListView (or a Flickable that
stays interactive) the scroll gesture can steal the grab mid-drag, so the
up never reached the row and the ghost stayed glued to the cursor. Add a
`changed pressed` safety net that ends the drag on any release or grab
cancel (both paths guard on ta.dragging, so end() runs once). Also give
LabelPageView's Flickable the interactive: !DragState.active flag the
other track views already have.
cc
SidebarNavRow (mini sidebar) opened its section flyout with no header;
the closed-sidebar CompactNavBtn dropdowns show the section name + a
hairline. Set HeaderMenuState.title to the row label so the mini flyouts
match (same @tr strings).
cc
play_local_tracks_now backs all five Local Library play entry points but
omitted the refresh_sidebar(true) its sibling play paths run after
set_queue + after_track_change, so the Queue panel kept showing the
previous queue until reopened or its tab toggled. Add the missing refresh.
cc
In the mini (icon-only) sidebar the rows show no name. Add a hover bubble
to the right of each folder/top-level-playlist row surfacing the name
(folders also append their playlist count), mirroring the native title
Tauri puts on its collapsed buttons. Rendered at the AppShell level
(SidebarTooltip + SidebarTooltipState) because the sidebar clips its
overflow; anchored with the same Flickable absolute-position correction as
the folder flyout. Closes on click so it does not linger behind the flyout.
cc
absolute-position of an element nested in a Flickable double-counts the
element's own layout offset (measured: abs.y = true_y + root.y), so the
collapsed-sidebar folder flyout drifted down by one row-pitch per row
(lower folders left the viewport) and the track drag ghost plus the
playlist drop-target hit box drifted the same way. Subtract root.{x,y}
to recover the true window position; scroll-safe because root.{x,y} is
the row's fixed in-layout offset, not the viewport scroll. Rows outside
a Flickable (the section nav rows) are unaffected and left as-is.
Sites: SidebarRow folder-flyout anchor + recompute-drop hit box;
TrackRow drag down/threshold/start/move coordinates.
cc
- Local Library Tracks: build the play queue from the already-loaded rows
(instant) instead of re-querying the DB + filling covers on every click.
- Mouse back/forward buttons now drive history nav over track rows and album
cards (a TouchArea swallows them, so each interactive surface forwards them;
the AppShell catcher only saw the gaps).
- ExpandableSearch open width trimmed 20px (no longer rides over left controls
on small screens).
- QbzSelect toolbar (Group/Sort/filter) trimmed 40px total.
cc
Flyouts & idle (main effort):
- Header dropdowns now glue under the tab: open-menu resets from-sidebar +
title (a stale sidebar flyout state was flinging the panel far away).
- Idle-close now pauses while the cursor rests on the TRIGGER too (full
header tabs gained the trigger-hovered logic the others already had).
- Dropped the dropdown caret (saves width on small screens).
Header nav layout:
- Section nav moved INTO the left-controls cluster, after the three sacred
buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off
and sidebar not closed; compact icons when closed — mutually exclusive (no
more duplicate/cluttered header).
- Search field gives up 60px (animated) when the header hosts the nav.
Settings made functional:
- Toast component mounted in AppShell (it was never rendered) + the in-app
toasts toggle drives ToastState.enabled.
- "Show album artwork in Local Library track list" now drives the Tracks/
Folders rows. Both rows un-flagged as unsupported.
Collapsible search (ExpandableSearch, app-wide):
- Smooth open AND close (width animates both ways; magnifier fades).
- Reliable auto-focus on open (deferred via a one-shot timer).
cc
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.
Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.
Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.
cc
Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
grouping/filtering span the whole set. derive_albums = search + sort + group
(off / by letter with A-Z jump / by artist, both with section headers) + the
quality/format/source filter (1:1 with matchesQualityFilters: OR within a
group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
layout, not a layout slot) with an active-count badge. Fixed the year sort
(sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.
Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
multiple copies of the same album no longer merge into a duplicated track
list. Best-quality version is the default; a source-icon version picker
(hard-drive / Qobuz / Plex glyph in both the selected display and the options)
switches the shown copy in place. The album cover is the best-quality version
that has one, falling through versions, else a placeholder (covers backfilled
from disk via fill_missing_covers). Header padding-top 11px matches the app.
Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.
cc
Complete the tag editor with remote metadata auto-fill.
- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
remote lookup without depending on the Tauri binary; src-tauri is left fully
untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
search_releases_extended / DiscogsClient search_releases, generation-guarded),
select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
full metadata; overwrites album fields + positional per-track title merge;
track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
card strip with select highlight, Apply + open-in-browser). ADR-008: square
badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.
LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.
cc
Bring the Tracks tab up to the Favorites precedent.
- Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an
A-Z jump strip for Name (over the loaded set); show-album hidden under album
grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs
precedent; default "off"), loaded in navigate_local_library before the tab
derives.
- Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode)
+ MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue /
Play next / Add to playlist / Clear (no make-offline, no remove).
- derive_tracks (reduced port: local folder_alpha_key, no genre filter) +
TRACKS_CURRENT selection-source cache (cloned in apply/append before the move)
so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip.
- playback::enqueue_local_tracks (source-aware via local_queue_track; reversed
for play-next); per-row play-next/queue wired in on_track_action.
- Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode
+ on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped
(decided on the underlying LocalTrack.source, not the remapped TrackItem one).
- No-match empty state added.
cc
The local-album tag editor (no remote lookup yet — that lands with the
remote-metadata crate move). Opens from a pencil on the local album detail.
- TagEditorModal.slint: album title/artist + year/genre/catalog + per-track
title/track#/disc# table (indexed model write-back) + persistence QbzSelect
(sidecar default / direct opt-in) + save-with-progress. Mounted last in
AppShell (ADR-009 by mount order). ADR-008: dropdown, no pills.
- tag_editor.rs controller: open_tag_editor (off-thread pre-fetch via
fetch_album_tracks_blocking + D1 year seed + can-direct-write CUE guard),
save_tags (validate, is_dir gate, CUE guard, one-time direct-write confirm
via rfd + library_kv ack, sidecar/direct branch on spawn_blocking with
per-file progress, update_album_group_metadata via new with_db_mut, post-save
refresh of the album + browse models), close.
- AlbumPageView: edit pencil CircleAction, visible is-local && source != plex;
AlbumState gains `source`, set in apply_local_album.
- library_db: with_db_mut for the transactional metadata update.
- main.rs: on_media_action("album","edit") + TagEditorActions wiring (close/
save/set-persistence with the CUE gate).
cc
Frontend-agnostic foundation for the LocalLibrary tag editor (consumed by the
Slint modal + controller in a follow-up slice; the Tauri direct-write command
can later fix-forward onto this):
- tag_writer.rs: write_album_tags_to_files() ports the Tauri lofty loop 1:1
(dedup by file_path first-wins, per-file on_progress closure instead of a
Tauri event, destructive remove-on-None for album-artist/year/genre/catalog,
honest partial-failure error wording) + compute_track_artist_match().
- database.rs: a small library_kv (key,value) table + get_kv/set_kv (upsert) to
persist the direct-write one-time acknowledgement (replaces Tauri localStorage).
cc
Render artist portraits on the two-column Artists rail and fill missing ones
from Qobuz in the background.
- qbz-library: new get_all_artist_image_urls() bulk reader (custom path else
cached Qobuz url, keyed by artist_name) so previously-fetched portraits
reappear on revisit without re-hitting Qobuz. (The Tauri batch command was
never registered; this is the corrected one-pass reader.)
- state.slint: LocalArtistItem gains a decoded `image` field; LocalLibraryState
gains the fetch toggle + progress props. merge_artists now returns a Send-safe
ArtistRow (no slint::Image) across spawn_blocking; apply_artists builds the
LocalArtistItem with the (default-empty) image on the UI thread.
- ensure_artists_loaded: seeds the rail from get_all_artist_image_urls, spawns
decode jobs by scheme (local path -> spawn_local_loads, http -> spawn_loads),
and kicks the capped fetch.
- fetch_missing_artist_images: sequential worker, max 50/session, 1s apart,
generation-guarded, exact-normalized match only (no wrong-artist persist),
paints each portrait immediately, persists via cache_artist_image_with_canonical.
- artwork.rs: Copy-safe LocalArtistRowImage { index, gen } target + apply arm
that resolves index -> name and routes through the new name-keyed dual-setter
set_artist_row_image (flat master + every grouped section), so portraits
survive a derive_artists re-run.
- LocalArtistRow renders the decoded image (placeholder until it lands).
cc
Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.
Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.
Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.
cc