Commit Graph
100 Commits
Author SHA1 Message Date
Victor RH fe2be63f0f Merge pull request #663 from fengalin/fix/external/qbzd-add-tls-provider
fix(qbzd): add tls provider
2026-07-26 16:42:35 -06:00
vicrodh 6fff4aae31 docs(readme): note QBZ has no built-in EQ/DSP by design (use EasyEffects/JACK)
cc
2026-07-24 01:14:19 -06:00
vicrodh 70aca326fd docs(readme): add the "what QBZ is and isn't" statement to Why QBZ
Aligns the README with the wiki and website: QBZ exists to give Linux users the
Qobuz client they deserve, and is explicitly not a web wrapper and not a
download tool. Keeps the technical native-pipeline explanation below it.
2026-07-23 23:42:48 -06:00
vicrodh e7aaeb33f9 chore: bump flake to 2.0.2
cc
2026-07-23 16:05:37 -06:00
vicrodh 93ae499f38 ci(release): install jack headers for the build-qbzd jobs
qbzd pulls qbz-audio whose jack-sys build script requires jack.pc; the
desktop build job already installs libjack-jackd2-dev, the new qbzd jobs
did not.

cc
2026-07-23 14:00:23 -06:00
vicrodh 0328a38b2f docs(readme): count Dutch among the bundled languages
cc
2026-07-23 13:09:47 -06:00
vicrodh d8f43cc4a3 docs(readme): qbzd and kiosk sections, drop diagram and star history
Promote the headless daemon to its own section with a screenshot and the
wiki link, add a Kiosk Mode section for Raspberry screens and handheld
consoles, remove the mermaid architecture diagram and the star-history
chart.

cc
2026-07-23 13:07:06 -06:00
vicrodh 116adbdd3e docs: fix TAG_DETAILS formatting for 2.0.2
Split the intro highlights into separate paragraphs, restore the em-dash
bullet pattern in the Casting entry, add the missing section separator
before What Won't Be Developed.

cc
2026-07-23 12:15:33 -06:00
vicrodh f434ed02cf merge: 2.0.2 final round — lyrics translation, Discover reco fixes, gestures/share/sandbox
Three owner-smoked feature/bugfix lines, validated together on
chore/internal/smoke-lyrics-discover (one build):

- feature/internal/lyrics-translation (15): Qobuz v10 lyrics translation
  (language param, translation-only doc merge, per-track toggle,
  bilingual lines, floating ghost-square toggle, language setting,
  account country/language capture, i18n x8, About +TerminalTilt).
- bugfix/internal/discover-reco-follow-state (11): reco follow-state
  seed+warm, followed/blacklisted/dismissed filter + cross-rail dedup +
  overflow backfill, Not interested (reco-scoped store + Blacklist
  settings tab), XDG deep-link navigation (argv + D-Bus OpenUrl).
- bugfix/internal/touch-gestures-share-url-sandbox (5): two-finger
  touchpad back/forward (#653), share links to open.qobuz.com for
  track/album (#514), Sandbox ReserveDevice1 note covers #652
  (device=all already in the published manifest).

km
2026-07-23 11:54:14 -06:00
vicrodh a31d3abf13 Merge branch 'bugfix/internal/touch-gestures-share-url-sandbox' into chore/internal/smoke-lyrics-discover 2026-07-23 09:35:09 -06:00
vicrodh 5b525b9e89 fix(settings): correct the #652 Sandbox row — device access is already granted
The published Flathub manifest has shipped --device=all since 2026-05-30
(flathub/com.blitzfc.qbz@11d38546), so /dev/snd IS exposed in the sandbox;
the device-override row added earlier on this branch was diagnosed against
the stale pre-repack local manifest clone and would have users grant what
they already have. The likely #652 mechanism is the ReserveDevice1 name
(qbz-audio device_reservation): PipeWire holds the DAC and Flatpak can't
own org.freedesktop.ReserveDevice1.* (Flathub linter blocks it), which is
exactly what the pre-existing Sandbox row fixes. Drop the device=all row
and make that row's note name the #652 symptom explicitly.

km
2026-07-23 09:35:09 -06:00
vicrodh 9db8bfd762 Merge branch 'bugfix/internal/touch-gestures-share-url-sandbox' into chore/internal/smoke-lyrics-discover 2026-07-23 09:23:32 -06:00
vicrodh cfa88098ee i18n(settings): translate the Flatpak ALSA device-access row (#652)
Hand-added the new Sandbox settings msgids (title + description) to all
8 locales; en msgstr = msgid. msgfmt --check passes on each.

km
2026-07-23 09:20:49 -06:00
vicrodh 3dca29fc62 feat(settings): document the Flatpak ALSA device-access grant (#652)
The Flathub manifest grants wayland/x11/pulseaudio/dri only, so /dev/snd
is not visible inside the sandbox: opening an ALSA hw:X,0 output fails
and exclusive mode silently falls back to PipeWire (other apps keep
mixing through the DAC). Per owner decision the manifest stays as-is
(Flathub reviewer friction); instead the Sandbox settings page gains a
third Flatpak row with the manual one-time grant
`flatpak override --user --device=all com.blitzfc.qbz`, following the
existing copyable-command-row pattern, and notes the required restart.

km
2026-07-23 09:20:49 -06:00
vicrodh 97adf3d3cf fix(share): use open.qobuz.com for track/album share links (#514)
The copied track and album links now use the canonical open.qobuz.com
form. Artist, label and playlist links are unchanged.

The builders also feed the Odesli (Song.link/Album.link) URL fallback;
that fallback never resolves Qobuz URLs anyway (every qobuz.com form
answers 400 could_not_resolve_entity, verified in-tree 2026-07-16) and
the working path stays ISRC/UPC -> Deezer, so the resolution machinery
is unaffected. open.qobuz.com is already accepted inbound by
qbz-qobuz's link_resolver.

km
2026-07-23 09:13:25 -06:00
vicrodh d6ed93280c feat(nav): two-finger touchpad swipe for back/forward history (#653)
Extend the AppShell bottom-layer TouchArea (the one already catching the
mouse back/forward buttons) with a scroll-event handler that accumulates
horizontal touchpad deltas and fires one NavState.request-back/forward
per gesture — the same callbacks the header nav buttons drive, so entry
swap, scroll restore and nav flags all follow the existing path.

Delivery is front-to-back, so a horizontal carousel Flickable consumes
horizontal deltas before they reach this layer (carousels keep scrolling
and never trigger navigation); a vertical-only page scroller rejects
horizontal-only deltas as an orthogonal direction, letting them fall
through to the gesture handler. The handler always returns reject, so
scroll routing is untouched everywhere. One navigation per gesture via
an idle-timeout/direction-flip reset and a fired latch; 70px threshold;
fingers-right = back / fingers-left = forward under libinput natural
scrolling (commented in code).

km
2026-07-23 09:10:28 -06:00
vicrodh e61318b286 Merge branch 'feature/internal/lyrics-translation' into chore/internal/smoke-lyrics-discover 2026-07-23 08:31:44 -06:00
vicrodh 6a7784366d feat(ui): restyle the lyrics translate toggle as a ghost square
Owner 2026-07-23: drop the mobile-parity circle — square, no border, no
shadow; bg lights up on hover, matching the Discover configurator gear
(GearButton in HomeView.slint). Glyph swapped to the owner-picked
translate-svgrepo icon (translate.svg; the Lucide languages.svg added
earlier on this branch is removed). Accent tint remains the ON state.

km
2026-07-23 08:31:20 -06:00
vicrodh d7a5c83c67 feat(lyrics): make the translation toggle per-track
Owner decision 2026-07-23: enabling translation applies only to the song
it was toggled on, in that playback — not a session/album-wide switch.
Every track change now resets the translation session and fetches
original-only (was: track-change requests rode the resolved target so
the translation followed across tracks). enable_translation/disable and
the fail-soft toast paths are unchanged.

km
2026-07-23 08:31:20 -06:00
vicrodh 7e40ff71db fix(deep-link): import ComponentHandle for Weak::as_weak
Static-review miss (qbz crate can't be check-built without the 30 GB
qbz-ui unit): as_weak() is a ComponentHandle trait method, so the trait
must be in scope. Found by the owner smoke build (E0599, qbz/src/
deep_link.rs:153) — the sole error in the crate.

km
2026-07-23 00:33:38 -06:00
vicrodh 3eb93ec4cb Merge branch 'bugfix/internal/discover-reco-follow-state' into chore/internal/smoke-lyrics-discover
# Conflicts:
#	crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po
#	crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po
2026-07-22 23:05:16 -06:00
vicrodh 3a6e5960ac Merge branch 'feature/internal/lyrics-translation' into chore/internal/smoke-lyrics-discover 2026-07-22 23:03:15 -06:00
vicrodh 299a7c3237 chore(packaging): drop dead x-scheme-handler/qbz registration
qbz:// resolves nowhere in the link resolver, so the registration
advertised a scheme the app never handles. Keep
x-scheme-handler/qobuzapp (handled by the resolver) in all 7 desktop
files.

km
2026-07-22 22:56:14 -06:00
vicrodh 537cabbb99 fix(deep-link): forward Qobuz links to the running instance over D-Bus
A second launch carrying a deep link exited after a bare Present(),
taking the URL with it — focus worked, navigation didn't. Add
OpenUrl(url) to the SingleInstance D-Bus interface: the primary stashes
the URL, presents itself, and dispatches through the same pending/drain
path as a cold-start link (stays pending while no shell is up).
acquire_or_raise calls OpenUrl when argv carried a link and falls back
to bare Present() when the method is missing on an older primary
(version skew), then to MPRIS Raise as before; no-link launches keep
plain Present().

km
2026-07-22 22:56:00 -06:00
vicrodh fd8f8f4064 fix(deep-link): navigate Qobuz links passed via argv on cold start
xdg-open https://open.qobuz.com/album/<id> raised/focused QBZ but never
navigated: main() never read argv, so the desktop files' Exec=qbz %u URL
was silently dropped (Tauri parity gap — the old app scanned argv on
cold start and in its single-instance callback).

New deep_link module: capture_argv() at the top of main() stashes the
first Qobuz-link argv entry (legacy is_qobuz_link prefixes) BEFORE the
single-instance guard runs — a second launch must read its own argv
before deciding to raise+exit. enter_shell binds a shell context
(runtime, window weak, tokio handle, image cache; cleared on logout) and
drains the pending URL at its very end, after the startup-page/view
restore, so the restore can't re-root over the deep link. Session is
active and the AppWindow exists there, so no readiness sleep hack.
Dispatch is the existing Ctrl+L machinery (link_resolver::resolve ->
apply_resolved_link); failures surface as toasts. At the login screen or
in offline mode the URL stays pending for the next successful online
enter_shell.

km
2026-07-22 22:55:47 -06:00
vicrodh c8e37f937d refactor(discover): borrow-free upsert in the reco-dismiss store
position() + index instead of an iter_mut().find() borrow held across a
match arm — no NLL subtlety, and the lifecycle test passes against the
verbatim file (scratch-crate run, since the qbz crate itself is not
built here).

km
2026-07-22 22:31:00 -06:00
vicrodh 2b0cf33d2e i18n(discover): Not interested + Recommendations-tab strings (8 locales)
New msgids hand-added to all 8 qbz-ui catalogs: the tab description, the
empty-state heading + hint, and the dismiss/undo toasts. "Not
interested" and "Recommendations" reuse the pre-existing msgids
(PlaylistView / Discover). en msgstr = msgid; es/de/fr/pt/ru/ja/nl
translated. msgfmt --check clean on all 8.

km
2026-07-22 22:25:01 -06:00
vicrodh 6adb33fcef feat(settings): Recommendations tab in the Blacklist Manager
Manage the reco-scoped Not-interested dismissals: a third tab in
Settings > Blacklist lists dismissed artists (search-filtered like the
other axes, count badge, empty + no-results states) with a per-row undo
that removes the entry from the reco_dismiss store — the artist becomes
eligible for the Recommendations rails again on their next paint. Row
click reuses the existing artist-select navigation. No bulk Clear-All on
this tab (undo is per-row); the modal stays scoped to the blacklist axes.

km
2026-07-22 22:18:59 -06:00
vicrodh a181fb7aa9 feat(discover): Not interested menu action with live removal + backfill
ArtistGridCard's context menu gains a 4th item (Not interested,
thumbs-down icon; popup height bumped for the extra row) that emits
("artist", id, "not-interested") through the existing media-action
chain. The new central arm persists the dismissal to the reco_dismiss
store and, when the artist is in a reco rail, removes the card live and
backfills the freed slot from the rail's retained overflow (already
Qobuz-validated — no network; the pop re-checks live exclusions and
cross-rail visibility). Dismissed from a non-reco surface, the display
name is resolved via get_artist before persisting, so the management
list and toast still read well.

km
2026-07-22 22:11:58 -06:00
vicrodh 465f8627eb feat(discover): filter, cross-rail dedup, and overflow backfill for reco rails
The two Recommended-Artist rails showed artists the user already follows
or blacklisted, and the same artist could appear in both rails at once.

Builders now retain the full validated pool (rotated, not truncated at
DISPLAY_CAP); the pools ride the results blob as before, so old blobs
simply have no overflow until they expire. A new pure compose step in
qbz-external-reco (unit-tested: exclusion, within/cross-rail dedup with
common-wins ordering, visible/overflow split order) runs at the single
paint choke point in external_reco.rs — covering BOTH the fresh build
and the cached-blob paint. Exclusions fold followed (fav_cache) +
blacklisted + reco-dismissed ids, each source independently fail-open.
The past-cap survivors are retained as overflow for the Not-interested
live backfill (no extra network).

km
2026-07-22 22:08:38 -06:00
vicrodh 2c162b6925 feat(discover): per-user reco-dismissal store for Not interested
Reco-scoped dismissal (NOT the app-wide blacklist): a dismissed artist
only leaves the Recommendations rails and stays visible everywhere else.
One small JSON store per user (the light discovery_dismiss precedent)
bound at session activation next to fav_cache (login / restore /
offline) and dropped on logout. Fail-open: unbound or corrupt file reads
empty and mutations no-op. Unit-tested lifecycle (roundtrip, idempotent
dismiss with snapshot backfill, re-bind persistence, undo, junk/unknown
format tolerance).

km
2026-07-22 22:02:25 -06:00
vicrodh da3a5553e0 fix(discover): seed artist follow state in the Recommendations rails
slim_from_artist hardcoded following: false, so reco artist cards never
reflected the current follow state at build time (the pinned carousel
already seeded from fav_cache). Seed from fav_cache::is_artist_favorite
and extend the shell-entry favorite-artists warm walk to the three
ExternalRecoState artist models, in place (no model swap, no flicker).
Live toggles were already propagated by search::mark_artist_followed.

km
2026-07-22 21:59:26 -06:00
vicrodh accdef5a02 fix(qobuz): merge the translation-only v10 lyrics document with the original
Live wire (verified 2026-07-22, track 266725027 with language=es): when a
translation is requested and available, lyrics_url points at the TRANSLATED
document (<id>-<lang>.json) carrying ONLY 'translation' — no 'original' —
and 'translation_requested' is absent. get_lyrics rejected such documents
('without original content -> no lyrics'), so every translation toggle
ended in the unavailable toast. Now get_lyrics fetches the original-only
document in a second round trip and merges both into one bilingual doc
(merge_translation_into); requested-but-unavailable still 403s the CDN
URL -> typed miss (fail soft).

km
2026-07-22 21:34:39 -06:00
vicrodh 9cc7307616 feat(about): add TerminalTilt to the contributors list
Owner request; rides the lyrics-translation branch. Appended after the
existing Slint-era external-PR contributors.

km
2026-07-22 20:00:03 -06:00
vicrodh f86b0f7386 i18n: lyrics translation strings in all 8 locales
New msgids: Show/Hide lyrics translation (toggle tooltips), Translation
language + Auto (account language) (flyout row), and the fail-soft toast
"Translation is not available right now. Please try again later.".
Register-matched per catalog (de/nl informal, fr/pt formal); en follows
its msgstr = msgid convention. msgfmt --check clean on all eight.

km
2026-07-22 20:00:03 -06:00
vicrodh b4ead674d8 feat(qbz): wire the lyrics translation toggle end to end
The commit push now carries translation-available (doc translation_langs
non-empty) and per-line translation-text, aligned 1:1 with the rendered
lines (the core filters translation lines like the originals; a missing
entry degrades to empty). Toggle ON resolves the target (explicit setting,
else Auto: account language_code -> UI locale) and refetches the current
track with LyricsRequest.language set — show-translation flips only when
the response actually embeds a translation; any gap toasts the Android-
parity error string and reverts, never putting the lyrics view in an
error state. Toggle OFF is pure UI (no network). While ON, track-change
requests ride the resolved target so the translation follows across
tracks; prefetch stays original-only. A doc that already carries the
target's translation flips locally without a refetch. The miniplayer
mirrors show-translation so the mini lyrics surface follows the sidebar.

km
2026-07-22 19:59:47 -06:00
vicrodh 7b447fb7e4 feat(qobuz): expose the account language code on the client
Read-only accessor over the session lock — the lyrics translation "Auto"
resolution reads it through the shared client handle. None pre-login and
for pre-v10 persisted sessions.

km
2026-07-22 19:59:47 -06:00
vicrodh b1b64f2dfb feat(ui): translation language setting in the lyrics flyout
New QbzSelect row (Auto (account language) + en es fr de it pt nl ja ru,
the owner-approved list) after the Size row; language names are native
literals, the same convention as the Appearance dropdown. The options
array lives on LyricsState (reseeded from Rust on language change) and
the choice persists per-user in lyrics_prefs.json as translation_language
("auto" or an ISO 639-1 code), sanitized on load (unknown -> auto) and
round-tripped through apply_to_ui/persist_from_ui like the other prefs.
Flyout popup height grows by the new row (and the comment now counts the
lite-fill row it had missed).

km
2026-07-22 19:59:27 -06:00
vicrodh 2b4c8c5f57 feat(ui): floating translate toggle in the lyrics sidebar
Circular button anchored bottom-right of the lyrics body, overlaying it —
mobile parity (owner decision 2026-07-22: floating, NOT in the header,
NOT in the flyout). Mounted only while LyricsState.translation-available,
so a doc without translations renders the sidebar exactly as before.
Off = dim, on = accent; tooltips via the shared TooltipState channel.
The glyph is Lucide's languages icon, matching the bundled icon set.

km
2026-07-22 19:59:07 -06:00
vicrodh 499f652b71 feat(ui): carry lyrics translation state and render bilingual lines
LyricsLineItem gains translation-text (empty when the doc has none) and
LyricsState the translation props (translation-available, show-translation,
toggle-translation callback) per spec section C. LyricsLinesView renders
the translation under its original line while show-translation is on —
smaller (~72%) and dimmer, static text that follows the line; the karaoke
fill stays keyed on the original timings/text. Secondary surfaces share
the model, so the field flows through untouched.

km
2026-07-22 19:58:35 -06:00
vicrodh 619f6d5abc feat(qbz): thread the translation language through the lyrics glue
SharedClientProviders implements the new language-aware LyricsProviders
boundary; both track-change and prefetch requests pass None (the default
original-only flow per spec section B.1) — the refetch-with-language is
triggered by the UI layer in a follow-up task. The committed LyricsDoc
now carries the translation into CURRENT_DOC automatically, so the sync
engine and the future UI toggle read the same document. qbz-core's
headless get_lyrics wrapper keeps its signature and fetches
original-only.

km
2026-07-22 19:12:22 -06:00
vicrodh 378afb9c9a feat(lyrics): map and cache embedded translations
LyricsRequest carries an optional translation target that threads
through the LyricsProviders::qobuz boundary to the client; the external
fallback chain is untouched. LyricsDoc gains translation
(Option<Box<TranslatedLyrics>> with a LyricsKind dispatch mirroring the
Android v10 mapper: wsync->WordSynced, lsync->LineSynced, plain->Plain)
plus a client-derived has_translation (requested lang in
translation_langs, false when none requested). QobuzWsync stores the
translation verbatim in the existing cache JSON (serde default, no DB
migration) with its lang recorded: a synced cache hit only serves a
language request when the stored translation matches it, otherwise the
chain refetches; original-only requests keep today's behavior.

km
2026-07-22 19:12:07 -06:00
vicrodh 8c2dfd5727 feat(models): capture account country and language on login
parse_login_response now reads user.country_code and user.language_code
(snake_case wire names, verified against Qobuz's own embedded login
fixture) into two new serde-defaulted Option fields on UserSession, so
pre-v10 persisted sessions still load with both as None (translation
stays Auto-off). qbzd's test construction updated for the new fields.

km
2026-07-22 19:11:52 -06:00
vicrodh 072223078e feat(qobuz): send the language param on track/lyricsUrl
get_lyrics_url_raw/get_lyrics_url/get_lyrics take an optional ISO 639-1
language, appended to the signed param list only when Some (the existing
sign_request sort covers it). None keeps the original-only default flow.
When a translation was requested but the document does not embed one,
the envelope's translation_requested.lyrics_url is fetched as a fallback
translation source (spec section B.3); every gap degrades to
original-only. lyrics_probe gains --lang=<iso639-1> for parity.

km
2026-07-22 19:11:41 -06:00
vicrodh 9d7f9c6c17 feat(qobuz): parse the v10 embedded lyrics translation block
QobuzLyricsDocument gains the optional translation member (same content
union as original, new in API 10.0), the content DTOs become
Serialize+PartialEq so the lyrics cache can persist a translation
verbatim, and a lang() accessor exposes the content language. New
fixtures cover embedded translations of each type (wsync/lsync/plain),
the lyricsUrl envelope with translation_requested, empty
translation_langs (feature off), and 9.x documents (no translation).

km
2026-07-22 19:11:28 -06:00
vicrodh 26baeb0ffc docs(release): cover the post-bump commits in the 2.0.2 changelog
Integrates the 30 commits that landed after the version bump: Most Played
Albums, album-identity mode, multi-artist header, cross-tag scan (#447,
#507), local album play-next/queue (#636), Plex rating-key resolution,
ALSA unstick, gapless-late resume, DSD/hi-res DLNA (#646), media-server
token redaction, label web parity, macOS chrome/NPB work, qbzd/Connect
hardening, apt trigger (#648) and the flatpak twin talk-name drop. Also
includes the owner's latest TAG_DETAILS touch-ups.

km
2026-07-22 18:23:10 -06:00
vicrodh 6c6eaf2b36 merge: Most Played Albums (feature/most-played-albums)
Play-count album history store (album_play_history, WAL per ADR-002) hooked
into record_recent, Most Played Albums carousel on Home + For You (default
off), View-all page with search + per-card play count, i18n in all 8
locales.

Conflicts resolved against pre-release: kept the Slint-era Pinned section
alongside the new MostPlayedAlbums everywhere (DiscoverySectionId, prefs,
render kinds, nav, ContentView, HomeActions); AlbumCardItem carries both
is_pinned and plays (populated via card_to_item); .po files union-merged,
msgfmt clean; discover_prefs tests updated (home 15/8 ON, forYou 14/13 ON)
— 10/10 green. Release build green.

km
2026-07-22 17:46:09 -06:00
vicrodh cb10d42b53 chore(flatpak): drop the org.freedesktop.secrets talk-name from the twin
Parity with the published Flathub manifest (repack flow, PR #49), which
already ships without it. The Flathub repo manifest is untouched.

km
2026-07-22 16:53:54 -06:00
vicrodh 0d58e1d0c3 docs(release): revise 2.0.2 tag details intro and highlights
km
2026-07-22 16:49:24 -06:00
vicrodh 03ac0a7164 fix(cast): redact media path tokens in media server request logs
Session tokens in /audio/<token>/<id> must not appear in log bundles. Port
of the remaining delta of 9aab6233 (pr/605) onto current pre-release: add
redact_media_uri() to media_server.rs and redact the URL in the three
handle_request logs (request, 404 parse, 403 token mismatch). The device.rs
side and the CSPRNG tokens already landed via PR #586.

km
2026-07-22 16:49:23 -06:00
vicrodh c49b9b52a0 fix(macos): reach NSWindow through the content NSView for traffic lights
raw-window-handle 0.6 exposes only the content view; dereference the NSView
and get the NSWindow via -[NSView window] instead of reading a non-existent
ns_window handle.

km
2026-07-22 16:49:23 -06:00
vicrodh f9a8edcbeb fix(shell): macOS NPB-Small bottom clearance via Rust-set npb-small-extra
The 2px .slint literal proved invisible (owner ask 2026-07-21, iteration
2026-07-22). The extra clearance is now set from Rust at startup
(ShellState.npb-small-extra, default 6px, macOS-only, QBZ_NPB_SMALL_EXTRA
env override) so tuning costs a ~2-minute qbz-bin rebuild instead of a
~2 h qbz-ui one on the 8 GB Mac. Linux stays pixel-identical (extra = 0).

km
2026-07-22 16:49:23 -06:00
vicrodh 6c5996cc50 fix(library): single-album play-next/queue on the local albums surface (#636)
The AlbumCard context menu on local albums (LocalLibrary grids, folder or
Plex source) routed play-next/queue to LocalLibraryActions.album-action,
whose handler only implemented play / toggle-select / favorite — the two
queue actions fell into a catch-all that logged "queue slice pending" and
did nothing.

Resolve the album's tracks with the same source-aware resolver play uses
(local_library::fetch_album_tracks_blocking — local folders AND Plex) and
enqueue them via playback::enqueue_local_tracks, mirroring the already-
working albums bulk-action arm. Track-level play-next/queue was never
affected. Port of d0f67149.

km
2026-07-22 16:49:10 -06:00
vicrodh ee37d65f40 fix(library): read album/album-artist/date across all tags at scan (#447, #507)
Port of 0c3b38f8 (bugfix/internal/local-library-tag-fallback) onto current
pre-release. 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 albums, folder-level
  grouping, MIN(year) across mixed folders).
- #507: the missed Album Artist left album_artist NULL, degenerating every
  COALESCE(album_artist, artist) to the per-track artist (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. Tests adapted to the AlbumGroupMode signature; 75/75 pass. A full
library rescan picks the fix up (no migration).

km
2026-07-22 16:49:10 -06:00
vicrodh b5c1a76e81 fix(playback): resolve Plex rating key from queue id when the hint is the album key
The MyQBZ collections path stamps the per-item ALBUM boundary key
(plex:<hash>, for album-shuffle boundary detection) in source_item_id_hint,
but the Plex play path used that hint verbatim as the track rating key:
/library/metadata/plex:<hash> -> 404, the Plex track never started, and the
previous track kept playing while the UI showed the new one (continuous
playback Qobuz -> Plex broken, audio/UI desync).

Ignore any plex:-prefixed hint and fall back to the numeric queue id (=
rating key for the common numeric-key case). The LocalLibrary path (hint =
raw rating key, no prefix) is unchanged.

km
2026-07-22 16:48:56 -06:00
vicrodh e822c21c8b chore(scripts): add aarch64 qbzd cross-build script
Docker/cross build of the slint-free daemon for the Pi streamer
(~90 s incremental, dist/qbzd-aarch64-linux); native path included for
aarch64 hosts.

km
2026-07-22 09:04:23 -06:00
vicrodh 6f7edb19db feat(qbzd): spawn-and-ack for load-bearing playback routes
The API serve loop is single-threaded; an inline block_on of the
load leg (next/previous/jump/play/cold-start) starved every route for
the whole fetch — clients read 'daemon not reachable' while the action
DID execute. The resolve+play leg now runs on the tokio runtime and
the routes ACK immediately; cheap gates (auth, 404, empty queue) stay
synchronous with their documented errors, and load failures latch into
last_errors.stream (visible via status). next/previous answer
{"queued":true,"direction":...} (CLI renders '-> queued (next)');
play/toggle cold-start report {"state":"loading"}; jump/play keep
their response shape. Live-verified: next returns in ~20 ms and status
answers during a load.

km
2026-07-22 09:04:23 -06:00
vicrodh da7964f6c3 fix(player): resume playback-state tracking when gapless lands late
When a track ends before its gapless prefetch completes, 'track
finished (engine empty)' sets thread_state.is_playing=false, and the
late PlayNext still plays (the engine's append re-opens the writer
gate) but nothing restores the player-level flag: the position monitor
freezes, no next gapless arm, no QConnect reports, the queue cursor
stays on the finished track, and playback dies silently at the next
end. The PlayNext handler now captures engine.empty() before appending
and restores is_playing when the append landed on an empty engine —
a user-paused engine still holds its source (not empty), so manual
pause is never resumed by this.

km
2026-07-22 09:04:05 -06:00
vicrodh 9fad1dc123 fix(audio): unstick ALSA direct stop, drain and EBADFD write recovery
Three transition killers on drivers that require an explicit drop
(snd-rpi-hifiberry/PCM5122; the desktop's USB DAC tolerated all three,
which is why only the Pi died):

1. stop() called prepare() without drop(): prepare from
   RUNNING/DRAINING returns EBUSY there — every stop failed and left
   the pcm in limbo. Now drop+prepare (drop EBADFD tolerated).
2. A write racing a still-draining pcm (natural end + late gapless
   append) gets EBADFD, and snd_pcm_recover(77) is unsupported on this
   stack, so the write died and the writer thread exited. New
   recover_write_error(): normal recover first, then drop+prepare for
   EBADFD (11 write sites via one helper).
3. snd_pcm_drain can block forever when the device stops clocking —
   the writer wedged and no 'engine empty' ever fired. drain() is now
   bounded: poll pcm.state() with a 10s deadline; the tail finishing
   shows as XRUN (drop+prepare), and a stuck device is cancelled.

km
2026-07-22 09:04:05 -06:00
vicrodh b103f2f971 fix(qbzd): report device_present when the device is open
CPAL enumerates descriptions, never hw:CARD=... ids, so a playing
direct-hw stream read as 'not present'. device_present is now
device_open || enumeration-match.

km
2026-07-22 09:03:43 -06:00
vicrodh f51eb6dc51 fix(qbzd): publish daemon-originated queues to the Connect cloud
The daemon was queue receive-only: a CLI/TUI/MPRIS/restored queue never
reached the cloud, so controllers rendered a different queue than the
one playing. New qconnect/publish.rs ports the desktop's
sync_local_queue_if_changed with its full gate set (active-renderer,
offline-only, echo-suppress vs last-applied, per-session pushed-ids
latch, all-or-nothing admission), triggered by a debounced
CoreEvent::QueueUpdated subscriber — which also covers queue edits
while paused/stopped, unlike a transition-only hook. Live-verified: a
CLI queue move produced exactly one QueueLoadTracks push (233 tracks)
and the cloud echo was suppressed.

km
2026-07-22 09:03:43 -06:00
vicrodh 48eaebef1d fix(qconnect): bounded CMAF fallback when the CDN header-floods a track
Akamai answers small raw-url objects with ~106 headers, over hyper's
hard-coded 100-header h1 cap, so every reqwest fetch of those tracks
failed identically (streaming probe AND full download) and the track
never played — with the cause hidden by reqwest's Display. Now:
describe_reqwest_error() walks the source chain for logs; on the
header-flood signature the engine skips the doomed reqwest re-download
and falls back to play_track_resolved (CMAF, unaffected) plus an
explicit sync_current_to_id so the queue cursor follows the audio.
Any other failure keeps the previous behavior. Live-verified on the Pi
(DSOTM track 1 plays; gapless continues).

km
2026-07-22 09:03:28 -06:00
vicrodh 46b9cd7a3b fix(qconnect): announce the persisted device name from every identity path
With a custom name set, the controller bootstrap announced it but the
deferred renderer join and resolve_local_identity used the DEFAULT name —
one device_uuid, two friendly names. When the server's ADD_RENDERER omits
the uuid (observed on the wire), the name-fingerprint self-match failed,
local_renderer_id stayed None and the is_local_renderer_active gate
dropped every renderer report (mute renderer, frozen controller seekbar).
default_qconnect_device_info() now resolves the persisted name on both
frontends, so all announce paths agree by construction. Wire-verified:
ADD_RENDERER carries the custom name and reports flow.

km
2026-07-22 09:03:28 -06:00
vicrodh 47506cb5b7 fix(credentials): derive the daemon token key without the XDG portal secret
The token encryption key mixed in the XDG portal secret (session-bus
dependent): a login run in a graphical session wrote a token an
init-started/headless qbzd could never decrypt, reported as a bare
"not logged in". PortalKey policy: desktop root keeps Session, daemon
root uses Never; one-shot migration on read (decrypt with Session,
rewrite portal-free); oauth_token_file_present_at() +
latch_undecryptable_token() so status distinguishes "no token" from
"token I cannot decrypt". Live-verified 3x (OpenRC, headless Pi,
migration path).

km
2026-07-22 09:03:07 -06:00
vicrodh f18960ba54 feat(library): collapsable multi-artist header on local album detail
Spec 2026-07-19-local-album-grouping-mode (B2). A compilation album (the
Saint Seiya case: 10 distinct track artists) now shows the single artist
line plus a '+N more artists' chevron that expands into the full
track-artist list, each entry clickable into the LocalLibrary Artists tab
(name-based open-artist). Distinct raw track artists in first-appearance
order, computed in apply_album_version into LocalAlbumState.all-artists.
Expansion is view-local and resets on album change; single-artist albums
never see the chevron. Track rows already showed their own artist
(verified, no change needed).

2 msgids ('+{} more artists', 'Show less') x 8 locales; slintcheck green.

km
2026-07-21 14:11:04 -06:00
vicrodh c72e71d410 feat(library): album-identity mode — header dropdown, Settings row, persistence
Spec 2026-07-19-local-album-grouping-mode (A2-A5 + i18n). The Albums tab
gains a third header dropdown ('Albums by folder' / 'Albums by metadata')
and Settings > Local Library a matching 'Album grouping' row — both write
the same pref: LocalLibraryState.albums-id-mode, persisted in
locallibrary_ui.json (default 'folder': compilations and box sets stay
whole; the metadata split stays opt-in for #411's use case).

The group key changes with the mode, so switching reloads the Albums set
(reload_albums) and invalidates the Artists tab (its album cache groups
the same way — a folder-mode compilation cross-lists under every artist
in all_artists, the owner's 'appears in all artists' ask). Threaded
through spawn_albums_load, seed_counts, the Plex quality-hydration
aggregate lookup, and the artists feed; myqbz_builder pins Metadata
(artist-scoped candidates, not the Albums view).

5 msgids x 8 locales (msgfmt clean); slintcheck green.

km
2026-07-21 14:11:04 -06:00
vicrodh 77e41caa2f feat(library): selectable album-identity mode query layer + root-artist clamp
Spec 2026-07-19-local-album-grouping-mode (A1 + C + D).

AlbumGroupMode { Metadata, Folder }: the Albums view can now group by the
album|artist metadata pair (today's #411 behavior) OR by the album-root
folder — a compilation with 10 track artists renders as ONE card, and
multi-disc boxes merge even when discs are tagged separately. Read-time
switch: no schema change, no rescan. Threaded through
get_albums_metadata_page / count / get_albums_metadata_grouped; the
cover-art lookup matches both key shapes. Title/artist derivation is
mode-agnostic (album tag preferred, album_group_title fallback, per-row
COALESCE(album_artist, artist) + COUNT(DISTINCT track_artist) > 1 ->
'Various Artists').

Untagged-root clamp (C): infer_artist_album no longer returns the library
ROOT's name as the artist when the album dir hangs directly off a root
(a tagless DSD set surfaced with artist 'Music'). extract_with_roots
threads the scan targets; plain extract keeps the legacy inference for
ephemeral/single-file paths.

Tests (66/66): folder mode compilation = 1 album + VA + all_artists,
album_artist respected, orphan bucket preserved, metadata split unchanged;
root-clamp cases incl. disc subfolders, 'Artist - Album' split, real
artist folder named like the root, and the no-roots legacy path.

km
2026-07-21 12:56:20 -06:00
vicrodh d4a4f30e91 feat(label): web-parity header actions, catalog/library toggle, label cards
LabelView parity with the revamped Qobuz web label page (semantics
verified live against play.qobuz.com via Playwright):

- Header: Follow + Shuffle (new play_label_top_shuffled, shuffle-then-
  delegate to play_tracks_ctx) + overflow menu (Play next / Add to queue /
  Share -> play.qobuz.com/label/<id>; no Song.link equivalent for labels).
  No header Play by owner decision (the Popular Tracks play does the same).
- The Popular Tracks section '...' button was dead (fired an unhandled
  'top-tracks-menu' action): now a popup with Play next / Add to queue
  over all cached popular tracks. Section buttons wrapped so the 44px
  primary and 32px secondaries share one vertical centre.
- Catalog/library toggle: the ArtistPage SegmentedTabBar verbatim, floated
  right of the header cluster (visible only with library items; mirrors
  the web's A/B-rolled 'Del catalogo / En la biblioteca'). Library tab
  renders added tracks + albums on the label, backed by the new
  library_by_label session index (AlbumCard.label_id / TrackCard.label_id,
  seeded from the SAME two favorites fetches as library_by_artist).
- More Labels carousel now renders the Library LabelCard (new label-cards
  mode in ArtistCarousel) instead of the legacy generic card.
- Artists carousel images: /label/page top_artists often carry no parseable
  image, leaving placeholders forever. Restored the Tauri fallback wave:
  custom artwork first, then v2/artist/get, fed through the stable
  LabelArtist artwork targets.

i18n: zero new msgids (all reused across the 8 locales). slintcheck green.

km
2026-07-21 12:30:40 -06:00
vicrodh 70d8dc85f2 feat(macos): center traffic lights in the header + NPB-Small +2px
With the custom chrome (titlebar_transparent + fullsize_content_view)
AppKit parks the native traffic lights at the stock ~28pt titlebar height,
visibly above the 42px header controls. macos_chrome.rs measures the close
button's centre in window coordinates and shifts the shared button
container by the delta to the 21pt header centre (custom chrome only,
one-shot after show; known gap: AppKit re-lays the titlebar on fullscreen
roundtrips). Deps: raw-window-handle direct + slint raw-window-handle-06 +
objc2-app-kit NSWindow feature, all macOS-target only.

NPB-Small on macOS gains 2px of bar height (ShellState.npb-small-height,
44px, growing upward from the bottom-anchored bar) so CJK/descender-heavy
meta lines stop clipping at the window's bottom edge. PlayerBarSmall pins
the song card to its old 39px top-anchored geometry: the art keeps its
exact top-relative position and the 2px land as bottom clearance. Linux
geometry is pixel-identical to before. slintcheck stub green.

km
2026-07-21 12:30:19 -06:00
vicrodh c7070241a4 ci(apt): trigger the apt repo update from the aarch64 workflow too (#648)
The x86_64 workflow fires the repository-dispatch minutes after its own
publish, while the native arm64 build is still running — so qbz-apt's
update-repo downloaded the Release assets when only the amd64 .deb existed.
The arm64 .deb landed hours later with nothing re-indexing, leaving the
apt arm64 tree stuck at 1.2.15 (verified: QBZ_*_arm64.deb IS on the 2.0.x
releases). update-repo is idempotent (re-downloads every *.deb, rebuilds
both indices), so the second dispatch completes whichever arch the first
one missed, in either finish order.

km
2026-07-21 12:29:26 -06:00
vicrodh 722ca933ef fix(cast): serve DSD and hi-res PCM to DLNA renderers (#646)
DSD containers (.dsf/.dff) fell through to application/octet-stream in the
media-server MIME map, which strict renderers reject (AVTransport 714), and
the serve path read the WHOLE requested range into RAM before emitting a
single header byte — a 32/384 PCM or DSD track (0.7-1.4 GB) stalled the
response long enough for renderer fetch timeouts and blocked the
single-threaded server for every other request (the reported 'had to
restart QBZ' wedge).

- content_type_for_path + the cast_service UI mirror gain audio/x-dsf /
  audio/x-dff (de-facto DLNA MIMEs; HQPlayer plays native DSF/DFF).
- open_range streams file entries straight from disk (seek + bounded Take);
  headers go out immediately and RAM stays bounded. In-memory (Qobuz)
  entries keep the bounded slice copy. 0-byte entries keep the old 500.
- DSD-aware cast quality label (was the nonsense '1-bit / 2822.4 kHz').
- Best-effort renderer stop() when DLNA load/play fails so it doesn't sit
  half-loaded on a faulted URI.

cargo check + 7/7 qbz-cast tests (new streaming + MIME coverage).

km
2026-07-21 12:29:11 -06:00
vicrodh b4b1f38cf0 feat(whats-new): render standalone release-note links as clickable
The What's New / release-notes modal renders the GitHub release body through a
custom block model that stripped inline markup and had no link support, so a
markdown link showed as literal `[text](url)`. Now:
- inline links `[text](url)` collapse to just their text (clean attributions),
- a whole-line link becomes a KIND_LINK block rendered as a clickable accent
  link opened in the browser (a new WhatsNewActions.open-url reusing open::that,
  mirroring about.rs).

Adds a `url` field to WhatsNewBlock plus a small markdown-link parser. The 2.0.2
notes use it for the daemon-wiki link. Built + smoked at release (window ready,
ALSA 192kHz bit-perfect, playback OK).

cc
2026-07-20 21:43:02 -06:00
vicrodh f9713591fe chore: bump version to 2.0.2
Workspace version 2.0.1 -> 2.0.2 (Cargo.toml + lock, workspace crates only —
no dependency re-resolution), a new metainfo <release> entry, and the release
docs: TAG_DETAILS.md (human-facing, About-modal/website format, 2.0.2 section
prepended above 2.0.1/2.0.0) + CHANGELOG.md (full detail since v2.0.1 — the
headless daemon, kiosk mode, dynamic background, cast/quality #638, audio,
rendering #617, library, themes and security work). Codename inherited on the
patch line; About-modal literal untouched.

cc
2026-07-20 19:49:09 -06:00
vicrodh 1c48bd6224 fix(qbz-app): add mpris_enabled to the daemon_prefs roundtrip test literal
The `playback.mpris` field added to DaemonPrefs broke the qbz-app roundtrip test
(a `DaemonPrefs { .. }` literal missing the new field) — a compile error the
`test-crates` CI caught but `cargo test -p qbzd` didn't (it doesn't compile
qbz-app's test module). Add the field with a non-default value and assert it
survives the save/load roundtrip. Full non-UI workspace suite green (67 ok/0
failed) via scripts/cargo-test.sh.

cc
2026-07-20 18:53:33 -06:00
vicrodh cdad220cf9 merge: qbzd CONSOLE expansion + service generators into pre-release
Integrates feature/qbzd-console (21 commits): the full headless-daemon CLI
parity surface (search/play/browse/discover/radio/reco/favorites/playlists/
queue/shuffle/repeat/lyrics/art/resolve), Last.fm/ListenBrainz scrobbling
(setup-TUI + CLI + scrobble-on-play + offline queue), MPRIS media controls
(TUI-toggleable, default on), the /api/events SSE stream + `qbzd watch`, GET
/api/artwork/current, and an init service-file generator (systemd/openrc/runit).

qbzd is a pure consumer of the audio crates: verified it still checks and passes
all 287 tests against pre-release's ALSA/DacCapabilities/models/player changes,
so the merge is textually and semantically clean (no conflict resolution needed).

cc
2026-07-20 18:38:31 -06:00
vicrodh 57cb07e07d fix(ui): size the hover tooltip to its content so multi-line fits
The bubble had a hardcoded 24px height, so the #638 Source/Output/cause tooltip clipped to its middle line. Height now follows the content: multi-line fits, a one-line tooltip stays compact.

cc
2026-07-20 18:29:04 -06:00
vicrodh 9dab943f4c fix(ui): keep the track-row play glyph legible on light themes
The no-art (number) variant drew a hardcoded #ffffff glyph with no backing, so it vanished on light themes (white-on-white). It now sits on a themed circular backing (polarity-baked alpha fill/border + shadow) and tints with Theme.text-primary; the row hover uses the themed alpha ramp too. Zebra left as-is to avoid a dark-theme shift. ADR-008 compliant (icon-only circle, not a pill).

cc
2026-07-20 18:29:04 -06:00
vicrodh dc6f4c6a2b fix(ui): report delivered quality + downgrade cause on the New/Classic NPB badge (#638)
The delivered-first badge, amber downgrade arrow and Source/Output/cause tooltip only lived in AudioStamp (Small/Large/kiosk). The default New mode - and Classic - mount SongCard, whose badge was bound to the catalog max only, so a CD-capped stream showed '24-bit / 96 kHz' with no arrow. SongCard now consumes the same NowPlayingState delivered fields; no new strings (reuses AudioStamp's msgids).

cc
2026-07-20 18:29:04 -06:00
vicrodh fe6f5cd3ab fix(cast): mirror Settings tier names in the device cap, drop the 48kHz copy (#638)
The per-renderer cap dropdown now uses the plain Settings product names (Hi-Res / CD Quality / MP3, looked up by key so they can't drift) instead of the misleading verbose 'CD - 16-bit / 44.1 kHz' form - a CD-tier track can be 48 kHz; the tier is a format_id, not a fixed rate. Removed the '...no 48 kHz option' caption. quality_label_from_track detail is now bit-depth-first to match every other badge.

cc
2026-07-20 18:29:04 -06:00
vicrodh be3319245e chore(scripts): visual build progress in slint-run (start/ETA/ticker/final)
Learned per-tier ETA from the last successful build, a live elapsed/ETA/percent ticker while codegen is silent, and start/finish banners. Safe under zsh/oh-my-zsh (runs under bash, newline-terminated, TTY-gated colors). NO_TICKER=1 silences it.

cc
2026-07-20 18:29:04 -06:00
vicrodh 798d91af54 fix(ui): name the renderer in the cast-cap tooltip line (#638)
The cause-3 line "Limited by the quality cap for this device" only ever
fires while casting, yet it renders in the main window's badge tooltip
where its sibling cause-2 line uses "device" for the local DAC — same
word, two different devices. Interpolate the connected renderer's name
(CastState.device-name), the form the spec's 2.5 copy sketch specifies.
All 8 catalogs updated in place; the picker's "capped for this device"
suffix is unchanged (the device name sits directly above it).

cc
2026-07-20 16:09:51 -06:00
vicrodh 4d66d6ed76 fix(quality): publish the F25 hydration values with Release/Acquire
The hydration task's store order (values, then id) was documented as the
guarantee that a reader keyed on the id never sees another track's
params, but all three stores were Relaxed — program order alone does not
survive a weakly-ordered CPU (macOS ARM is a shipped target). Release
the id store and Acquire its load in hydrated_catalog_quality so the
claimed invariant actually holds; the value atomics stay Relaxed.

cc
2026-07-20 16:09:45 -06:00
vicrodh 305addf576 fix(cast): never label probed FLAC cast bytes as MP3 (#638)
delivered_tier_str's requested-mp3 shortcut is correct on the local
engine path (an MP3 stream decodes to 16-bit PCM, so the requested tier
is the only tell), but on the cast path a successful STREAMINFO parse
proves the served bytes are FLAC: an over-cap cache/offline serve under
an mp3 cap rendered "MP3" beside a measured "24-bit / 96 kHz" detail.
Derive the tier token from the probed bits whenever the probe read the
bytes; the shared helper still covers the probe-less fallback.

cc
2026-07-20 16:09:40 -06:00
vicrodh b285bd5e80 fix(cast): prefetch at the cast-effective tier while casting (#638)
after_track_change runs kick_prefetch even when play_audible routed the
track to a renderer, and the cast auto-advance funnels through the same
local advance. The prefetch fetched upcoming tracks at
local_playback_quality() — the DAC-capped tier — into the quality-blind
L1/L2 cache, and the cache-first cast resolve then served those bytes to
the renderer verbatim: the local device cap leaked into a signal path it
is not part of (precedence rule, owner decision), and the badge
misdiagnosed the resulting downgrade as catalog availability.

Resolve the prefetch tier once per kick_prefetch: the cast-effective
tier (preference clamped by the renderer's manual cap, via the new
casting_prefetch_quality accessor) while a renderer is connected, the
device-capped local resolve otherwise. The single resolve also keeps the
network-throttle bandwidth estimate agreeing with what the prefetch
actually requests. Skipping the prefetch during cast was rejected: the
cache-first cast resolve deliberately consumes it (fast advance, P1).

cc
2026-07-20 16:09:32 -06:00
vicrodh e76d59c833 i18n(quality): translate fix-1 badge and picker strings in all non-English locales (#638)
The four msgids fix 1 left en-only — the streaming-quality-setting cause
line, the no-higher-quality cause line, and the offline/cached over-cap
picker suffixes — now carry real translations in es/de/fr/pt/ru/ja/nl,
inserted in the same tail-block order as en.po. Register and vocabulary
match each catalog's existing quality-cap block (es tu/neutral, de Sie,
fr vous, pt-PT, ru formal, nl je) and reuse the catalogs' established
terms (Calidad de streaming, Titel, piste, faixa, качество потока,
ストリーミング音質, nummer). Informative tone throughout — a downgrade
the user configured is stated as fact, never as an error.

With this, every user-visible string introduced by the four #638 fixes
exists in all eight bundled catalogs (verified against the full
pre-release..HEAD diff, 16 msgids x 8 locales).

cc
2026-07-20 15:37:59 -06:00
vicrodh 991d66540e feat(cast): apply the manual per-renderer tier cap to cast requests (#638)
register_qobuz now resolves through effective_cast_quality: the user's
streaming preference clamped by the connected renderer's stored cap,
lowest tier wins, tie reports RendererCap (spec 2.2 - the more
specific, more surprising constraint). The request-time min is the
ENTIRE enforcement (owner decision C): no cache clear on cap change,
no bypass, no re-fetch, no conversion - locally-existing bytes above
the cap go out as-is and stay disclosed by the fix-1 surfaces (the
existing over-cap info log now names RendererCap-capped serves too via
the threaded request cause). The resolution NEVER consults the local
DAC cap: cast code keeps calling the pure playback_quality(), never
local_playback_quality() (precedence rule - the local DAC is not in a
cast's signal path; stated in the doc comments).

Connection state carries the renderer's stable identity: DLNA keys
dlna:<UDN> (always stable, F14); Chromecast keys chromecast:<id> only
when the mDNS TXT id record was present - the fullname fallback would
detach on rename, so those devices get no cap row (logged at connect,
which also logs identity + cap key so a 'my cap does not apply' report
is diagnosable from the log alone). Cap row state pushes on connect,
clears on disconnect/shutdown.

set_device_cap persists index 0..3 as remove/hires/cd/mp3 into
ui_prefs.cast_quality_caps with the friendly name; wired to the new
CastActions callback. push_cap_options seeds the option labels
(reseed_i18n_labels hook, so a live language switch re-translates
them) and the option-0 'Follow app setting' parenthetical, which the
Settings streaming-quality arm now refreshes on change.

No protected file touched: qbz-audio and qbz-player are not in this
diff; the audio path is untouched (cast bypasses the local backend
entirely).

cc
2026-07-20 15:31:12 -06:00
vicrodh 3118abb4a1 feat(ui): per-renderer quality cap row in the cast picker (#638)
CastState gains the fix-4 cap-row surface (device-cap-available/-key/
-options/-index + global-quality-label) and CastActions the
set-device-quality-cap(cap-key, index) callback - property/callback
additions to existing globals only, no app.slint export change. The
connected panel gets one label-left / QbzSelect-trailing row per the
flyout alignment rule (explicit menu-width, popup wider for the long
option labels, no pills), visible only when the renderer has a stable
identity, plus the muted caption that is the literal answer to #638:
Qobuz sells four discrete tiers, there is no 48 kHz option.

The quality-line suffix property now also names the cap at work
(cause 3, 'capped for this device') when no over-cap disclosure takes
precedence. Copy discipline throughout: the cap is the user's own
quality/bandwidth choice - never an implied device incapability
(renderers accept ~anything and downsample internally).

i18n: 8 new msgids in ALL EIGHT locales (en identity-translated),
including the cause-3 tooltip line AudioStamp pre-wired in fix 1.
Unit style matched per catalog (ru Cyrillic units, nl decimal comma).
Validated with the slintcheck stub (full tree + all catalogs).

cc
2026-07-20 15:25:58 -06:00
vicrodh 0185ee00ca feat(qbz): persist manual per-renderer cast quality caps in ui_prefs (#638)
New cast_quality_caps map on UiPrefs, keyed dlna:<UDN> /
chromecast:<mDNS id> (raw stable ids, never option indexes) with the
tier key plus the renderer's friendly name for display/re-matching.
Absent key = follow the global streaming-quality setting. Only
mp3/cd/hires are ever stored - a Hi-Res+ cap is a no-op against the
preference, so it is not offered.

ui_prefs.json is the spec-chosen home (same store as the
streaming_quality the cap clamps): the settings bundle reads this file
key-by-key and only picks up streaming_quality, so per-renderer caps
are intentionally machine-local - a renderer id is LAN-local and
meaningless (or colliding) on another box. serde(default) keeps older
files deserializing; pinned by the extended legacy test plus a
roundtrip test.

cc
2026-07-20 15:23:08 -06:00
vicrodh cd928c92a1 feat(cast): expose whether a Chromecast discovery id is stable (#638)
The mDNS TXT id record is the Cast device UUID - stable across restarts
and renames, directly comparable to a DLNA UDN. When a device omits it,
discovery falls back to the mDNS fullname, which is derived from the
friendly name and silently detaches the moment the user renames the
speaker. The per-renderer quality cap (fix 4) must not be keyed on such
an id - a cap that silently stops applying is the failure mode #638
exists to eliminate - so DiscoveredDevice now says which case it is.

Additive field on the discovery DTO only; no behavior change for
existing consumers. Verified: the struct has a single construction site.

cc
2026-07-20 15:22:38 -06:00
vicrodh f450975675 feat(ui): truthful limit-quality-to-device row with read-only detected limit
Settings > Audio (#638 fix 3, spec C.6): the "Limit quality to device"
toggle's description now states what the wired setting really does — it
caps the REQUESTED tier, local playback only, never casting. The
conditional "Maximum sample rate" Hz dropdown is replaced by a read-only
"Detected device limit" line ("192 kHz · Hi-Res+") fed by the probe: a Hz
control was the documented root-cause trap (it offered rates the app
cannot request — Qobuz sells four tiers, and no 48 kHz tier exists), and
with real detection there is nothing left to pick. When the probe fell
back to the assumed common rate set, a plain informative caveat below the
row discloses that the cap still applies but may not match the hardware
(owner Decision B: no warning icon, no error styling).

Removes the dead dropdown plumbing from the desktop bridge only:
MAX_SAMPLE_RATES, the SettingsState sample-rates/sample-rate-index
properties, the snapshot fields/derivation/pushes, the "sample-rate"
select arm (and with it a dead Apply::Reinit device teardown for a value
nothing read), and the table test. Deliberately KEPT per the spec's
hazard analysis: every qbz-audio SQLite column/migration/setter/getter
(positional 23-column SELECT), the qbzd CLI/TUI surfaces, the settings-
bundle entries, and all existing .po msgids (msgmerge will mark obsolete
ones; "No limit" is reused elsewhere) — grep hits for the old identifiers
under crates/qbzd/ are expected and correct.

3 new msgids in all 8 locales. Slint tree + catalogs validated with the
slintcheck stub.

cc
2026-07-20 15:09:56 -06:00
vicrodh 534a2a0d5e feat(qbz): cap local playback requests at the detected output-device limit
Wires limit_quality_to_device to a real consumer on the LOCAL output path
(#638 fix 3, spec Phase C). New crate::device_cap module caches the DAC's
detected rate ceiling mapped to a Qobuz tier (spec C.3: >96k -> Hi-Res+ =
no effective cap; 96/88.2k -> Hi-Res; <=48k -> CD 16/44.1, bit depth
included since no 48 kHz tier exists). Detection is the proven read-only
query_dac_capabilities probe; it runs in spawn_blocking on EXPLICIT
triggers only (startup, the Settings toggle, output-device change, backend
change, reset-to-defaults) — never on the playback hot path or poll tick.
A stale cap after a hot-unplug until the next device change is the
accepted trade, same class as the HiFi wizard.

playback::local_playback_quality() returns (tier, QualityLimit): the
preference clamped by the cap, with the request-time cause for the badge
tooltip (tie reports LocalDeviceCap — the more specific constraint,
mirroring the spec's cast tie rule). All LOCAL request sites switch to it:
play_track_resolved, prefetch (fetch + throttle estimate), the gapless
pre-fetch, and the now-playing badge seed (which can now name the device
as the downgrade cause, msgid x8 locales).

PRECEDENCE (owner decision): the device actually playing governs.
playback_quality() stays the PURE preference and the cast path keeps
calling it — cast_service is untouched and the play_audible cast branch
returns before any local resolve, so the local DAC cap is structurally
unreachable from a cast. The two settings stay independent; nothing is
auto-synced. No downsampling anywhere: the cap only changes which tier is
REQUESTED; cached/offline bytes still play as-is.

Settings plumbing: SettingsState gains device-cap-summary/-detected
(property additions to an existing global — no app.slint export change);
build_snapshot fills them from the cache; refresh_device_cap re-probes and
re-pushes on the explicit triggers.

Protected-crate note: no protected file is touched — qbz-audio is only
READ through the dac_capabilities probe; qbz-player, playback_engine.rs,
alsa_direct.rs, alsa_backend.rs and the backends are untouched; no
sample-rate handling changes.

cc
2026-07-20 15:06:31 -06:00
vicrodh 2e55edea89 feat(audio): expose detected-vs-fallback in DacCapabilities and backfill stale limit flag
Two prerequisites for wiring limit_quality_to_device to the local output
path (#638 fix 3, spec Phase C):

- DacCapabilities gains an additive detected flag (F26): assemble() records
  whether sample_rates came from real /proc/asound-ALSA detection or from
  the FALLBACK_RATES common set, so a consumer deriving a quality cap can
  disclose that a fallback-based cap is not guaranteed to match the
  hardware (owner Decision B: the cap still applies either way). Existing
  consumers (HiFi wizard, qbzd TUI wizard) only read fields and compile
  unchanged; module tests updated to assert the flag.

- One-time user_version-gated backfill of limit_quality_to_device to 0
  (F10): installs that first ran a pre-#45 build carry a column backfilled
  to 1 by the original DEFAULT-1 migration. That divergence was inert while
  the flag had zero consumers; the moment fix 3 consumes it those installs
  would silently gain a cap nobody asked for on upgrade. The backfill runs
  once, stamps user_version only on success, and never touches a value the
  user sets after this release. Stale field/default doc comments revised to
  match the wired semantics.

Protected-crate note: dac_capabilities.rs is the read-only probe/DTO
assembler and settings.rs is the SQLite settings store — neither is part of
the protected bit-perfect path (alsa_direct.rs, alsa_backend.rs, the
backends, playback_engine.rs are untouched). No sample-rate handling
changes; the flag only shapes which Qobuz tier is REQUESTED.

cc
2026-07-20 14:57:08 -06:00
vicrodh 20dc5f4a19 feat(cast): report the MEASURED delivered quality on the cast surfaces (#638)
Fix-1 cast half (spec Phase 1). During cast there is no PlaybackEvent —
the renderer is playing, not our engine — so the delivered quality is now
MEASURED from the bytes about to be served: register_qobuz probes the FLAC
STREAMINFO (shared qbz-models prober) before register_audio moves them,
which reports the truth regardless of which tier (network / L1/L2 cache /
offline store) produced the bytes.

- CastPicker line: measured label+detail via the shared formatter
  ("Hi-Res FLAC", "24-bit / 96 kHz"); quality_label_from_track demoted to
  the fallback for non-FLAC/local casts (F20 closed).
- The now-playing badge stays live during cast: cast_track publishes the
  85e11d28 effective properties plus the fix-1 cause/effective-tier through
  the SAME shared helpers the local poll uses (stream_downgraded /
  classify_limit_cause / delivered_tier_str — no forked arithmetic). The
  cause is the cast request-time resolution: preference today, renderer cap
  when fix 4 lands; NEVER the local DAC cap (precedence rule).
- Over-cap disclosure: origin != network AND measured tier > requested tier
  -> logged at info and surfaced on the picker line as "offline copy — cap
  not applied" / "cached copy — cap not applied" (Decision C: local bytes
  go out as-is, never resampled, never re-fetched — disclosed, not hidden).
  Three new CastState properties (quality-limit-cause, quality-over-cap,
  quality-origin), reset on disconnect; properties on existing globals only,
  no app.slint export change.
- remote_stream.rs now calls the shared prober, keeping its own CD-shaped
  defaults at the call site — behavior byte-identical (F17/F21).
- Two new msgids land in en.po; remaining locales come with the i18n pass.

Validated with the slintcheck stub (full .slint tree + 8 .po catalogs).

cc
2026-07-20 14:45:17 -06:00
vicrodh be85c0fc81 fix(quality): close the search-surface metadata hole (F25, #638)
track_item_to_queue leaves bit_depth/sample_rate None on every
search-surface play, which (a) rendered the quality formatter's DEFAULTS —
"16-bit / 44.1 kHz" under a HI-RES tier label — and (b) zeroed TRACK_MAX_*
so the downgrade arrow could never fire for search-queued tracks.

- The badge seed now shows an EMPTY detail when both catalog params are
  missing (never a guessed line), and adopts the hydrated values when they
  are already cached for the track.
- An async one-shot hydration (get_track, governed Qobuz sources only,
  de-duped per track id) fetches the catalog maxima at play time, re-seeds
  TRACK_MAX_*, re-pushes tier/detail/sample-rate/bit-depth, and forces a
  poll re-push so the downgrade compare re-runs even while paused. The
  response is DROPPED if the track changed meanwhile — a slow reply must
  never stamp the next track with the previous track's catalog max.
- The Slint TrackItem struct is deliberately NOT extended (24 construction
  sites; rejected for blast radius) and the queue rows keep their None
  fields — the hydration cache covers the meta re-runs.

cc
2026-07-20 14:39:57 -06:00
vicrodh 6c0228b390 feat(quality): badge reports the DELIVERED quality and names the cause (#638)
Phase A of the #638 fixes (owner decision, reverses the deliberate #590
catalog-max choice back to Tauri behavior): while the delivered stream is
below the track's catalog max, the AudioStamp main line shows the DELIVERED
tier/detail, the amber arrow stays on, and the tooltip gains a third line
naming WHY — Source/Output plus the cause. A downgrade caused by the user's
own setting is the app doing what it was told, so the strings are
informative, never alarming.

Mechanics:
- REQUESTED_QUALITY_ID / REQUESTED_CAUSE atomics seeded once per track
  change beside TRACK_MAX_* (one ui_prefs load per change, never per 450ms
  poll tick), gated to Qobuz sources — local/Plex/ephemeral are not
  governed by the streaming preference and never get a cause line.
- Display-time classification (classify_limit_cause): requested Hi-Res+ or
  delivered below the requested tier's promise -> catalog availability;
  promise met -> the request-time cause (the user's preference today; the
  device/renderer caps of fixes 3/4 reuse the same discriminants).
- The #590 downgrade arithmetic (0.9 rate-family guard + full DSD
  exemption) is extracted UNCHANGED into stream_downgraded so the fix-1
  cast half reuses it instead of forking it.
- Two new NowPlayingState properties (quality-limit-cause int,
  quality-effective-tier string) on the existing global — no app.slint
  export change — reset on track change, mirrored to the miniplayer.
- Causes 2 (local device cap) and 3 (renderer cap) are pre-wired in the
  tooltip chain but unreachable until fixes 3/4 land; their .po entries
  ship with those fixes. The two reachable msgids land in en.po here; the
  remaining locales are populated by the i18n pass.

Validated with the slintcheck stub (full .slint tree + 8 .po catalogs).

cc
2026-07-20 14:37:20 -06:00
vicrodh fbf95ee03f feat(models): shared QualityLimit cause enum, min_tier, STREAMINFO prober
Groundwork for the #638 quality fixes: ONE cause vocabulary shared by the
local badge, the local device cap (fix 3) and the cast surfaces (fixes 1/4)
so nobody grows a second enum; Quality::min_tier for cap clamping (the
derived Ord is tier-correct — discriminants 5<6<7<27 ascend with tier);
and the proven FLAC STREAMINFO bit math hoisted verbatim from
qbz::remote_stream into a shared prober that returns None instead of
guessing defaults (callers keep their own fallbacks).

cc
2026-07-20 14:33:02 -06:00
vicrodh 87700f22c8 fix(audio-settings): NULL-default limit_quality_to_device to off
The unwrap_or(1) was stale residue from when the column shipped as
DEFAULT 1 with a struct default of true; since 1.1.9 (#45) the struct
default and the ALTER are both 0/false. The NULL branch is unreachable
today (the migration carries DEFAULT 0 and the seed INSERT materializes
it), so this is hygiene, not a behavior change - but it would become a
live wrong-default the moment anything consumes the flag, which the
local device-cap wiring for #638 is about to do.

This touches the settings store only, not a backend: no contact with
alsa_direct.rs, alsa_backend.rs or any playback path.

cc
2026-07-20 14:20:13 -06:00
vicrodh a7cdab8810 fix(cast): clear the audio cache when the streaming quality changes
Without this the #638 fix is intermittent: the L1/L2 audio cache is
keyed by track id alone with no quality dimension, so a track warmed at
the old tier keeps serving local plays and casts at that tier until it
ages out. Clear both tiers the moment the Settings dropdown lands on a
different key (no-op re-selects do not clear).

The clear is fire-and-forget, deliberately not deferred: deferring
would let the next play after a change still deliver the old tier,
which is exactly the intermittency this exists to remove. Known cost,
accepted: an armed gapless handoff can drop (one possible audible gap
on an explicit, rare user action) and warmed bytes for local playback
are discarded too. Logged at info so a reported gap is attributable.

Protected-crate note: the qbz-player change is one pub fn on Player
that purely delegates to the already-existing AudioCache::clear()
(which clears both L1 memory and L2 disk). No engine logic, no contact
with playback_engine.rs or any sample-rate handling.

cc
2026-07-20 14:20:04 -06:00
vicrodh e1a70fe69b fix(cast): honor the streaming-quality preference when casting (#638)
Replace the hardcoded Quality::UltraHiRes in register_qobuz with the
user's persisted streaming-quality preference, resolved per cast track
via playback::playback_quality() (now pub(crate)) so a Settings change
applies to the very next cast without a reload.

register_qobuz sits before the protocol dispatch in cast_track: content
is resolved protocol-agnostically and only then branched on Chromecast
vs DLNA, so this one change covers both protocols. Both callers that
reach it (the connect-time resume re-cast in cast_service::connect and
the new-track-while-casting branch in playback::play_audible) flow
through cast_track and need no separate change.

This deliberately reverses the old decision D3 ("always attempt
UltraHiRes when casting"); the module doc is updated to match. The
preference governs only what we REQUEST: bytes already in the L1/L2
cache or the offline store are served as-is, never resampled.

playback_quality() stays the PURE preference read and its doc now says
so: a local output-device cap must never leak into the cast path, since
the local DAC is not in a cast's signal path.

cc
2026-07-20 14:19:34 -06:00
vicrodh 9353ed700d feat(settings): Flatpak/Snap sandbox section with copyable permission commands
Tauri parity (the sections did not survive the Slint port): a Settings
section shown only on sandboxed installs holding the one-time permission
commands with copy-to-clipboard buttons. Flatpak: exclusive-DAC D-Bus
reservation override (the user-side fix for #641's exclusive-mode failure
— the Flathub manifest cannot carry the wildcard own-name past its linter,
so the user grants it locally) and NAS/external-drive filesystem access.
Snap: the audio interface connections (qbz-player:alsa/pulseaudio/
pipewire/mpris) plus optional removable-media. New SandboxState global
seeded from qbz_app::diagnostics install_method; 14 msgids across all 8
bundled locales.

km
2026-07-20 13:47:24 -06:00