Slice 3/13. New qbz-offline-cache/src/purchases_service.rs (pagination glue,
get_purchase_total two-call per-type-totals primitive, byte-for-byte
filter_purchase_response). New qbz-slint/src/purchases.rs controller load shells
(get_downloaded_track_ids -> HashSet, load_purchases_metadata with TWO separate
ids-totals calls, load_purchases_by_tab with total backfill + error mapping).
Replicates Tauri's 'Failed to fetch ... purchases' wrapper so network errors map
to purchases.loadFailed 1:1 (TDD-caught). 10 unit tests green. Lazy/cache/force
gating deferred to UI slices 8/9 by design.
cc
Slice 2/13. New impl QobuzClient block in qbz-qobuz/src/purchases.rs ported
1:1 from src-tauri api/client.rs: typed/ids purchase listing (page_limit 500,
two-call per-type totals quirk preserved), get_track_file_url_by_format
(RPC-signed, intent=stream, empty-url -> TrackUnavailable), and download_audio
(http1_only, NO total timeout). Adds 2 endpoint consts; bumps
http()/secret()/authenticated_headers() to pub(crate) for the sibling module.
Offline-gated via self.http()?. Drops .use_native_tls() (crates/ workspace is
rustls-only; matches cmaf.rs build_cdn_client) to keep the standalone build green.
cc
Slice 1/13 of the Purchases Tauri->Slint port. Adds PurchaseResponse,
PurchaseIdsResponse, PurchaseAlbum, PurchaseTrack (no version field) and
PurchaseFormatOption to qbz-models, plus the 5 lenient deserializers ported
verbatim from src-tauri api/models.rs. Derives Default on SearchResultsPage
for the empty-page fallback. 4 round-trip tests, cargo test green.
cc
select_bundled_translation must run after AppWindow::new() (the component's
global translation context does not exist before then), so startup now paints
in the persisted language instead of falling back to English. Adds
reseed_i18n_labels() which seeds the 14 AppearanceState option arrays from
qbz_i18n::t() — these were @tr() property defaults, which are not reactive to
translation changes — called at startup and on live language switch so the
settings <select> options translate and update without restart.
cc
Fills all previously-untranslated msgstr (UI strings absent from the Tauri
JSON seed) via a translation pass, then hand-fills the remainder. Neutral
Latin-American Spanish; standard professional register for de/fr/pt; brand/
technical tokens (FLAC, kHz, Plex, etc.) kept literal; {} placeholders and
gettext plural forms preserved. All four catalogs pass msgfmt -c.
cc
Adds qbz_i18n::mark() (identity, extraction-only) and applies it at the
definition sites of literals later translated through t(variable):
release-type classifier, discover section labels, artist release-section
titles + month names, label jump tabs, and audio settings dropdown tables.
Fixes the discover_prefs double-t() (label_for now returns the marked
literal, translated once at the consumer).
cc
Wrap user-facing Rust-built strings in qbz_i18n helpers across the
Artist/Album/Label surfaces: artist release-section titles + jump-tab
labels (routing kept on the English keys, only displayed labels
translated), MusicBrainz month-name/date formatting, album track-count
subtitle + "From the same artist"/"Listening suggestions" carousels,
label carousels + jump tabs + group-by "Unknown", and the shared
album_map release-type labels (Album/EP/Single/Live/Compilation).
cc
Wrap user-facing Rust-built strings in qbz_i18n helpers across the
Discover/Home/Search surfaces: discover section titles, configurator
labels (discover_prefs::label_for), For You carousel titles, mix
subtitles + duration, suggestions track-count + Song Radio, search
section headers + count subtitles, and the Lossless quality tooltip.
Reuses shared msgids ({} track/{} tracks, {} album/{} albums, section
names) so the catalog stays deduped.
cc
Wrap user-facing eyebrow labels (MIXTAPE/COLLECTION/ARTIST), album/item
counts, add/edit/builder/cover toasts, dialog titles + image filters,
play/queue toasts, and the default discography name in the My QBZ
controllers with qbz_i18n t/t_args/tf helpers. Keep the My QBZ brand
itself untranslated via positional interpolation.
cc
Wrap user-facing toasts, dialog titles/descriptions, durations,
track/folder counts, scan/cleanup status, and grouping labels in the
Local Library controllers (local_library, local_library_settings,
local_playlist, tag_editor) with qbz_i18n t/t_args/tf helpers.
cc
The folder-options dropdown is re-seeded from Rust at runtime in both
create-playlist builder sites, so the state.slint default is dead. Wrap
the seed-site 'No folder' literal in qbz_i18n::t so it resolves through
the shared gettext catalog.
cc
Wrap remaining user-facing literals in @tr across discover (RADIO,
TOP TRACKS), DiscographyBuilder (release-type/order-by labels), the
OfflineManager hero, ExpandableSearch placeholder default, and the
appearance option arrays in state.slint (displayed as-is, no Rust
re-seed). Replace string concatenation with positional @tr plus
ngettext plurals for the DiscographyBuilder footer and OfflineManager
track counts so word order is locale-stable. Language endonyms and the
macOS/Adwaita/quality-tier brand names stay literal.
cc
Wire the Appearance language selector (appearance-select "language"): map the
dropdown index to a language key via the verified order (0 Auto, 1 English,
2 Espanol, 3 Francais, 4 Deutsch, 5 Portugues), persist the raw user choice,
resolve "auto", then set_language() + select_bundled_translation() to switch
all @tr bindings live with no restart. Seed the selector index from the
persisted key at startup.
cc
Add a persisted `language` UiPrefs field (default "auto") with index<->key
helpers matching the existing appearance-prefs pattern. At startup, before
the AppWindow is created, resolve "auto" via qbz_i18n::resolve_auto(), call
qbz_i18n::set_language() and slint::select_bundled_translation() so the first
paint uses the right translations. Map dates::current_locale() from the active
runtime language so month tokens localize with the rest of the UI.
cc
POSIX locale precedence is LC_ALL > LC_MESSAGES > LANG. resolve_auto
previously only checked LC_MESSAGES and LANG; add LC_ALL as the
highest-priority env var, skipping it when empty. Extend the
resolve_auto test (env-mutation serialized via the existing Mutex).
cc
Implement the frontend-agnostic translation layer test-first:
- plural.rs: PluralRule parsing nplurals + the two expressions our
locales use ((n != 1), (n > 1)); index(n) selects the form, with a
safe English default for anything unrecognized.
- po.rs: Catalog::parse handles the header entry, msgid/msgid_plural/
msgstr/msgstr[N], multi-line continuations, and \n \t \" \\
escapes; empty msgstr means no translation (get returns None so
callers fall back to English). Reads Plural-Forms from the header.
- lib.rs: 5 catalogs embedded via include_str! and lazily parsed
(OnceLock); AtomicU8 language state; set_language/current_language/
resolve_auto (LC_MESSAGES then LANG -> 2-letter prefix); t/tn/t_args/
tf with left-to-right {} substitution and English fallback.
17 unit tests, all green via cargo test -p qbz-i18n. Content tests use
inline fixtures since the bundled .po files are header-only for now.
cc
Add new no-dependency qbz-i18n crate to the workspace members.
Frontend-agnostic translation catalog per ADR-006 (reusable by
Slint/TUI/headless; no slint or tauri deps).
cc
Three restart-persistence features built and tested together (they share
ui_prefs + the enter_shell restore sequence, so kept as one change):
- Startup page: persist + restore the last SAFE top-level view via the
canonical apply_entry dispatcher (loads data, not a blank view). Wired to
Settings > Appearance > "Where you left off"; ui_prefs startup_page/last_view.
- Window geometry: persist logical size + outer position and restore them at
launch, clamped to the current monitor so a smaller/disconnected display can
never open an oversized or off-screen window (winit Resized/Moved hook).
- Playback session: new session_persist module wiring qbz-app's SessionStore.
Saves queue + current track + volume + shuffle/repeat + position on
track-change / pause / ~5s tick / exit (incl. hide-to-tray and tray Quit);
restores the queue PAUSED at launch. Position resume on first play threads
the player's existing start_position_secs (no protected-audio change beyond
passing that param). Gated on the persist_session / resume_playback_position
playback prefs; opened per-user on login + boot-restore + offline entry.
Known issue: the seek bar/timer do not visibly show the resume position until
play is pressed (the audio resume itself works); marked in code for follow-up.
cc
Drop the custom-icon picker (owner decision): My QBZ now always uses
QBZ's monochrome (tray) glyph — in the Settings preview, the sidebar row,
and the header section nav (incl. the icons-only compact nav when the
sidebar is fully closed). Only the name stays editable; the reset control
is a square glyph button that clears the name to the default. The
icon-change backend (pick-icon / custom-icon) stays but is unsurfaced.
cc
Restore across restarts: the three-state sidebar (open/mini/closed) and
the section-nav placement (sidebar vs header) seed from ui_prefs before
the shell renders and persist via new ShellState callbacks; the player
volume persists on slider release only (a new QbzSlider `released`
callback + NowPlayingState.persist-volume — no ui_prefs write per drag
tick) and is reapplied to the player on session entry.
cc
Track info is a Qobuz-only metadata page, but it fired for local / Plex /
ephemeral playing tracks too. Add NowPlayingState.source (populated from
the QueueTrack: None coerces to "qobuz" for catalog tracks, "local" for
local files) and gate the trigger on source == "qobuz" — in the SongCard
TouchAreas (pointer cursor + click both no-op otherwise) and the PlayerBar
menu handlers.
cc
The refresh button + device select sat in a center-aligned row, so they
floated with a right-hand gap once the window widened out of responsive
mode instead of landing flush on the same right edge as the single-control
rows. Switch the row to alignment: end.
cc
Drop the unported "Match system window chrome" row from Settings >
Appearance entirely (UI only; the AppearanceState flag + handler stay
inert for now).
cc
Frontend-agnostic core in qbz-integrations::discord (DiscordRpc +
NowListening) — the Tauri backend minus the tauri::command/State
wrappers: lazy IPC connect, Listening activity (details/state/assets +
"Get QBZ" button + timestamps), and the opt-in Flatpak socket bridge.
qbz-slint discord_rpc controller: process-global client, init AFTER the
session is active (PR #477 persistence fix — both online and offline
entry, never at early boot), push on the track-change and play/pause
edges, clear on logout. Opt-in toggle in Settings > Integrations
(DiscordState, persisted in ui_prefs.discord_rpc_enabled) + a Flatpak
socket-access note. The opt-in is per-machine (ui_prefs) rather than
Tauri's per-user.
cc
System Notifications now persists (ui_prefs.system_notifications) and
gates the desktop notification via a poll-thread atomic, seeded at
startup and flipped live by the toggle (scrobbling stays independent).
Mini player default view persists to mini_default_view and seeds its
select index at startup; the miniplayer already reads the key on open.
Both lose the red "unsupported" flag. Also removed 13 unsupported
Appearance rows that will not be ported: Font, the title-bar navigation
group (Search/Navigation in title bar + per-section toggles) and
navigation buttons position, window controls style/size/color preset,
and a duplicate immersive default view.
cc
The binary compiles both rustls providers (aws-lc-rs via rust_cast, ring
via reqwest), so rustls cannot auto-select one and panics on the first
TLS connection. It was only installed lazily on the cast paths, so
joining a Qobuz Connect session without ever casting panicked the
WebSocket worker thread. Install it once in main() before any TLS;
idempotent, so the cast paths' calls stay no-ops.
cc
Local playlists were excluded from both sidebar folders and the cover
collage. Folders: new local_playlists.folder_id column (+ migration) on
the shared playlist_folders, move_to_folder/clear_folder helpers, the
move-to-folder context menu enabled for local rows, optimistic cache
move, and grouping under folders in the rebuild + collapsed-folder
flyout. Collage: resolve up to four covers from a local playlist's own
tracks with no network (local file artwork, Plex thumbs, cached-Qobuz
covers), populated in the async load and routed through the local/Plex
art loader (file/Plex paths would miss the HTTP loader).
cc
The sidebar Queue reads the same core pointer as the now-playing bar,
but several paths refreshed the bar without the sidebar, so the panel
showed a stale NOW PLAYING track and the NPB shuffle button looked dead
(the reordered up-next was never re-pushed). Pair refresh_sidebar with
the now-playing refresh in toggle_shuffle, the local-tracks inline
shuffle, the gapless seamless-change (regardless of moved), and the Plex
quality patch. The shuffle engine itself was already correct.
cc
Slint hit-tests sibling children in reverse declaration order, so the
keyboard FocusScope declared after the pointer TouchArea was visited
first and, with the default focus-on-click, consumed the first Pressed
just to grab focus — aborting traversal before the TouchArea, so every
button needed two clicks. Set focus-on-click: false on the FocusScope in
QbzPrimaryButton, SecondaryButton, QbzCheckbox, QbzToggle, ToggleButton
and QbzSelect; the press now reaches the TouchArea on the first click
(Tab/Enter/Space activation unaffected).
cc
The immersive Queue up-next reuses the coverflow model, whose art was
windowed to ±4 around the current track, so only now-playing + the next
four rows decoded a cover. Build art jobs for the whole list and widen
the decode window when the immersive Queue panel is open (still lazy per
row); the panel fires QueueState.panel-opened on mount so the covers
repaint when it opens.
cc
Album Info expanded-track credits: the roles line now stretches and word-wraps
instead of running off the right edge of the modal.
Track Info credits: render two independent fixed-width columns (even-index ->
left, odd-index -> right) so long name lists wrap and the right column stays
aligned on every row, with no height-coupled row gaps; names stay clickable,
one per line. Mirror the wrap in the immersive Track Info panel.
cc
Widen the album label/awards/external-links sidebar 160->200px. Replace the
generic external-link chips with inline brand SVG icons (Last.fm / Discogs /
MusicBrainz) rendered in their native colors (MusicBrainz split half purple /
half orange), no visible label + a hover tooltip. Uppercase the "EXTERNAL
LINKS" heading to match LABEL / AWARDS.
cc
- Booklet: new frontend-agnostic qbz-booklet crate (MuPDF render to PNG;
the Tauri Cargo stanza + device_rgb/alpha=false render workaround ported
verbatim). In-app reader modal (paginate/zoom/rotate/download) driven by
BookletState/BookletActions; header booklet button now opens it.
- "From the same artist" carousel (/artist/getReleasesGrid) and "Listening
suggestions" carousel (/album/suggest), reusing the Discover Carousel,
appended below the track list with their own artwork pipeline targets.
- External Links sidebar block (Last.fm / Discogs / MusicBrainz) built from
artist + title, opening in the system browser.
cc
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.
New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).
Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.
Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.
Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.
Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.
cc
Fourth NPB layout: a cover + spectrum dock pinned flush to the bottom-left
of the open sidebar (forming an L), with the bottom bar reusing the New
layout (window-centred transport, full-width inset seekbar, right cluster)
minus the cover. Gated on the open sidebar (npb-mode 3 && sidebar-state 0);
cycling the sidebar out of the open state reverts live to New while keeping
the persisted choice.
- SidebarNowPlayingDock: AppShell root overlay, cover flush to the window
bottom-left, compact spectrum band (Bars 28-mirrored / Waveform / Energy)
above it, single top-right eye toggle to show/hide it. FFT reuses
VisualizerState; when hidden the sidebar list reclaims the space.
- Extract AudioStamp into a shared exported component (consumed by the Small
and Large bars); in Large it sits at a constant gap left of the right
cluster, out of the left song card.
- Persist the visualizer on/off and the spectrum mode in ui_prefs; restore
at startup and prime the FFT tap after visualizer::install().
- Reduce the horizontal volume slider width 10% across all NPB modes.
cc
Add local albums/artists/tracks sections to the main header cortinilla
(below the Qobuz results) and local albums to the immersive cortinilla,
derived by grouping the on-device track rows. Merge the Plex cache into
the local search so Plex-only content surfaces (db.search is
local_tracks-only). Route each cover by source via spawn_search_loads
(Qobuz http / Plex /library PlexThumb / local file). Widen the local
section caps when offline or signed out. Rename the section lore from
"on this device" to "on Local Library".
Also clear the search input after a cortinilla element is activated
(row click / View more / Enter) so lingering text can no longer
re-invoke the dropdown when focus returns to the field; replicated in
the immersive search field via a dedicated raw-text property.
cc
Split layout is a true 50/50 (explicit half-widths; the horizontal-stretch weights skewed it because the left's intrinsic content out-measured the right). Removed the glass backing plates from the split panel and the song-data metadata block — both sit directly on the atmosphere now.
Cover art no longer upscales blurry on large windows: the now-playing large cover decodes at 1000px (was 300, the hover-preview size) so the full Qobuz source resolution comes through, and the split / Static / Album-Reactive panels cap the art at its native size so it grows sharply instead of stretching.
New Settings > Appearance 'Immersive default view': 'Remember last' restores the last view, or pin Album Reactive / Static / Coverflow / Spectrum / Lyrics / Queue. Persisted via ui_prefs + ImmersiveActions opened/view-changed hooks.
cc
Intelligent Search (phases 3-4): a frontend-agnostic SearchService in qbz-app (Capa A result cache + Capa B interaction ranking) behind a live header dropdown with skeleton loading, stale-while-revalidate, 220ms debounce, keyboard nav + scroll-into-view, per-section caps, a URL-match artwork guard, and an opt-out Settings toggle. Typing previews in the dropdown; Enter goes to Search > All; local-library results show in the dropdown only.
Immersive search: an in-immersive glass cortinilla (Albums/Artists/Playlists) that switches playback without navigating — a Settings action select (disabled/replace-queue/play-next/add-to-queue), the HUD stays up while searching and never hides while the cursor rests on the chrome, twinned top buttons, and a clear button with always-centered field text.
cc
Theme.danger-hover is a 15-25% translucent tint sized for danger-bg
surfaces, so using it as the destructive QbzPrimaryButton fill made the
button ~80% transparent (standard themes) or pale-pink (a11y themes) on
hover. Use Theme.danger.darker(0.15) instead — an opaque, richer red in
every palette, matching the existing danger-button hover idiom in
BlacklistManagerView.
cc
The BlacklistManagerView disabled-filter banner hardcoded the Dark-theme
warning hex (#fbbf24 / #fbbf241a / #fbbf244d) for fill, border, icon tint
and label. These resolve to no other cluster (item #6's named-file list
excludes this view; item #7 owns the WarningBanner.slint primitive, a
different file), so without this swap accessible themes (wcag-dark,
high-contrast) would render the wrong amber here, defeating P4's
'accessible themes take effect everywhere' goal. The hex equal the Dark
warning family exactly, so the swap is identity-preserving on the default
theme.
cc
Switch the open field's focused border from Theme.accent to the dedicated
Theme.focus-ring token, reusing the existing input.has-focus signal. Stays
accent-equal in most palettes but reads distinctly in the a11y themes, so
keyboard focus is consistent with every other primitive's ring.
cc
Give SecondaryButton, ToggleButton, CircleAction, QbzCheckbox, QbzToggle
and QbzSelect a keyboard FocusScope (Enter/Space trigger the same action
as a click) plus a non-layout focus-ring overlay: a sibling Rectangle
with a transparent-default 2px border that turns Theme.focus-ring only
when the scope has keyboard focus, with its radius matched to the control
(circle for CircleAction) so focus never reflows the layout.
The existing TouchArea stays the pointer source on every control; the
FocusScope only adds key activation and focusability. Universal: the ring
is a per-theme token and only appears on keyboard focus, so it stays
visually neutral at rest across all 33 themes (WCAG 2.4.7 focus-visible).
cc
Swap the filled-heart tint from the generic Theme.accent to Theme.favorite
so the favorited fill keeps a stable >=3:1 contrast against the row in
every theme and does not flatten against accent in High-Contrast. The
heart-filled/heart SVG swap remains the primary WCAG 1.4.1 shape cue.
cc
The active playlist/folder row and a hover row both paint surface-hover,
so the only active signal was a subtle text-color shift. Add a 3px accent
left-edge bar (non-layout overlay, no reflow) as a redundant WCAG 1.4.1
shape cue. Move the hardcoded green drop-target fill/border (#22c55e26 /
#22c55e66) to Theme.success-bg / success-border so the affordance is
themed across all 33 palettes.
cc
Replace the fixed mid-grey #b0b0b0 / #999999 on the CD/MP3/lossless
marks, the tier label, the detail line, and the SongCard LED labels
with Theme.text-muted (and text-secondary for the primary tier label).
A fixed grey vanished on light/HC backgrounds; tying it to the theme
keeps the badge legible everywhere. The Hi-Res brand mark is a
non-tinted Image and is left untouched (stays branded).
cc
Replace hardcoded danger reds (#ef4444 / #dc2626 / alpha variants) with
the Theme danger tokens so destructive affordances follow every theme,
including the high-contrast and colorblind palettes (WCAG 1.4.1, P4 #10).
- ContextMenuItem: danger row icon tint + label -> Theme.danger.
- MultiSelectBar: danger button hover bg -> Theme.danger-hover,
border -> Theme.danger-border, icon tint -> Theme.danger.
- BlacklistManagerView: per-row remove + Clear-All control + confirm
modal danger button -> Theme.danger / danger-bg. The solid-filled
confirm button uses Theme.danger.darker(0.12) for hover (mirroring
how accent-pressed is derived) instead of Theme.danger-hover, which
is a translucent ghost-surface tint and would wash out a filled
button (and is lighter in some a11y palettes).
These danger surfaces already pair an icon + verb label with color, so
color is reinforcement (not the sole signal); no HC-gated borders added
here to avoid touching shared button geometry.
cc
Replace the hardcoded variant hex with the themed families:
error -> danger/danger-bg/danger-border, success -> success(+bg/border),
warning(default) -> warning(+bg/border). The "info" variant has no
dedicated token, so it maps to Theme.accent with the same 10% bg / 30%
border alphas the literals used -- a small hue shift in some themes
(behavior change, not a pure 1:1), but accent is the themed
informational color. The per-variant glyph already provides the
non-color cue (WCAG 1.4.1).
cc
Replace the hardcoded per-kind icon hex (#4CAF50/#ff6b6b/#FFA726) with
Theme.success/danger/warning so the toast tint is themeable; buffering
and info keep Theme.accent. The per-kind icon already carries the
non-color cue (WCAG 1.4.1) -- this only guarantees contrast in the a11y
palettes while staying visually neutral in the 31 standard themes.
cc
Active segment gains a 2px accent underline (non-color shape cue, WCAG
1.4.1) drawn as a non-layout overlay so it never reflows the row, plus an
HC-only strong border gated on Theme.is-high-contrast. Count-badge fills
move from fixed white washes to Theme.alpha-15/alpha-8 so they follow
theme polarity.
cc
Add Theme.is-high-contrast-gated 1px border-strong border to the PlayButton
filled white disc so it stays delineated against a light HC background. The
disc colour stays 1:1 with Tauri (#ffffff); only an HC outline is added, so
the 31 normal themes and the non-circle glyph variant are untouched.
cc
Add Theme.is-high-contrast-gated 1px border-strong border to the seek-bar
hover thumb so it is delineated against the accent progress line under a
flattened HC palette. The 3px rail itself gets no outline (a 1px top+bottom
border would swallow the thin line); the thumb border carries the
redundant-encoding for the seek bar. Zero visual change in non-HC themes.
cc
Add Theme.is-high-contrast-gated 1px border-strong outlines to the track
and thumb of QbzSlider and QbzVerticalSlider so the rail stays visible and
the thumb is delineated against the filled track under a flattened HC
palette. Borders draw inside Slint's box so thumb travel math is unchanged.
Zero visual change in the 31 non-HC themes.
cc
P4 foundation for the redundant-encoding accessibility pass. Adds a single
flag that High-Contrast-only affordances (1px control borders, slider-thumb
borders) read so they never leak into the polished normal themes, mirroring
the existing is-system wiring.
- qbz-theme: `pub fn is_high_contrast(id)` at the crate root next to
`is_light` (true for HighContrast + HighContrastLight), with a unit test
`high_contrast_flag_only_for_hc_themes` asserting exactly the two HC themes.
- qbz-slint theme.slint: `in-out property <bool> is-high-contrast: false;`
beside is-system; re-exported via semantic-colors.slint so all ~145
components get `Theme.is-high-contrast` with no per-file import change.
Embedded the canonical HC border gate and the non-layout focus-ring
snippet as a comment for the cluster agents to copy verbatim.
- qbz-slint theme.rs: apply_theme sets it via
`qbz_theme::is_high_contrast(id)`.
cc
Materialize the final verified Part B palettes for wcag-light, wcag-dark,
high-contrast (dark), high-contrast-light, and colorblind. Each is a fully
populated ThemeColors row with solid (opaque) status surfaces/borders (not
alpha tints) so accessible contrast is guaranteed independent of backdrop.
Includes the success + focus-ring families per token; high-contrast-light
applies the adversarial warning correction (#735c00 -> #5e4b00, AA-only ->
AAA on white). colorblind uses the exact Okabe-Ito values with body-size
success routed to success-hover on the lightest tier (C.6).
Mark accessibility themes as implemented and add WCAG/APCA + CVD unit tests:
- wcag-* assert AAA (>=7:1) text/accent/status + APCA body |Lc|>=75
- high-contrast/-light assert >= the wcag-dark/-light text-bg ratio and
>=3:1 non-text for border-strong/focus-ring/border-subtle
- colorblind asserts AA on all tiers + danger/warning/accent stay distinct
under a Machado deuteranopia simulation (delta-E separation)
- all-rows test asserts every registered theme is fully populated (no zero
color), exactly 5 accessibility themes, 33 total ThemeId rows
cc
P2 of the theme migration. Materialize every non-accessibility theme
from src/app.css (per 01-tauri-themes-inventory.md): the core Light row
plus the 23 branded/community themes (warm, nord, dracula,
catppuccin-mocha, breeze-dark, adwaita-dark, alucard, aurora, ikari,
ayanami, iscariot, stratego, rumi, zoey, mira, rose-pine-dawn,
breeze-light, adwaita-light, duotone-snow, snow-storm, frost, langley,
kurosaki).
- No CSS cascade on the Slint side: every row is fully materialized.
Omitted tokens (light's accent trio; breeze-dark/adwaita-dark's
danger/warning families) are resolved against :root Dark at
transcription time.
- Alpha polarity is luminance-derived, not the Tauri type flag: frost
(#2e3440) and langley (#2c0a0a) are registered light but are dark
canvases -> white alpha base; alucard (#fffbeb) is genuinely light ->
black base. surface_hover/border_muted bases flip with polarity too.
- Status families (danger/warning) are baked as straight-alpha tints of
the solid hue (0.1/0.3/0.2; dracula 0.15/0.4/0.25; light hover 0.15),
matching Tauri's rgba() derivation.
- Derived (no Tauri parity) per-theme: success (project green #3fae6a on
dark, #1f8a4c on light), focus_ring (= accent), favorite (= danger).
These are placeholders to be polished in P4.
- is_implemented() now reports all standard themes ready; the 5
accessibility themes remain gated for P3.
Adds a full-population smoke test over every registered theme plus
per-theme hero-value, polarity and non-standard-tint assertions.
cargo test -p qbz-theme: 33 passed.
cc
Connect the qbz-theme registry to the running app and persist the choice
across restarts (owner hard requirement 2026-06-20).
- `src/theme.rs`: Rgba->slint::Color conversion, `to_slint` building the
generated `ThemeColors` (55 fields, exact name parity with the Slint struct),
`apply_theme` (set_c + set_is_system), and slug<->index<->id mapping with
slug as the source of truth. Unit tests cover the roundtrip + fallbacks.
- `ui_prefs.rs`: new `theme: String` slug field, `#[serde(default)]` ->
`qbz_theme::default_slug()` (OLED). Old profiles without the field restore to
OLED. Tests assert the default.
- `main.rs`: add the missing `"theme"` arm in `on_appearance_select` (was
falling into the debug `other =>` arm) — maps dropdown index to id, persists
the slug, hot-switches the palette. Startup restore seeds the dropdown list
from the registry, derives the index from the persisted slug, syncs the
legacy `ThemeState.mode`, and applies the theme before first paint.
- `app.slint`: export `Theme`/`ThemeColors`/`ThemeState` so Rust can drive them.
- `AppearanceSettings.slint`/`state.slint`: the theme list is now Rust-seeded;
comments updated (index no longer drives colors; slug is source of truth).
Headline acceptance: pick a theme, quit, relaunch -> same theme; fresh profile
boots into OLED Dark. The 4 existing themes are unchanged (legacy alias values
preserved in the registry).
cc
Replace the per-token `ThemeState.mode` ternary system with a single
`ThemeColors` struct (`Theme.c`) that Rust pushes from the qbz-theme registry.
- New `foundation/theme.slint`: `ThemeColors` struct (kebab-case fields in
lockstep with `qbz_theme::ThemeColors`) + `Theme` global of thin aliases.
Every existing token name is preserved (surface-*, text-*, accent-*,
border-subtle/muted, favorite, card-shadow, alpha-08/10/12/18/55/65/70/75)
so the ~1900 call sites compile verbatim; the danger/warning/success
families, border-strong, bg-hover, focus-ring and the full 24-tier alpha
superset are added.
- System mode preserved: `Theme.is-system` (set by Rust) falls the
System-overridden tokens back to the std-widgets Palette exactly as the old
`mode == 3` branch did; all other themes read `c.*`.
- `semantic-colors.slint` becomes a re-export of `Theme`/`ThemeColors` from
`theme.slint`, so the 146 existing imports keep resolving.
- The `Theme.c` default is seeded with the Dark palette so the live `.slint`
preview renders without a Rust feed.
cc
Introduce the `qbz-theme` crate (ADR-006): pure Rust, no Slint/Tauri/heavy
deps, hand-rolled color + contrast math. It holds the scalable theming data
model the Slint migration consumes.
- `Rgba` + WCAG relative-luminance/contrast-ratio + an APCA Lc approximation
(secondary a11y gate for P3 tests).
- `ThemeColors`: the complete token contract (surfaces incl. bg-hover, text,
accent, danger/warning/success families, border-subtle/muted/strong,
focus-ring, favorite, card-shadow, 24-tier alpha superset with polarity
baked in).
- `ThemeId` enum (all 33 themes) with stable slugs, display names (proper-noun
data, not i18n keys), Core/Dark/Light/Accessibility categories, and a
luminance-derived light/dark flag. `default_theme_id()` = OLED.
- Registry materializes the 4 P1 themes (Dark/OLED/Tokyo Night/System-fallback)
from src/app.css, preserving the legacy translucent alias values so the
existing themes stay pixel-identical; derives the new token families.
- 22 unit tests (registry fully populated, default is OLED, legacy alpha
aliases unchanged, slug roundtrip).
cc
The collapsed cluster put the (opacity-0 but still hit-testable) exit
button on top of the circle, so it stole the hover (only a sliver of the
circle reacted) and captured clicks (every press exited immersive). Make
the circle a permanent right-edge handle with its own full-area TouchArea;
reveal the action buttons to the LEFT of it so they never overlap; gate
the buttons inactive while collapsed; drive expand from an OR of bg/circle/
button hover (no steal-flicker); and let a click on the circle pin it open
as a fallback.
cc
Center the search field to the window (absolute x, independent of the
left mode trigger and right controls) like the main UI, instead of a
stretch slot that drifted off-center. Collapse the window controls to a
single circle pinned to the right edge that hover-expands LEFTWARD into a
glass capsule (right edge fixed, so it never displaces the centered
search). Reduce the active controls to Fullscreen toggle + Close (the
Close now EXITS the immersive overlay, not the app). Minimize, maximize/
restore, drag and close-app are parked as commented-out blocks for the
future qbz custom titlebar; their WindowControlActions wiring stays.
cc
Content sat directly over the variable album atmosphere and washed out.
Add a semi-opaque dark backing behind the split-right panel (and a lighter
plate behind the split-left metadata) so any panel reads on any cover.
Track Info: replace the dark-gray modal labels with bright local variants
(labels #ffffffb3, values near-white). Queue: fix the misaligned clear
button (content-sized, 34px baseline, flush trailing edge), de-pill the
sub-tabs (underline active), brighten rows. Suggestions: hairline rims and
badge backing discs, brighter text. Header: unify the glass capsule idiom
across the mode trigger, search placeholder and controls; polish the
two-level dropdown (caps group headers, spacing). Split gutters/gaps tuned.
cc
minimize/maximize/fullscreen were calling winit directly, but the Slint
winit adapter reconciles window state from Slint's own properties on every
realization (winitwindowadapter.rs ~1328 toggles winit to match
properties.is_fullscreen()), so a direct winit set_fullscreen was undone on
the next frame - fullscreen never stuck. Drive them through
slint::Window::{set_minimized,set_maximized,set_fullscreen} (api.rs), the
same properties the adapter reads, so the state holds. Drag stays on winit
(no Slint API). Same lesson as the miniplayer decorations fix.
cc
Replace the immersive ModeBar/ShaderBar with a two-level dropdown (Focus
and Split groups with their children) behind a display glyph, freeing the
header for a future Album/Playlist search (an inert styled placeholder
sits in the center for now). Port the Tauri header window-controls cluster
1:1: a new WindowControlActions global drives the main window via the
winit accessor (minimize, toggle-maximize, toggle-fullscreen, drag-move)
plus exit-immersive and close-app (reusing invoke_close_app). Fix the
split layout: the columns were both stretch 1 so the panel got a sliver;
art-left is now 55% with a large responsive cover, the panel 45%.
cc
The focus line used the cover's dominant hue (spectrum-primary), which
matched the atmosphere (built from the same cover) and blended in. Derive
a contrast accent instead: immersive::lyrics_accent_color takes the cover's
coverage-dominant hue and rotates it to the complement (+180deg), vivid and
mid-bright, with a teal fallback for B&W covers; exposed as
ImmersiveState.lyrics-accent and set on every cover decode.
cc
Extend the wgpu fragment-shader underlay from one scene to three.
shader_underlay builds a pipeline per scene (Vec, indexed by mode-1) over
the shared uniform feed; render_frame takes the active mode. Two original
clean-room WGSL scenes: tunnel.wgsl (neon polar-warped tunnel, zoom on
energy0, ring flare on transient) and aurora.wgsl (domain-warped curtains).
ImmersiveView gains a header scene picker (Off/Plasma/Tunnel/Aurora) and
the g key now cycles 0->1->2->3. WGSL is wgpu-compiled at runtime, so the
look needs a GUI smoke (cargo only checks the Rust/Slint).
cc
The 30Hz lyrics engine gated solely on ShellState.lyrics-open (the
sidebar), so the immersive Lyrics panels (focus + split) never advanced:
active-index/line-progress stayed frozen and the open-time kick() no-op'd
because it bailed at the same gate before computing. Widen the gate so the
engine also ticks while an immersive lyrics panel is active (focus mode 4
or split panel 0), read directly from ImmersiveState.
cc
Add ImmersiveSuggestionsPanel for the split-right slot (split-panel 2):
a cards row (up to 2 artist-playlist book-collage cards + a Song-Radio
diamond-collage card) and a recommended-tracks list. New suggestions.rs
controller assembles everything in Rust (ADR-006): artist tracks_appears_on
with an artist-tracks fallback under 5, dedupe by title, drop the current
track, shuffle, take 10; cards from the first two playlists plus a track
radio. New core method QbzCore::create_smart_track_radio mirrors the smart
artist radio (spawn_blocking + block_on, seed hoisted to index 0). Does NOT
use reco_store (live Qobuz queries only). Collage tilt deferred (flat for
now); blacklist drop happens downstream in play_tracks.
cc
Add ImmersiveTrackInfoPanel for the split-right slot (split-panel 1):
title + album, Duration/Quality/ISRC/Label-link metadata rows, grouped
credits and copyright, reusing MetaCell/CreditCell from TrackInfoModal
(now exported). A new load-without-open path (info_modals::
load_track_info_inline via TrackInfoActions.load-inline) populates
TrackInfoState without raising the floating modal, so the inline panel
fills in place; the data refreshes on now-playing track change. Header
gains a Track Info tab in the split rail (Lyrics, Track Info, Queue).
cc
Add ImmersiveQueuePanel, one component used both as a focus full-screen
queue (mode 5) and the split-right panel (split-panel 3). Queue/History
sub-tabs bound to QueueState.tab/set-tab; NOW PLAYING card + UP NEXT list
from the flat coverflow-tracks model (upcoming index = idx -
coverflow-index - 1 -> play-coverflow-upcoming), History from the
canonical history list. Header gains a Queue tab in both rails;
QueueState.panel-opened() fires on entry. Rows reuse the queue row
shape; scrolling via Flickable + shared ListScrollbar.
cc
Add ImmersiveState.view-mode (0 focus, 1 split) and split-panel; extend
the mode enum with Lyrics-focus (4). The immersive header gains a
focus/split toggle (the former inert panel-left slot) and a Lyrics tab
in each mode. New ImmersiveLyricsFocusPanel renders the giant centered
current line with a fadeInUp-lite entry; split mode reuses
LyricsLinesView at immersive sizing with the shared ImmersiveTrackInfo
block on the left. LyricsState.panel-opened() fires on lyrics entry so
fetch starts. Existing visualizer modes, atmosphere and the wgpu shader
underlay are gated on view-mode==0 and otherwise untouched.
cc
Reorder the Small now-playing bar's right control set to
Connect / Cast / Audio settings / Now-Playing view / Lyrics / Volume /
Queue, matching PlayerBar. Pure reorder; the leading AudioStamp stays.
cc
Port Tauri's AwardView/AwardAlbumsView to Slint: the award landing
(hero + acclaimed-releases preview grid + other-awards rail + follow
heart), the paginated albums listing with client-side search, and
clickable album-sidebar laurels that open the award page (resolving the
award id by name when /album/get omits it).
- AwardState/AwardActions globals + AwardEntry; AlbumState.awards now
carries award ids
- award controller (src/award.rs): /award/page hero (never page.releases),
/award/getAlbums grid, /award/explore other-awards + name->id resolver;
follow via the shared favorite_awards cache (plural-read/singular-write
favType, skip-if-remote, login seed)
- nav registration: NavEntry::Award/AwardAlbums, ContentView arms,
navigate_award/navigate_award_albums, AppShell mounts, app.slint exports
- ArtworkTarget::AwardAlbum/AwardOther
cc
The 36px accent-blue mic-vocal tile at the top of the lyrics sidebar was a
Tauri-era treatment that no longer matches the current QBZ header style.
Removed per owner request; the LYRICS overline + track meta stay.
cc
A wrapped lyric line (one logical line over two visual rows) lit BOTH rows at
once because the active line was a single word-wrap Text masked by one clip
Rectangle of full block height + scalar width. Now it progresses per visual
line — row 1 fills to 100% before row 2 starts — matching the final Tauri
behavior. NOT a Slint limitation (proven in the investigation doc).
- lyrics_measure.rs (new): swash advance pass over the active line at the same
font (by font-index) + ppem + wght=700 + 0.2px letter-spacing the view
renders with, greedy word-wrap (CJK per-char) -> per-visual-line segments
with pixel widths. Embeds the same TTFs the view registers. swash is the
shaper femtovg uses, so widths match the render.
- lyrics_sync.rs: on active-index / content-width / font / size / uppercase
change, segment the active line and push [LyricsSegment{text, start-weight,
width-weight}] (Tauri's width-weighted partition); pushed atomically with the
active index. Per-surface cache keys (main + mini). line-progress unchanged.
- state.slint: LyricsSegment struct + active-segments + content-width on
LyricsState.
- LyricsLinesView.slint: active row renders a VerticalLayout of per-segment
rows (no-wrap), each the existing dim-base + clipped-bright-overlay with clip
width = segwidth * clamp((line-progress - start-weight)/width-weight, 0, 1).
Root publishes content-width. Inactive rows unchanged.
- miniplayer.rs: mini lyrics surface segmentation fan-out.
Investigation + plan: qbz-nix-docs/lyrics/2026-06-18-karaoke-highlight-investigation/.
Owner-verified working. Known tuning point: wrap-boundary precision per font tier.
cc
Pre-existing dead code unrelated to cast; restores the zero-warnings policy.
Deleted (abandoned refactor leftovers): ModeTextToggle (LocalLibraryView.slint,
unreferenced), section_artwork_jobs + ArtworkTarget::Section variant + its match
arm (Discover-slice leftover; live render uses DiscoverSectionAlbum),
rename_folder (orphan DB wrapper).
Marked #[allow(dead_code)] with a "reserved, pending wiring" note (ported work
from other epics, not discarded): qconnect_service set_autoplay_mode_if_remote /
autoplay_load_tracks_if_remote / stop_if_remote (QConnect autoplay/stop API);
myqbz_play skip_to_next_item / skip_to_previous_item (Mixtape item-skip, spec 40).
cargo check -p qbz-slint: 0 warnings.
cc
- Lyrics auto-follow while casting: the local poll's cast-skip now runs BEFORE
clear_remote_anchor() (it was wiping the anchor every 450ms), and the cast
poll publishes its position via lyrics_sync::publish_remote_anchor each tick
(the 30Hz engine extrapolates between ticks, like the QConnect path). Anchor
cleared on disconnect so local lyrics resume.
- DLNA seekbar: many renderers report TrackDuration=0 in GetPositionInfo, which
left the bar permanently full (position/0 -> clamp 1.0) with no total time.
Fall back to the track's catalog duration when the renderer reports 0 (the
renderer's position is still authoritative). Chromecast reports a real
duration, so it's unaffected.
cc
The cast was slow because the resolver always did a full CMAF download
(~10s for a 190MB hi-res FLAC) and never read the cache — unlike the fast last
Tauri build, which resolved bytes cache-first.
- P1 cache-first: Player::fetch_for_external_stream now checks L1 (in-memory) ->
L2 (on-disk playback cache) before any download. The gapless prefetch already
warms the next 2 tracks at UltraHiRes while casting, so next/advance/replay are
now instant; only a cold track pays the download.
- Offline tier: CoreBridge::fetch_for_external_stream_resolved gains offline +
sink params and tries resolve_offline_bytes (local CMAF decrypt, no network)
before the network path — a downloaded track casts fast with no connection
(the same local-segments+decrypt path offline playback uses). Replaces the
hard "Qobuz cast needs a connection" gate (offline-cached tracks now work).
- P2 quality label: derived from the track's catalog metadata
(bit_depth/sample_rate/hires) in cast_track — no extra API call, consistent
whether bytes came from cache, offline, or network.
cargo check -p qbz-slint: clean.
cc
Smoke-test fixes after the first real Chromecast session:
- Local 450ms poll now skips the bar update while casting (it was pushing the
stopped local player's position=0/playing=false/local-volume every tick,
fighting the cast poll → seekbar flicker, wrong play icon, dead volume bar).
The cast poll owns the bar, mirroring the existing QConnect-controller skip.
- Cast poll + connection-state push now set NowPlayingState.playing (the
play/pause icon was stuck on play), and set_volume_if_cast pushes the slider
position (the local set_volume that normally moves it is skipped while casting).
- next/previous no longer use a cast-only path: that advanced the renderer +
core cursor but never refreshed the card/queue, and left the cursor desynced
so queue-click resolved the wrong track. They now run the local
next()/previous() flow (move cursor -> refresh card+queue -> play_audible
casts the new track), keeping UI + renderer in sync. Auto-advance on track end
uses the same local next() flow.
cc
rust_cast opens a rustls TLS channel to the Chromecast. In the full binary both
the aws-lc-rs and ring providers are enabled (reqwest pulls ring, rust_cast pulls
aws-lc-rs), so rustls can't auto-select one and the worker thread panicked with
"Could not automatically determine the process-level CryptoProvider" — surfacing
as "Connection error: Thread response error".
Add qbz_cast::ensure_crypto_provider() (installs aws-lc-rs once, idempotent) and
call it at the top of ChromecastHandle::connect and DlnaConnection::connect.
cc
- play_audible now gates Cast-first (before the QConnect peer route): when a
renderer is connected, a newly-played track is cast to it instead of starting
the local audio backend (no double audio). Mirrors the existing QConnect
play_on_peer_if_active chokepoint.
- Offline gate: casting a Qobuz track while offline is refused with a clear
message (the external-stream resolver is network/CMAF only; local files still
cast offline). Offline-cached Qobuz cast is a tracked follow-up.
Deferred (documented, non-blocking): continue-locally prompt on disconnect
(needs a small Slint modal); skipIfRemote UI gate (cast while this instance is
a QConnect remote — rare edge).
cargo check -p qbz-slint: clean.
cc
- CastPicker.slint: real modal (scrim + 400px card) replacing the static
PlayerBar/PlayerBarSmall cast-menu PopupWindow stubs. Two rectangular tabs
(Chromecast/DLNA, no pills, plain count) + per-tab device list from
CastState.devices, connect-on-click (DLNA rows gate on can-play), connected
banner with Disconnect + delivered-quality line (D3), scanning/empty states,
error line. Mounted last in AppShell (on top).
- PlayerBar + PlayerBarSmall cast buttons now open the modal
(CastState.picker-open = true; CastActions.open()) instead of the dead popup.
- Cast teardown wired: shutdown() on logout (fixes the Tauri connection + poll
leak #33) and best-effort on app exit (renderer Stop + media server, #32).
cargo check -p qbz-slint: clean (only the 6 pre-existing warnings).
cc
The Slint-side seam over the qbz-cast crate, modeled on qconnect_service.rs.
cast_service.rs (singleton):
- discovery (mDNS + SSDP) with a 2s device-refresh loop and 15s scan window;
- connect/disconnect: snapshot local playback, stop the local backend, suspend
QConnect (remember + restore), connect, then re-cast the current track
passing its REAL source (fixes the Tauri resume-source bug) + deferred seek;
- cast_track: source-routed (qobuz via the Phase-0 shared resolver at
UltraHiRes; local via register_file streaming + rich MIME; plex = TODO),
one shared lazy MediaServer, per-device source-IP URL, Chromecast auto-play
vs DLNA two-step load->play;
- 1s position poll mirroring position/duration/elapsed to the bar + CastState,
with the verbatim track-end heuristic (Chromecast ->IDLE, DLNA ->STOPPED/
NO_MEDIA_PRESENT) driving auto-advance;
- delivered-quality (D3) pushed to CastState; shutdown() for logout/exit.
state.slint: CastState global + CastActions + CastDevice (tagged record with
DLNA capability flags); re-exported in app.slint.
main.rs: init + CastActions wiring (open/close/refresh/connect/disconnect) and
transport gating — play/pause/next/previous/seek/volume now route Cast >
QConnect > local.
Compiles clean (only stop_if_cast/shutdown unused pending the stop callback +
logout/exit wiring, next).
cc
Additive, backward-compatible enhancements to the shared cast crate (the
Tauri shims are pure re-exports, so existing callers are unaffected).
- DLNA timeouts: pause/stop/seek/set_volume now run through a shared
run_action() with a 10s timeout (was none) so a hung renderer can't block
the caller forever. New DlnaError::Timeout / CastError::Timeout variants.
- DLNA mute: add set_mute / get_mute (RenderingControl SetMute/GetMute,
Master channel) — was missing alongside set_volume.
- DLNA capability detection is now version-agnostic (matches AVTransport:2/:3,
not only :1) so newer renderers report has_av_transport correctly.
- Media server endpoint auth: served path is now /audio/<token>/<id> with a
128-bit per-session token (RandomState-seeded, no new dep). Token lives in
the PATH, not a query string, so DLNA renderers that strip queries still
authenticate. Mismatch -> 403.
- register_file content-type parity: content_type_for_path now mirrors the
rich local-cast MIME map (audio/x-ape, mp3/ogg/opus/aac, m4a/alac->audio/mp4)
instead of the poorer table; add register_file_with_content_type so the
caller can supply an exact MIME. Local files stream from disk (no full-RAM
read) per decision D4.
Unit-tested: tokened-path parsing, rich MIME map, token randomness.
cc
Add a frontend-agnostic API to resolve a track's bytes + MIME + actually-
delivered quality for an external renderer (Chromecast / DLNA), so the Slint
Cast port does not need an improvised downloader (ADR-006).
- qbz-models: ExternalStreamAsset { bytes, content_type, quality, duration,
origin }, StreamQualityInfo (kHz-normalized via from_raw, handles the
kHz-vs-Hz endpoint ambiguity), AssetOrigin.
- qbz-qobuz/cmaf: download_full_with_quality(_progress) returns the resolved
format_id/sampling_rate/bit_depth alongside the FLAC bytes; download_full is
now a thin wrapper (no duplicated segment assembly).
- qbz-player: Player::fetch_for_external_stream — CMAF-first (FLAC, accurate
quality) with legacy stream-URL fallback; never reads the local playback
cache (casting attempts the best tier and must report the real delivered
quality); never serves an empty content type (external_content_type helper).
- qbz-core: CoreBridge::fetch_for_external_stream_resolved wrapper.
Decision D3 (attempt UltraHiRes first + surface delivered quality) is satisfied
by passing Quality::UltraHiRes and returning StreamQualityInfo. Unit-tested:
content-type fallback by format id, sampling-rate unit normalization.
cc
Port the Tauri MiniPlayer to a second Slint MiniPlayerWindow (not an
AppWindow morph). Borderless via the native no-frame property (Slint's
winit adapter re-applies decorations from no-frame on every realization,
so the attributes hook alone was undone); the vendored renderer gives the
transparent rounded floating card. First QBZ surface with no system
titlebar.
State is mirror-driven with no second poll loop: the existing 450ms
playback poll and the 30Hz lyrics tick each fan out one mirror call into
the mini's per-window globals (REQ-1), gating the expensive copies
(artwork / queue / lyric lines) on track-id change. The queue surface owns
a self-contained navigable model (current + full upcoming, not the 40-row
sidebar page), and per-row play routes remote-first and bails before
mutating local (REQ-2). Transport callbacks delegate to the main window's
already-wired remote-first handlers (zero duplication).
Surfaces: micro / compact / artwork / queue / lyrics (reuses
LyricsLinesView + LyricsState). Footer with pending-seek state, transport,
volume popover, and the hover-expand controls capsule (surface tabs +
background-blur toggle + move-drag + close). Optional static
artwork-derived background-blur backdrop. Lifecycle in src/miniplayer.rs
(create-once / enter / exit / close / per-surface geometry persistence /
winit hooks). Menu entry enabled in PlayerBar + PlayerBarSmall.
cc
Slint 1.16's femtovg-wgpu renderer configures the wgpu surface via
get_default_config() and leaves the composite alpha mode at Auto, which
resolves to an opaque surface on KWin/Wayland — a borderless transparent
window then shows a black rectangular backing behind the rounded card.
WGPUConfiguration exposes no surface/alpha hook, so the only fix is a
renderer patch.
Vendor i-slint-renderer-femtovg-1.16.1 with a preferred_alpha_mode() that
picks a supported non-opaque mode (PreMultiplied / PostMultiplied /
Inherit, guarded by the surface's advertised alpha_modes, else Auto) and
wire it into set_surface(). Pulled in via [patch.crates-io]; Slint stays
pinned =1.16.1. The main window is unaffected (it paints opaque).
cx
ListScrollbar gains an auto-hide mode: track and thumb fade in on list hover
or active scroll (900ms idle timer) and out otherwise. Discover and Home derive
hover from the global ShellState pointer over the flickable bounds; the
Blacklist manager swaps std ScrollView for Flickable + ListScrollbar driven by
a per-row hover counter. Resolves the hover-only scrollbar over album cards that
a TouchArea overlay would have broken.
cx
Drop to the winit backend (on_winit_window_event) to (1) route the browser
Back/Forward mouse buttons to navigation before Slint hands them to the
topmost TouchArea, so cards/sidebar rows no longer swallow them, and (2)
mirror the cursor position into ShellState (pointer-x/y/in-window) so passive
hover chrome can react without installing a TouchArea over interactive
content. This is the seam that unblocks the auto-hide scrollbars.
cx