Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh f63bad80e3 docs(release): expand 1.2.6 notes with carry-over from 1.2.5
Quick-follow-up release: lead with what is genuinely new in 1.2.6
(accessibility themes + infinite play V2 rewiring + branding polish)
and append the full 1.2.5 content for users coming from 1.2.4 or
earlier.

cc
2026-04-15 22:48:02 -06:00
vicrodh f7f32e014a fix(queue): apply infinite play to natural track-end auto-advance
The previous infinite play fix only patched the manual "Next" button
handler. Tracks ending naturally go through the setOnTrackEnded
callback, which had two problems:

1. The early-exit guard `if (!isAutoplayEnabled())` evaluated to true
   for the new `infinite` autoplay mode (since isAutoplayEnabled only
   returns true for `continue`), so playback stopped after the current
   track instead of advancing.

2. Even if it had advanced, the queue-end branch just stopped playback
   without checking infinite play.

Fix:
- Allow auto-advance for both `continue` and `infinite` modes; only
  `track_only` stops after the current track.
- When `nextTrackGuarded()` returns null AND infinite play is on,
  collect last 5 played tracks (current + history), call
  v2_create_infinite_radio, append the result via v2_bulk_add_to_queue,
  and retry — same logic that already exists in the manual Next path.

Closes the natural-end gap reported during e2e testing of #319.

cc
2026-04-15 22:09:57 -06:00
vicrodh 570532ce90 fix(branding): refresh tray icons + snap icon to match new vinyl Q
Tray icons (tray.png, tray-light.png, tray-dark.png) and the Snap
launcher icon were left at the v1.2.4 hand-drawn outline Q during the
v1.2.5 branding refresh. Regenerate them from the canonical
icon-symbolic.svg / 256x256.png so the system tray, KSNI panel and
Snap launcher show the matte-vinyl Q.

Also drop the drop-shadow filter on the login screen logo: with the
new icon's edge rim and inner highlight, the extra ambient shadow is
redundant and visually muddies the artwork.

cc
2026-04-15 21:49:25 -06:00
vicrodh efe06763f6 fix(player): match QConnect badge height to badges-group
Issue #318 follow-up: after moving the QConnect badge out of the
songcard, the badge collapsed to its content height (~28px) instead of
matching the badges-group height (60px) inside the songcard, and got
even shorter when the responsive breakpoint kicked in.

Reserve a 60px min-height on .center-section (artwork 56px + 2px
padding) and switch align-items from center to stretch, so both the
song-card-slot and the QConnect badge wrapper fill the same vertical
space whether or not a track is playing and across all breakpoints.

cc
2026-04-15 21:42:59 -06:00
vicrodh f6377d5df9 chore(release): bump version to 1.2.6 + release notes
Bumped:
- src-tauri/Cargo.toml + Cargo.lock
- src-tauri/tauri.conf.json
- package.json + package-lock.json
- snapcraft.yaml
- flake.nix (qbzVersion, hashes cleared for nix users to regenerate)

Added:
- packaging/flatpak/com.blitzfc.qbz.metainfo.xml (1.2.6 entry)
- docs/release-1.2.6/TAG_DETAILS.md (release notes)

cc
2026-04-15 21:36:11 -06:00
vicrodh 283763f47b fix(player): keep Qobuz Connect badge visible when no track is playing
Issue #318: the QConnect badge was rendered inside the songcard, which
disappears when the queue is empty (e.g., on app start). Users couldn't
hand off to a remote renderer without first picking a track to play.

Move the QconnectBadge out of the songcard and render it as a sibling
of the songcard slot in the center section. The slot keeps the same
min-width (354px) and min-height when empty, so the badge stays in a
stable horizontal position whether or not a track is loaded.

cc
2026-04-15 21:26:09 -06:00
vicrodh 33d65681c4 fix(queue): restore infinite play (V2 reimplementation)
Issue #319: Infinite Play toggle had no backend wiring after the V2
migration — toggle only wrote to localStorage and the queue-end handler
ignored it, so playback stopped at the last track.

Reimplemented in V2 (no legacy wrappers):

- New `v2_create_infinite_radio` command that takes the last few played
  track IDs, uses the most recent track's artist as the radio seed via
  `RadioPoolBuilder::create_artist_radio` (Qobuz `/radio/artist`
  endpoint), and returns 50 V2QueueTrack ready to append. Does NOT
  mutate the queue or playback context — caller decides how to use them.

- `playbackPreferencesStore`: expose `'infinite'` in `AutoplayMode`
  type and add `isInfinitePlayEnabled()` helper.

- `+page.svelte`:
  - `handleToggleInfinitePlay` now syncs to backend via
    `setAutoplayMode('infinite' | 'continue')` so the choice persists
    in `playback_preferences.db` across restarts (was localStorage only).
  - On startup load infinite play from cached preferences (was
    localStorage).
  - When `nextTrack()` returns null AND infinite play is on: collect
    the current track + last 5 history tracks, call
    `v2_create_infinite_radio`, append the result via
    `v2_bulk_add_to_queue`, then advance — playback continues without
    interruption.

i18n: add `toast.failedToSavePreference` to all 5 locales.

cc
2026-04-15 21:20:04 -06:00
vicrodh 3d0df4afba feat(a11y): add WCAG Light, WCAG Dark, High Contrast, and Colorblind themes
Four accessibility-focused themes with WCAG AAA contrast ratios:
- WCAG Light/Dark: 7:1+ contrast for all text levels
- High Contrast: pure white on pure black, yellow accent (21:1)
- Colorblind: deuteranopia-safe palette (Wong colors), no red/green

Addresses discussion #313 (user with severe vision impairment).

cc
2026-04-15 20:30:26 -06:00
vicrodh e39d9b0068 feat(carousel): add mouse drag scrolling to horizontal rows
Add pointer-based drag scrolling to HorizontalScrollRow so users can
click-and-drag to scroll album rows, matching iPad/browser behavior.
Distinguishes drag from click using a 5px threshold to avoid
intercepting album selection. Cursor changes to grab/grabbing.

Addresses user report from discussion #313 where horizontal album
rows were not explorable without arrow buttons.

cc
2026-04-15 20:27:44 -06:00
vicrodh ffe3a7b49b Merge branch 'fix/icon-sizing-shadow' into pre-release 2026-04-15 20:24:36 -06:00
vicrodh 1fe7d44311 fix(branding): resize icon to fill canvas + add blue edge rim
Tighten the viewBox so the vinyl fills ~97% of the canvas (matching
Steam/Chrome/Papirus sizing). Add a #3968be stroke rim around the
vinyl edge so the icon remains visible on pure-black backgrounds.
Include a subtle radial inner-light from top-left for depth.

Also adds 512x512.png for Flathub compatibility.

Regenerate all PNG exports: Tauri standard sizes, Windows Store
squares, ICO, Android mipmaps, and static logos.

cc
2026-04-15 20:22:00 -06:00
vicrodh 931512fed3 fix(gentoo): add inherit xdg-utils to qbz-bin ebuild template
pkg_postinst and pkg_postrm in the binary ebuild call
xdg_icon_cache_update and xdg_desktop_database_update, which
come from the xdg-utils eclass. Without `inherit xdg-utils`
those helpers are undefined and the hicolor icon cache never
refreshes on upgrade.

The source ebuild template (qbz.ebuild.template) already
inherits xdg, so this only affects users installing via
qbz-bin (the prebuilt binary path). The qbz-overlay has a
direct patch for v1.2.5; this ensures v1.2.6+ release-gentoo
runs emit correct ebuilds.

cc
2026-04-15 18:59:09 -06:00
Victor RH 76db2015c1 Merge pull request #314 from vicrodh/dependabot/cargo/crates/rustls-webpki-0.103.12
chore(deps): bump rustls-webpki from 0.103.9 to 0.103.12 in /crates
2026-04-15 16:31:51 -06:00
vicrodh 0461c36912 security: address CodeQL alerts from the v1.2.5 scan
Triaged the 8 open CodeQL alerts raised on the 1.2.5 merge commit.

Critical (1) — real, fixed:

  * .github/workflows/release-updater-manifest.yml:29 — the resolve
    step interpolated `${{ github.event.workflow_run.head_branch }}`
    directly into a shell heredoc. `head_branch` on `workflow_run` is
    attacker-controllable (any outside contributor can push a branch
    whose name embeds a payload). Route the value through an env var
    (HEAD_BRANCH) and validate the shape with a strict regex before
    letting it flow into shell or $GITHUB_OUTPUT.

Warnings (2) — real, fixed:

  * .github/workflows/build-qbzd.yml had no `permissions:` block, so
    every job inherited the repo's default GITHUB_TOKEN scope. Add a
    top-level `permissions: contents: read`; the existing `release`
    job keeps its own `contents: write` override for publishing
    artifacts.

Warnings (3) — real, low severity, mitigated:

  * crates/qbzd/src/{daemon.rs,session.rs} logged `user_id` at info
    level (CodeQL rust/cleartext-logging). Demoted the three lines to
    `log::debug!` so default-level logs stay free of the numeric user
    id, with a comment noting RUST_LOG=debug re-enables them for
    diagnostics.

Warnings (2) — false positives, dismissed in GitHub:

  * crates/qbzd/src/login.rs:103 — `println!` to stdout inside the
    interactive `qbzd login` subcommand; login confirmation UX, not a
    persistent log. (alert #63 dismissed, reason: false positive)
  * crates/qbzd/src/qconnect.rs:540 — `session_uuid` is an ephemeral
    per-connection identifier QConnect exchanges in cleartext itself;
    not a credential or token. (alert #65 dismissed, reason: false
    positive)

cc
2026-04-15 16:10:06 -06:00
vicrodh a867d77528 fix(build): gate macOS-unreachable imports to cfg(target_os = "linux")
The v1.2.5 tag push flagged three macOS-only compile errors on the
release-macos workflow (Linux, aarch64 Linux, flatpak still building
successfully). All three are Linux-only symbols pulled unconditionally
into files that are compiled on every target:

  * src-tauri/src/tray.rs: imports PathBuf inside the
    `cfg(not(target_os = "linux"))` branch without `use std::path::PathBuf`.
    Add the import under the matching cfg so macOS / Windows see it.

  * src-tauri/src/commands_v2/playback.rs: `cached_audio_incompatible_with_hw`
    is defined only on Linux (ALSA). Callsites are already
    `cfg(target_os = "linux")`, but the shared `use super::{...}` block
    was not. Move that one symbol out into a Linux-only import.

  * src-tauri/src/commands_v2/legacy_compat.rs: same story with
    `v2_prepare_notification_icon_bytes` — only the Linux notification
    path calls it, but the import was unconditional.

No runtime behaviour change; this only unblocks the release-macos
(and any future Apple-target) workflow so it can build.

cc
2026-04-15 15:46:06 -06:00
vicrodh 64d51d3c85 Merge pre-release into main for v1.2.5 'Accolade Watch' 2026-04-15 15:25:16 -06:00
vicrodh aa68fa0a5b docs(release): apply user's 1.2.5 notes polish + trim
Rewrite the opening paragraph into a personal, first-person note from
the maintainer (including the Spanish sign-off), drop the sprint-
level bullets that only described iterations kept in the narrative
but don't matter to end users, and tidy a couple of typos.

cc
2026-04-15 15:22:50 -06:00
vicrodh e0a3ed6e72 chore(release): bump flake.nix qbzVersion to 1.2.5
Missed in the earlier bump pass. Confirmed every other version pin
reads 1.2.5: src-tauri/Cargo.toml, src-tauri/Cargo.lock,
src-tauri/tauri.conf.json, package.json, package-lock.json,
snapcraft.yaml, packaging/flatpak/com.blitzfc.qbz.metainfo.xml
(top <release> entry); the only remaining "1.2.4" string in the tree
is the historic release entry in metainfo.xml, which is correct.

cc
2026-04-15 15:19:14 -06:00
vicrodh 130efeb1f0 docs(release): add 1.2.5 "Accolade Watch" release notes
Follows the same pattern as docs/release-1.2.4/:

  * CHANGELOG_FULL.md — short-hash + subject for every non-merge commit
    since v1.2.4 (216 commits)
  * TAG_DETAILS.md — narrative changelog grouped by Accolades, Release
    Watch, Labels, Visual identity, Desktop integration, Streaming /
    audio / player, Updater, UI polish, Internal architecture, Packaging

qbzd work is noted but flagged as scaffolding (hidden from the release
narrative per the plan to ship it separately).

cc
2026-04-15 14:22:21 -06:00
vicrodh be63d6b3d3 chore(flatpak): point metainfo screenshots at the 1.2.5 commit
Bump the SHA in every <screenshot> URL to 5f759514d3
(the screenshot refresh commit) so Flathub picks up the new captures.

Add three new <screenshot> entries on top of the existing three —
immersive line-bed, enriched artist page, Hi-Fi DAC setup wizard — and
re-promote the Audio settings capture (default-settings.png) that was
sitting unused in the screenshots folder. New ordering for the listing:

  1. home-discover (default — first impression)
  2. immersive-cover-flow
  3. immersive-line-bed
  4. enriched-artist-page
  5. local-library
  6. hifi-wizard
  7. default-settings

cc
2026-04-15 13:42:28 -06:00
vicrodh 5f759514d3 chore(flatpak): refresh screenshots for 1.2.5
Replace the four existing Flathub screenshots with captures from the
1.2.5 build (new logo, new sidebar, refreshed login chrome) and add
three new ones that showcase features worth highlighting on the
Flathub listing:

  * enriched-artist-page.png — artist view with editorial data
  * hifi-wizard.png — DAC setup wizard
  * immersive-line-bed.png — immersive line-bed playback mode

Metainfo manifest is updated in the next commit because Flathub freezes
screenshot URLs by commit SHA, so we need this commit's SHA to land
first.

cc
2026-04-15 13:42:01 -06:00
vicrodh 824b577dc2 fix(genre-filter): popup uses CSS max-height for smart positioning
The collision check in positionPopup was reading the popup's measured
rect right after mount, but the popup grows after that runs (the
genre tree fills in asynchronously). The first measurement reported a
height much smaller than the final ~500px / ~700px max-heights from
CSS, so the "would I overflow the bottom?" branch evaluated false even
when the final popup didn't fit. Result: bottom of the popup got clipped
by the window edge (visible in the Discover -> Filter by genre flow on
short windows).

Use the CSS max-heights (530/630 already used for width) as the
collision target instead. If the down-placement still overflows, try
flipping above the trigger; if neither fits (very small window), clamp
to the bottom padding and let the popup's own internal scroll handle
the overflow.

cc
2026-04-15 13:40:58 -06:00
vicrodh 6049240640 chore(release): bump version to 1.2.5 "Accolade Watch"
* src-tauri/Cargo.toml and Cargo.lock: 1.2.4 -> 1.2.5
  * src-tauri/tauri.conf.json: 1.2.4 -> 1.2.5
  * package.json (+ package-lock.json regenerated): 1.2.4 -> 1.2.5
  * snapcraft.yaml: 1.2.4 -> 1.2.5
  * packaging/flatpak/com.blitzfc.qbz.metainfo.xml: new <release> entry
    dated 2026-04-15 covering the visual refresh (new logo + login
    branding), Plasma/Klassy auto-detect for window controls,
    opt-in match-system-chrome rounded corners, Editor's Picks
    ribbon cleanup, the two sidebar menu positioning fixes shipped
    alongside this bump, and the DAC wizard contrast fix.

Tag not applied per request — release notes are staged, ready for
the screenshots pass and manual tag afterward.

cc
2026-04-15 13:16:36 -06:00
vicrodh 372357497b fix(sidebar,about): smart positioning for playlist menus + platform label
Two bugs surfaced during 1.2.5 QA:

* The per-playlist right-click context menu opened at the cursor and
  could fall below the bottom of the window when invoked on items near
  the last of the sidebar. It now measures its own rect and flips above
  the cursor if there's no room below, or clamps to the bottom padding
  as a last resort. Same pattern already used for the main playlists
  dropdown menu.
* The Sort submenu inside the sidebar's general playlists menu was
  being clipped by its parent .dropdown-menu because the submenu lived
  inside that menu's DOM subtree. Teleport it via a local `use:portal`
  action so absolute positioning escapes any ancestor `overflow: hidden`.

Also fix a duplicated "License" label in the About dialog's Build Info
block: the second row meant to show the platform was using the license
key. Swap to about.buildInfo.platform (the key already existed in both
locales).

cc
2026-04-15 13:16:07 -06:00
vicrodh 7d1e5604da refactor(appearance): keep only the rounded corners on match-chrome
The inset dark frame + inset highlight + outer drop shadow combo
didn't produce a convincing edge on WebKitGTK — rounded corners are
the only piece of "match system chrome" that actually works today, so
drop the rest and leave the CSS minimal.

Real native shadow/border around a CSD Tauri window requires hints
like `_GTK_FRAME_EXTENTS` (X11) or `zxdg-toplevel-decoration-v1`
(Wayland) that wry doesn't expose yet. That path stays as a Phase 3
TODO — either upstream wry/tao or an FFI to gtk::Window via
raw_window_handle.

cc
2026-04-15 12:20:22 -06:00
vicrodh 4c5019dff3 fix(appearance): prioritize visible edge over drop shadow
Per user request, put explicit window edges above the ambient drop
shadow. Restore clip-path for clean corner anti-aliasing (it doesn't
clip inset shadows, only outer paints), drop the body padding
workaround, and paint two inset bands on .app:

  inset 0 0 0 1px rgba(0,0,0,0.75)    — crisp dark frame, 1px
  inset 0 0 0 2px rgba(255,255,255,0.06) — subtle inner highlight

Keep a low-weight outer drop shadow (0 8px 20px) so the window still
has a hint of lift, but the edges — not the shadow — define the
window boundary now. Works the same on any compositor because it's
all painted by the webview, not by KWin/Mutter.

cc
2026-04-15 12:12:21 -06:00
vicrodh 74d4d98e42 fix(appearance): draw the external drop shadow around rounded window
Phase 2 produced clean rounded corners but no visible shadow because
.app filled 100vw/100vh, leaving no pixels for box-shadow to paint
into — and the clip-path added for anti-alias was clipping the shadow
as well.

Reserve a 12px padding on body while match-chrome-transparent is active
and shrink .app by the matching 24px in width/height, so the outer
drop shadow has somewhere to render. Drop clip-path (it was consuming
the shadow); keep transform: translateZ(0) for GPU AA on the corners.
Two-layer shadow: a broad soft ambient (18/40 rgba(0 0 0 / 0.65)) plus
a tight 4/12 crisp shadow for edge definition without painting a hard
1px stroke that jaggies at the rounded joint.

cc
2026-04-15 12:10:57 -06:00
vicrodh 823e1c313e fix(appearance): smoother rounded corners under WebKitGTK
Remove the hard 1px outline that the "match system window chrome"
style was painting inside the radius: on WebKitGTK the outline
amplified anti-aliasing jaggies at the rounded corners because the
stroke has no blur. Replace with a softer two-layer drop shadow,
add clip-path: inset(... round ...) on top of border-radius (clip-path
gives noticeably cleaner AA than border-radius + overflow:hidden alone
in WebKitGTK), and promote the container to its own compositing layer
with translateZ(0) + backface-visibility:hidden so the GPU handles the
edge.

cc
2026-04-15 11:51:03 -06:00
vicrodh 57e63e3ef5 feat(appearance): phase 2 of match-system-window-chrome (transparent)
Rounded-corner window chrome now actually works. The toggle in
Settings > Appearance persists to the window_settings SQLite store and
gates the Tauri window transparency decision at startup, so on next
launch the main window is rebuilt with `transparent: true` and the
border-radius plus edge outline apply without painting white corners.

Flow:
  * Toggle in Settings > Appearance -> windowChromeStore saves to
    localStorage and calls v2_set_match_system_window_chrome which
    writes match_system_window_chrome to window_settings.db.
  * Toast reminds the user to restart.
  * On next launch, should_use_main_window_transparency() reads the
    setting and returns true, Tauri builds the window transparent.
  * Setup stores the decision in MAIN_WINDOW_TRANSPARENT. A new command
    v2_main_window_is_transparent reports it back to the frontend.
  * +page.svelte only applies .app.match-chrome when the window is
    actually transparent AND the custom titlebar is visible, so
    stale-state toggling never produces white wedges.
  * CSS sets html/body transparent via a global class on <html>, then
    .app.match-chrome.floating paints the rounded corners plus a
    12/28 rgba drop shadow and a 1px darkening outline.
  * Maximized state clears the radius automatically.

Env vars QBZ_FORCE_TRANSPARENT_WINDOWS / QBZ_FORCE_OPAQUE_WINDOWS still
take precedence over the DB-persisted toggle.

cc
2026-04-15 11:31:11 -06:00
vicrodh 8502c2fdb3 feat(appearance): match system window chrome (phase 1) + ribbon cleanup
Adds an opt-in "Match system window chrome" toggle in Settings >
Appearance. When enabled (and the custom title bar is active, i.e.
neither "Use system title bar" nor "Hide title bar"), QBZ rounds its
own corners to match the detected desktop's decoration radius and
paints a thin edge outline so the window reads as its own surface
against the desktop instead of a bare rectangle.

Phase 1 scope:
  * New windowChromeStore (localStorage) holding the toggle and the
    detected corner radius.
  * desktop_theme module gains a best-effort window_corner_radius_px:
    10 for Klassy, 6 for Breeze Plasma, 12 for GNOME/Adwaita, 8 fallback.
  * +page.svelte subscribes to the detector; .app.match-chrome.floating
    applies the radius via a CSS variable plus a 1px darkening outline
    and a deeper shadow. Maximized state removes the radius.
  * SettingsView: new toggle, disabled when hideTitleBar or
    useSystemTitleBar is on; i18n for en/es.

External shadow that matches each DE's compositor remains a phase 2
item — it requires `transparent: true` on the Tauri window, which has
compositor stability trade-offs and must be restart-gated.

Also cleans up redundant AlbumCard ribbons in the Editor's Picks
TAB (distinct from the home tab that was fixed earlier): removes
ribbon={album.ribbon} from the `editorPicks` and `qobuzissimes`
sections inside the editor-picks tab block. Press Accolades and
Popular Albums keep their ribbons (they vary per card).

cc
2026-04-15 11:17:05 -06:00
vicrodh 62d1674673 chore(release): hide qbzd from UI and CI until it ships
qbzd is feature-complete on disk but not ready for public release yet.
Keep all code paths in place so it can be unhidden with a one-line
flip, but remove it from the user-facing surface:

  * build-qbzd.yml: comment out the `push.tags` trigger so neither a
    regular `v*` version tag nor a `qbzd-v*` tag auto-builds artifacts.
    workflow_dispatch is preserved for manual testing.
  * CastPicker.svelte: guard the QBZ Daemon tab, the manual-connect
    section, and the three `v2_qbzd_*` Tauri invocations behind a
    `QBZD_ENABLED` flag (currently false). Default activeProtocol
    switches to 'chromecast' so the picker doesn't start on a hidden
    tab.
  * SettingsView: DAC Setup Wizard icon filter swapped — button icon
    drops the `invert(1)` so it reads on light accent colors (e.g.
    Catppuccin Lavender), and the tooltip copy of the same icon now
    has `invert(1)` so it reads on the dark surface bg. Unrelated to
    qbzd but bundled since both are pre-release polish.

cc
2026-04-15 10:28:25 -06:00
vicrodh 6a5cfc7235 feat(titlebar): auto-detect KDE Plasma/Klassy theme for window controls
Adds a runtime-detected "Klassy (auto-detect)" preset for the custom
window controls that reads the user's Plasma decoration theme and
applies matching colors and shape. When Klassy itself is not installed
but Plasma is, the option re-labels to "Plasma (auto-detect)" and
still applies the desktop color scheme. Hidden on non-KDE / non-Linux.

Backend:
  * New desktop_theme module parses kwinrc, kdeglobals, and klassyrc
    without external deps. Exposes detect_desktop_theme Tauri command
    returning DesktopThemeInfo: desktop id, isKlassy flag, WM active/
    inactive background + foreground, accent, DecorationHover, and the
    user's Klassy ButtonIconStyle / ButtonShape when present.

Frontend:
  * windowControlsStore gains applyKlassyPreset(), a cached detection
    helper, and mapKlassyShapeToQbz() that maps Klassy's button shapes
    (FullHeightRectangle, FullHeightRoundedRectangle, Tab, Circle,
    Square, IntegratedRoundedRectangle, ...) onto QBZ's shape set.
  * Adds the full-height-rounded shape to ButtonShape to cover the
    most common Klassy style that QBZ was missing; CSS matches Klassy
    FullHeightRoundedRectangle visual (full-height tab with ~7px
    radius and margin).
  * SettingsView fires detection on mount, derives the preset list
    reactively so the auto-detect option only appears when relevant,
    and adds the new shape to the Style picker.
  * i18n keys added for the new shape and both preset labels in en/es.

cc
2026-04-15 09:43:00 -06:00
vicrodh 1cb8d2c305 feat(branding): refresh app icon, tray theming, and login branding
Apply the new matte-vinyl QBZ logo (Flathub-compliant: square canvas,
74% footprint, no baked shadows/gloss) across every icon asset: Tauri
standard sizes, Windows Store squares, Android icons and mipmaps,
static/, README, and the tray.

Ship two tray variants (black and white glyphs) and pick the right
one at runtime by reading GNOME color-scheme, GTK settings, and KDE
kdeglobals. Defaults to light-theme glyph when nothing matches.

Login screen: logo 80 -> 175 px with a subtle var(--text-muted)
drop-shadow using color-mix() for alpha, plus QBZ and "Qobuz(TM)
Player" under the icon. Update app.tagline i18n accordingly.

cc
2026-04-15 03:55:28 -06:00
vicrodh b227355af7 fix(nix): export LD_LIBRARY_PATH for libappindicator in devShell
The package's `postInstall` wraps the installed `qbz` binary with
`LD_LIBRARY_PATH` so it can `dlopen(libayatana-appindicator3.so.1)` at
runtime. Inside `nix develop`, users run `target/debug/qbz` directly
without that wrapper, so the tray init panics at startup.

Add a `shellHook` that mirrors `postInstall`'s library path.

Same root cause as the previous LIBCLANG_PATH fix: `inputsFrom` makes
dependencies available at build time but does not set runtime/env vars
the way the installed wrapper does.

Refs #312.

cc
2026-04-14 23:00:35 -06:00
vicrodh c63c3ab160 chore(audio-watch): drop deprecated DeviceTrait::name fallback
rodio deprecated `DeviceTrait::name()` in favour of `description()`
(name) / `id()` (stable identifier). The legacy fallback in
`preferred_name()` was unreachable on our target platforms anyway —
the only path that matters is the `description()` one, since that is
what `get_audio_devices()` persists in settings.

Skip the device if `description()` fails instead of falling back to
the deprecated API. Silences the build warning.

cc
2026-04-14 22:59:22 -06:00
vicrodh 8871262377 chore(deps): bump rand to silence dependabot advisories
- src-tauri: rand 0.10.0 → 0.10.1 via cargo update (alert #36)
- qbz-player: rand 0.8 → 0.9, `gen_range` → `random_range` (alert #38)

The underlying RUSTSEC issue only triggers for code that calls
`rand::rng()` alongside a custom logger — we don't do that anywhere,
so actual impact is nil. Bumping closes the alerts.

cc
2026-04-14 22:47:42 -06:00
vicrodh 551c986591 fix(nix): export LIBCLANG_PATH in devShell for mupdf-sys bindgen
`inputsFrom` does not propagate the package's `env.*` attributes into
the dev shell, so running `npm run tauri dev` inside `nix develop`
failed at `mupdf-sys(build)` with "Unable to find libclang".

Re-export LIBCLANG_PATH on the shell itself.

Closes #312.

cc
2026-04-14 21:47:45 -06:00
vicrodh f7e51b980e fix(audio-watch): stop pre-flight device toast — stored name format never matched cpal enum
Level A of issue #307 was firing 'device no longer available' spuriously
because settings.output_device stores the ALSA-normalized form
(front:CARD=X,DEV=0 per normalize_device_id_to_stable) while cpal's
output_devices() enumeration returns different strings entirely —
DeviceTrait::description() (friendly) and DeviceTrait::name() both
produce values that don't match what the UI persisted. Comparing them
marked every configured device as 'missing' on normal playback.

Removes the pre-flight emit_missing_if_needed() calls from
v2_play_track and v2_resume_playback so the false-positive toast
stops firing. The init_device fallback still routes audio to default
on a truly missing device — we just no longer surface that to the UI,
which is net neutral for the user vs. wrong notifications.

Keeps audio_device_watch module, DeviceMissingThrottle state and the
frontend listener in place so Level B (emit from inside the audio
thread when the fallback actually triggers) can reuse the plumbing
without another refactor. Hardens the presence check with
empty-enumeration = inconclusive and description()-based matching for
whenever Level B actually wires up a call site that has a comparable
device name at hand.

cc
2026-04-14 21:12:03 -06:00
vicrodh 6094876aa1 fix(player): pass durationSecs on session-restore first play so seekbar advances
Root cause traced from user diagnostic: only one playback:state event
fired (position=0), then nothing. Backend's current_position() returned
0 forever because thread_state.duration was 0 — the PlayStreaming
handler at player/mod.rs:1849 stores duration verbatim from the
command parameter and current_position() at player/mod.rs:789 ends
with .min(duration), clamping to 0.

Parameter names also didn't line up. cmdPlayTrack was invoking
v2_play_track with qualityStr and lowQualityFallback, neither of
which matches Tauri's camelCase mapping of the Rust signature
(quality, forceLowestQuality, durationSecs). Quality and force-fallback
were being silently ignored; durationSecs was never sent at all.

The normal queue playback path in playbackService uses the correct
camelCase names and passes track.duration, which is why the seekbar
worked everywhere except the session-restore first play — that path
goes through togglePlayback → cmdPlayTrack.

Fix:
- Correct the parameter names in cmdPlayTrack to match the backend
  (quality, forceLowestQuality, durationSecs).
- Add durationSecs as a first-class arg and pass it from
  togglePlayback when resuming a restored track.
- Drop the temporary [Diag:*] console logs added while investigating.

cc
2026-04-14 18:57:20 -06:00
vicrodh 77d54f99d2 fix(qconnect): always persist local session to preserve track-level restore (#304)
evaluateQconnectSessionPersistence now unconditionally returns
shouldPersist: true. Previously it refused to save the local session
whenever Qobuz Connect was in remote mode, which meant closing QBZ
with QConnect active discarded the current track + position. On next
launch the app fell back to whatever QConnect held, which is
playlist/release-level only.

QConnect-vs-local priority on restore is a separate concern and
belongs at the restore path, not here.

Tests updated.

cc
2026-04-14 18:11:18 -06:00
vicrodh b62a4b01f5 feat(audio): notify user when selected output device goes missing (issue #307)
Level A of issue #307. When the configured output device has
disappeared from the host device list at the time of play/resume
(KVM switched away, USB sink unplugged, PipeWire sink removed…)
the backend emits an 'audio:device-missing' event and the frontend
shows a warning toast:

  'Output device "XYZ" is no longer available. Using system default.'

The existing init_device fallback already routes audio to the system
default, so this is purely informational — the user now understands
why audio came out of a different sink instead of staring at a silent
progress bar.

Does NOT touch the sacred audio init path (pipewire_backend.rs,
init_device, audio_settings.rs). New module audio_device_watch.rs
adds a pure presence check, a new-state throttle so we don't emit on
every resume once the user's been notified, and a v2 command
v2_check_audio_device_presence so the frontend can poll on demand.

Ready for Level B (background watchdog) — the same helpers plug into
a timer without further refactor; Level B only needs to add a tokio
task that calls emit_missing_if_needed() every N seconds while
playback is active.

cc
2026-04-14 17:58:59 -06:00
vicrodh 7425d34e27 chore(assets): add laurel wreath SVG used by AwardView placeholder
Referenced by AwardView hero / Other Awards mini-card / AlbumDetailView
sidebar award avatar in commit d719fb67 but the asset itself was never
tracked. Ships the SVG so the placeholder renders after a clean clone.

cc
2026-04-14 17:33:05 -06:00
vicrodh d8fc85a071 fix(artist): dedup releases by id in convertPageArtist initial load
appendPageReleases already skipped releases whose id already lived in
artist.albums/epsSingles/liveAlbums/others, but convertPageArtist
(the first /artist/page response) pushed everything blindly. When
Qobuz returned the same release in multiple groups (e.g. both the
main category and the 'download' distribution bucket) we ended up
with side-by-side duplicates on the discography grid — reported by
user on Holly Humberstone's 'Cruel World' entry.

Adds a seenIds Set scoped to the whole response so a release is
assigned to exactly one category on first encounter and silently
dropped on subsequent references. totalAlbums is now counted per
unique accepted item instead of raw group size, so the header count
matches the rendered grid.

cc
2026-04-14 17:27:57 -06:00
vicrodh e6ed62657a fix(artist): replace gold QualityBadge with plain text to match AlbumView
The compact QualityBadge in Popular Tracks rendered a gold-gradient
chip that clashed with the rest of the track row. Homologates to the
same look AlbumDetailView uses in its TrackRow: plain 12px grey text
('24-Bit/96 kHz' / 'CD Quality'), centered in an 80px cell, no
coloured background.

Uses formatQuality() with bit-depth > 16 as the hires signal so the
output string is identical to what AlbumDetailView shows for the
same track.

cc
2026-04-14 17:21:09 -06:00
vicrodh d719fb6794 feat(award): swap trophy icon for white laurel wreath on gold gradient
Uses /static/laurels.svg as the default award artwork everywhere a
placeholder was rendering the lucide Award (trophy) icon:
- AwardView hero circle (140px, 68%)
- AwardView 'Other awards' mini-card (120px, 56%)
- AlbumDetailView sidebar award avatar (44px, 58%)

Laurel is rendered as an <img> filtered white (brightness(0) invert(1))
over the existing gold gradient. Awards that ship with their own
image/magazine cover keep theirs — the swap only affects the default
fallback.

cc
2026-04-14 16:26:37 -06:00
vicrodh 47963c0a4f fix(award+home): reload AwardView on awardId change; drop ribbon from Essential Discography
- AwardView stayed on the same activeView when switching between
  awards via the 'Other awards' carousel, so onMount didn't re-fire
  and the old award's data persisted. Replaces the onMount trigger
  with a  keyed on awardId: resets loading flags and calls
  loadHero/loadAlbums/loadOtherAwards whenever the prop changes.
- Essential Discography albums all carry the Ideal Discography award
  by definition — the ribbon is redundant on every card. Drops the
  ribbon prop from the two AlbumCard call-sites for that rail.
  Ribbons still appear on New Releases, Press Accolades, Most
  Streamed, Qobuzissimes, Album of the Week, Editor's Picks and
  Forgotten Favorites.

cc
2026-04-14 16:21:29 -06:00
vicrodh 7c2f2941fd feat(award): gold press ribbon + Other awards carousel on AwardView
1. Press ribbon restyled to a solid gold gradient (#f5c042 → #d49511)
   with dark (#1f1407) uppercase text and a subtle white text-shadow.
   Drops the red border-left. Qobuzissime / Album of the Week keep
   their dark-background treatment with coloured left accent.

2. AwardView now loads /award/explore on mount (first 30 entries,
   current award filtered out) and renders them as a horizontal
   'Other awards' carousel below the album grid — same layout as
   LabelView's 'More labels'. Each card shows the award laurel
   (or magazine cover when present) + name + magazine name; clicking
   routes into that award's AwardView.

i18n key award.otherAwards added for all five locales.

cc
2026-04-14 16:07:22 -06:00
vicrodh c4c4bdf244 fix(album-awards): accept LegacyAwardDto's awardId/awardedAt field names
Root cause of 'Discoteca Ideal Qobuz not clickable': /album/get
returns awards as LegacyAwardDto (per a20/c.java + LegacyAlbumDto
in the Android decompilation) whose id field is named awardId, not
id. Our AlbumAward struct only looked for 'id', so every award from
/album/get deserialized with id=None and the click fell back to the
catalog resolver — which was itself incomplete.

Adds serde aliases 'awardId' / 'award_id' for the id field and
'awardedAt' for awarded_at. Applies to both qbz-models (V2) and the
src-tauri legacy mirror.

Endpoint coverage:
- /discover/index  → {id, name, awarded_at}       (primary match)
- /album/get       → {awardId, name, awardedAt, …} (alias match)
- /artist/page     → {id, name, awarded_at}       (primary match)

cc
2026-04-14 16:02:08 -06:00
vicrodh 776e7846cd fix(award-catalog): exhaustive /award/explore pagination with diagnostic logs
Previous loop short-circuited when a page returned fewer than the
requested limit, but Qobuz may return a partial page with has_more=true.
Only stop when items is empty, has_more is explicitly false, or the
page repeats ids already seen (server ignoring offset).

Also bumps the sessionStorage key (v2) so prior truncated caches are
discarded, and logs every page (offset, returned count, has_more) plus
a summary (distinct ids, names, total items, pages) for debugging.

cc
2026-04-14 15:55:23 -06:00
vicrodh d75a8505b0 debug(album): log full /album/get payload; catalog can harvest award ids from album responses
- Console-dumps album and album.awards when AlbumView loads so we can
  see exactly what Qobuz returns for a given album (user reported
  Discoteca Ideal Qobuz still not resolving even after /award/explore
  cached 15 entries).
- Adds rememberAward / rememberAwards / rememberAwardsFromAlbums to
  the catalog store plus sessionStorage persistence. Wiring these
  into the data paths comes next once we know what shape /album/get
  actually returns for awards.

cc
2026-04-14 15:52:33 -06:00
vicrodh b7f45b2d95 refactor(award-catalog): remove hardcoded editorial seed, rely on /award/explore
User pushed back on the hardcoded list of Qobuz editorial award names
across locales. Removed the seed map entirely — the catalog is now
100% what Qobuz returns from /award/explore in the user's current
session locale, so id resolution always matches whatever labels the
API is currently emitting.

Kept:
- Cache-first lookup (hit the in-memory map before touching the API).
- Diacritic-insensitive + whitespace-agnostic normalization so accent
  or case differences don't miss a real match.
- Single fetch per session, paginated.

Added a console.log summary after the initial fetch and a warn when a
name misses the catalog so future 'X award doesn't open' reports are
debuggable without guessing.

cc
2026-04-14 15:37:07 -06:00
vicrodh 2dc9c143d5 feat(award-catalog): seed editorial Qobuz awards across locales + diacritic-insensitive lookup
'Discoteca Ideal Qobuz' was not resolving because /award/explore
either doesn't list editorial Qobuz awards or returns them under a
different locale's label. Adds a hardcoded seed of the three
stable-id editorial awards (88 Qobuzissime, 151 Album of the Week,
70 Ideal Discography) with their Spanish/English/German/French/
Portuguese aliases so they resolve instantly regardless of the user's
Qobuz locale.

Name normalization now strips diacritics (NFD + combining chars) and
collapses whitespace so 'Discoteca Ideal Qobuz',
'Qobuz Ideal Discography' and 'Discothèque idéale Qobuz' all map to
the same id.

Seed runs first on every lookup (sync). /award/explore only kicks in
for press awards the seed doesn't know about, once per session.

cc
2026-04-14 15:24:45 -06:00
vicrodh 6a5f9d0901 feat(award): add See all link and dedicated AwardAlbumsView
AwardView now shows a single grid of the first 20 albums with a See
all link in the section header (visible only when there are more
albums behind it). Grid layout stays — no horizontal carousel.

Clicking See all navigates to a new 'award-albums' view
(AwardAlbumsView) with a full paginated list: header with kicker +
title, top-right search, virtualized grid with load-more. Same
structure as LabelReleasesView for visual consistency.

cc
2026-04-14 15:12:46 -06:00
vicrodh 854eaaa0a4 feat(award): resolve award id by name via /award/explore when /album/get omits it
/album/get's AwardDto has id: String? — Qobuz sometimes returns an
award entry with only a name and no id. Rather than disabling the
click or toasting, we look up the id in the Qobuz award catalog.

- Adds v2_get_award_explore (paginated /award/explore) all the way
  through qbz-qobuz → core → bridge → command → invoke_handler.
- New frontend awardCatalogStore caches the full (id, name) list
  lazily, one fetch per session, resolved on first click that needs
  it. Further clicks are instant.
- handleAwardClick goes straight to navigation when id is present,
  falls back to resolveAwardIdByName otherwise. Toast only fires if
  the name isn't in the catalog either (genuinely unknown award).

cc
2026-04-14 15:06:15 -06:00
vicrodh 9460838223 fix(album-sidebar): keep award card always clickable; toast if id missing
Previously disabled={!onAwardClick || !award.id} grayed out the award
card whenever /album/get returned an award entry without an id. User
reported the award was 'no longer clickable' on albums opened from the
artist page. Now the button always accepts clicks and the handler
shows a toast when no id is available instead of silently doing
nothing — keeps the affordance honest without sending 'null' to
/award/page.

cc
2026-04-14 14:51:54 -06:00
vicrodh 037e18423a fix(award): normalize award.id as string end-to-end so /award/* calls stop sending 'null'
The log showed 'award=null' reaching /award/getAlbums and Qobuz
replying '400 Bad Request: Invalid argument: award_id (accepted type
are number)'. Root cause: AlbumAward.id was typed Option<u64> and
/album/get returns award ids as strings; serde dropped them to None,
frontend stringified null → 'null', we sent that as the query param.

Fix:
- AlbumAward.id becomes Option<String> with a custom deserializer
  that accepts both string and integer JSON values.
- TS types + AlbumDetailView Award interface + pickAlbumRibbon all
  use string id (compared as '88'/'151' now).
- Sidebar button is disabled when id is falsy; handleAwardClick
  early-returns on empty id so we never navigate to award 'null'.
- Better body/status logging in get_award_albums stays so future
  4xx responses are visible in the log.

Also switches /award/page and /award/getAlbums to signed_get_auth
(app_id + user token). Mobile does the same — catalog visibility is
region-scoped and the unsigned variant returns 400.

cc
2026-04-14 14:39:20 -06:00
vicrodh a273174716 feat(award): use /award/getAlbums for the grid + follow award as entity
Two behavior fixes for AwardView based on user feedback.

1. Grid content. The previous implementation rendered the releases
   array from /award/page, which is user-scoped (only surfaces awards
   on albums the user already favorited), so the grid came up empty
   in almost every case. Switch to /award/getAlbums which is the
   public catalog listing with pagination. /award/page is still
   called, but only to hydrate the hero (name, image, magazine).

2. Follow-the-award. Awards themselves are followable on Qobuz mobile
   (it's what feeds the 'Awards' tab of Release Watch). Add:
   - favorite_awards table + store methods in favorites_cache.rs
   - 4 V2 commands (get/sync/cache/uncache) mirroring the label ones
   - awardFavoritesStore.ts — clone of labelFavoritesStore with string
     ids (Qobuz mobile API passes award_ids as strings)
   - Follow/Unfollow heart button in AwardView hero, matching the
     ArtistDetailView / LabelView visual
   - loadAwardFavorites() wired into the login bootstrap alongside
     loadArtistFavorites / loadLabelFavorites
   - i18n keys (award.follow / award.unfollow)

Also adds a Ver más button for paginated loading of the album grid.

cc
2026-04-14 13:02:37 -06:00
vicrodh e52e3e1fdc fix(award): loosen AwardPageData and AwardMagazine for inconsistent Qobuz shapes
Matches Android's AwardDto where every field is nullable. Our struct
required id and name as String which made /award/page fail parsing
with 'missing field `id`' as soon as any award response deviated from
the happy path (e.g. a deprecated award, an id returned as integer,
or a reduced payload).

All top-level AwardPageData fields are now Option; id + awarded_at
accept string OR integer via a shared deserializer. AwardMagazine.id
gets the same treatment. Frontend TS type matches the looser shape.

cc
2026-04-14 12:47:33 -06:00
vicrodh 8730c891cc feat(award): dedicated AwardView with header matching Label/Album conventions
Adds the frontend for award detail pages. The hero header mirrors
LabelView's proportions 1:1 — 180px circular avatar left, inline
subtitle/name/magazine stack right, same 32px heading, same 12px
uppercase kicker, same 24px gap, same 40px bottom margin, same back
button treatment. Outer container uses the same paddings and scroll
behaviour as LabelView/AlbumDetailView.

Body pulls /award/page and builds one HorizontalScrollRow per
container (releases, new-releases, classics…). Container titles
localize via award.section.<id> with fallback to 'Acclaimed Releases'.

Wiring:
- 'award' added to ViewType and session restore map
- handleAwardClick in +page.svelte navigates to the view
- AlbumDetailView sidebar now passes onAwardClick so Phase 2 award
  cards become interactive
- Frontend AlbumAward.id is optional to match tolerant backend struct;
  sidebar disables the award button when id is absent
- i18n keys added in all five locales

cc
2026-04-14 12:37:41 -06:00
vicrodh ee2f413ec1 fix(album): use dedicated tolerant AlbumAward struct instead of mutating PageArtistAward
Previous attempt loosened PageArtistAward (id u64 -> Option<u64>,
awarded_at custom deserializer) to accept /album/get's inconsistent
award shape. That rippled into /artist/page consumers, which the user
rightly called out ('no eliminar lo que ya funcionaba, adaptar pues').

Restores PageArtistAward to its original shape (id: u64, name: String,
awarded_at: Option<String>) and introduces a parallel AlbumAward type
used only on DiscoverAlbum and Album. AlbumAward tolerates missing id,
accepts awarded_at as int or string, and never fails deserialization
of the parent payload. /artist/page keeps its strict contract.

cc
2026-04-14 12:30:18 -06:00
vicrodh fe3213ece5 feat(award): backend V2 commands for /award/page and /award/getAlbums
Backend plumbing for Phase 3 AwardView. New endpoints:
- v2_get_award_page(awardId)       → hero + categorized releases
- v2_get_award_albums(awardId, …)  → paginated album list

Mirrors Android's AwardDto shape (id, name, image, awarded_at,
magazine, releases, playlists). Follows the label/page pattern for
signing and error handling. Deserializer accepts the V2 generic
{has_more, items} envelope as well as the legacy search-style
{albums: {items, total}} in case Qobuz swaps shapes per account.

cc
2026-04-14 12:12:01 -06:00
vicrodh f0b4499dab fix(album): accept awarded_at as integer or string on PageArtistAward
/album/get returns award.awarded_at as a Unix timestamp integer while
/discover/index returns it as a YYYY-MM-DD string — our struct only
accepted String, so /album/get started failing with
'invalid type: integer `…`, expected a string' as soon as awards were
added to Album in Phase 2. Every 'Load album' call broke.

Adds a custom deserializer that accepts both shapes and normalizes to
String downstream. Applied in both qbz-models (V2) and src-tauri legacy
api::models for symmetry.

cc
2026-04-14 11:28:19 -06:00
vicrodh 7bf9972846 feat(album-view): right sidebar with label and full awards stack
Ports the Qobuz web 'label + awards' sidebar pattern to AlbumView.
Track list and sidebar sit side-by-side above 1100px and collapse
vertically below it. Label card navigates to the existing LabelView.
Award cards call onAwardClick when provided (disabled until Phase 3
lands AwardView).

Wiring:
- Adds awards (Vec<PageArtistAward>) to both qbz-models Album and
  src-tauri api::models Album so /album/get payloads hydrate the
  field instead of silently dropping it.
- Adds awards to QobuzAlbum and AlbumDetail frontend types plus the
  convertQobuzAlbum adapter.
- New album.sidebar i18n keys in all five locales.

Card ribbon still shows only the LAST award (Phase 1 behavior);
sidebar renders every award as a tap target — mobile-style.

cc
2026-04-14 11:11:24 -06:00
vicrodh ef9cab64bc feat(ribbons): extend album ribbon to press accolades (last award wins)
Changes the AlbumCard ribbon prop shape from a union string to
{kind, label} so press accolades (Pitchfork BNM, Rolling Stone 5★,
Gramophone Editor's Choice, …) can render with their actual award
name rather than a pre-defined string. pickAlbumRibbon now picks the
LAST entry in album.awards[] (per product direction) and classifies
it as qobuzissime (id 88), albumOfTheWeek (id 151) or press (anything
else). Press variant gets a red border-left to differentiate from the
Qobuz-branded awards. Full award stack will render in AlbumView's
sidebar (Phase 2).

cc
2026-04-14 11:05:00 -06:00
vicrodh ebeb86d03d feat(release-watch): dedicated tabbed view mirroring Qobuz mobile
Ports the Qobuz mobile 'Radar de Novedades' / Release Watch screen 1:1
(see rr0/i.java): a standalone view with three lazy-loaded tabs —
Artists, Labels, Awards — each hitting /favorite/getNewReleases with
the matching type parameter (limit 150 per tab, matching the mobile
cap).

Home's Release Watch rail (which still shows the Artists feed by
default, matching the mobile's default tab) now has a 'See all' link
that routes to the new view.

Adds view 'discover-release-watch' to the navigation type, wires the
route in +page.svelte, and adds tab/empty-state i18n keys in all five
locales.

cc
2026-04-14 10:52:20 -06:00
vicrodh 060395885b fix(release-watch): backfill album.artist from artists[0] on getNewReleases
AlbumDto from Qobuz carries both legacy singular 'artist' and current
'artists[]' array. /favorite/getNewReleases omits the singular field
and only populates the array, so our Album struct (which has a default
serde Artist) deserialized an empty name — every Release Watch card on
Home read 'Unknown Artist'.

Pre-process the JSON response items before deserializing: when 'artist'
is absent or null, copy artists[0] into it. Keeps the rest of the
album-list plumbing untouched.

cc
2026-04-14 10:40:42 -06:00
vicrodh de056abd8a fix(home): move album ribbon to bottom-left and let overlay cover it on hover
User asked explicitly for the ribbon to sit below the hover overlay;
misread the original request and put it above. Relocates ribbon to
bottom:8px left:0 and drops z-index to 1 so .action-overlay (z-index 2)
covers it on card hover.

cc
2026-04-14 10:34:03 -06:00
vicrodh b738c27507 fix(home): plumb album awards through backend + invalidate stale home cache
The ribbon feature relied on album.awards arriving on the frontend, but
the DiscoverAlbum Rust struct in both qbz-models and src-tauri/api was
missing the 'awards' field, so serde silently dropped it during
deserialization of /discover/index. Result: frontend always saw an
empty awards array and pickAlbumRibbon returned undefined.

Also bumps the home sessionStorage cache to schemaVersion 2 so pre-fix
caches (which persisted AlbumCardData without ribbon) are discarded on
next launch instead of rehydrating with missing fields.

cc
2026-04-14 10:32:28 -06:00
vicrodh 89c9ee1028 feat(home): add editorial ribbons for Qobuzissime and Album of the Week
Adds a small top-left ribbon on AlbumCard that surfaces Qobuz's two
locale-stable editorial awards: 'Qobuzissime' (award id 88, purple) and
'Álbum de la semana Qobuz' (award id 151, yellow). The ribbon is
z-index 4, above the hover action-overlay (z-index 2), so it stays
visible when the user hovers the card.

HomeView maps every /discover/index rail via discoverAlbumToCardData
through a new pickAlbumRibbon(awards) helper that matches by stable
award id rather than translated name. Essential Discography and
Forgotten Favorites (which render DiscoverAlbum raw) call
pickAlbumRibbon inline.

i18n keys added to all five locales under 'ribbon.*'.

cc
2026-04-14 10:22:14 -06:00
vicrodh dcea5fe6b2 fix(labels): drop label-card background hover so follow-btn hover is visible
The card hover painted the same bg-tertiary that the follow button uses
on hover, so mousing over the button produced no visible state change
because the card background already matched it. Removes the card-level
background transition; the cursor:pointer is enough to signal the card
is clickable, and the button now shows its hover distinctly against the
plain background.

cc
2026-04-14 10:12:30 -06:00
vicrodh 8ca2accb9c fix(labels): replace pill with 6px rounded rectangle for label follow btn
Aligns the 'More labels' Follow/Following button with the existing
artist '.follow-btn' style in ForYouTab (6px border-radius, 14px
horizontal padding, border-primary / text-secondary). No more pills
anywhere in the app.

cc
2026-04-14 10:04:47 -06:00
vicrodh d98c893878 fix(foryou): wait for topArtists/recentAlbums before loading dependent rails
Previously onMount fired a single 'forYouLoaded' guard that launched
loadArtistsToFollow, loadSpotlight and loadSimilarAlbums immediately —
but each of those functions early-returns when its input prop is empty,
and the guard prevented re-running once the prop arrived. Users who
switched to the For You tab before HomeView finished resolving topArtists
would see these rails stay permanently blank (no 'Artists to Follow',
no Spotlight, no 'Similar to').

Replaces the single guard with per-section guards and $effects that
react to topArtists / recentAlbums becoming non-empty. Sections with no
prop dependencies (forgotten, essentials, release watch) still fire
once at mount.

cc
2026-04-14 09:59:28 -06:00
vicrodh 8b0bd820d9 feat(labels): replace heart overlay with pill Follow button below card
Swaps the top-right heart-in-circle on 'More labels' cards for a
pill-shaped Follow/Following button underneath the label name, matching
the Qobuz mobile app's affordance. Uses UserPlus icon + short label
('Sigue' / 'Follow' / etc.) when not followed, Check icon + 'Siguiendo'
when followed. Adds 'label.followShort' and 'label.followingShort'
i18n keys for all five locales.

cc
2026-04-14 09:42:02 -06:00
vicrodh 79e0f93b8f fix(home): persist Release Watch in home cache so it renders on revisits
Release Watch is fetched from a REST endpoint separate from /discover/index,
so it lives in its own state (releaseWatchAlbums / loadingReleaseWatch).
HomeCacheData did not include it, so restoreFromCache on a 'fresh' hit
populated every other section but left the Release Watch row stuck on its
initial loading=true / empty-items state, showing dark skeleton boxes.

Adds releaseWatchAlbums to HomeCacheData, writes it in setHomeCache,
reads it back in restoreFromCache, and resets loadingReleaseWatch=false
there so the row either renders populated or hides itself.

cc
2026-04-14 09:38:48 -06:00
vicrodh ebe999c43d feat(favorites): add Labels tab to Favorites view
Introduces 'labels' as a first-class favorites tab alongside
tracks/albums/artists/playlists. Adds the navigation view
'favorites-labels', plumbs it through sidebar, titlebar dropdown,
favorites preferences modal and navigation store, and renders a
circular grid of followed labels with search and empty states.
Clicking a card navigates to the existing LabelView detail page.

Spanish copy uses 'Sellos' and Portuguese 'Selos' to match
Qobuz mobile terminology.

cc
2026-04-14 09:28:34 -06:00
vicrodh 50f5fd7778 feat(labels): add follow heart overlay to more-labels cards in LabelView
Changed label-card from <button> to <div role=button> (nested buttons
are invalid HTML) and overlaid a 28x28 heart button on each card image.
Heart is hidden by default, visible on hover/focus, and stays visible
when the label is already followed.

cc
2026-04-14 09:09:00 -06:00
vicrodh a9a9ba5050 feat(labels): follow / unfollow labels (mirrors Follow Artist)
Adds the same follow flow we already have for artists, applied to
labels. Confirmed against the inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml) — the
existing /favorite/create, /favorite/delete and
/favorite/getUserFavorites endpoints already accept fav_type='label'
and label_ids, so no new Qobuz endpoints were needed.

Backend
- favorites_cache: new favorite_labels SQLite table + add / remove /
  sync / get methods alongside the artist counterparts. clear_all
  drops the new table on logout.
- commands_v2::favorites: 4 new commands —
  v2_get_cached_favorite_labels, v2_sync_cached_favorite_labels,
  v2_cache_favorite_label, v2_uncache_favorite_label. Same wiring as
  the artist commands.
- lib.rs invoke_handler registers the four new commands.

Frontend
- src/lib/stores/labelFavoritesStore.ts: clone of
  artistFavoritesStore — same in-memory Set, same isLoaded flag,
  toggle calls /favorite/create with fav_type=label, sync paginates
  /favorite/getUserFavorites?type=labels.
- routes/+page.svelte: loadLabelFavorites() called alongside the
  other three after login is confirmed.
- LabelView.svelte: heart-shape Follow button in the label header
  info column, mirroring ArtistDetailView's .artist-actions
  treatment. Reactive to the store via labelFavoritesVersion bump
  so any future Favorites view that toggles a label will keep this
  view in sync.
- i18n: label.follow / label.unfollow in all five locales.

cc
2026-04-14 09:00:11 -06:00
vicrodh b9a1f9be70 Merge bugfixex/1.2.5 — feature work & issue fixes
Accumulated branch of bugfixes and features targeted for the 1.2.5
cycle. Rolls up:

Issues & bug fixes
- #288 Vanatoo / DAC sample-rate fallback: quality downgrade cascade,
  plughw software-resample fallback, clearer CPAL error, BitPerfect
  mode surfaced in PlaybackEvent and QualityBadge
- #303 playback controls overlap on narrow windows: compact badge
  variants, hamburger overflow menu, XP-style vertical volume popup
- #310 tray left-click toggle: custom ksni SNI implementation on
  Linux (Tauri's libayatana-appindicator backend can't dispatch
  primary-click), Tauri tray kept on macOS
- #279 offline mode: streaming setting snapshot/restore, explicit
  TrackNotAvailableOffline refusal, [V2/CACHE STALE] warnings

Quality-of-life
- auto-updater plumbed end-to-end (tauri-plugin-updater behind a
  Cargo feature so sandboxed Flatpak / Snap builds skip it) and
  signing wired into release workflows
- QconnectBadge/QualityBadge compact variants
- refresh audio output badges when playback starts
- window size clamped to the largest available monitor at startup
- remove silk animations from Your Mixes covers (HomeView + ForYouTab)
- Release Watch (/favorite/getNewReleases?type=artists) integrated
  as a new section in Home and For You

cc
2026-04-14 08:34:21 -06:00
vicrodh 40ba414486 feat(discover): move Release Watch to below Your Mixes + add home.releaseWatch i18n key
HomeSettingsModal renders the section label with $t(`home.${section.id}`),
so the home.releaseWatch key was missing and the settings row showed
the raw key. Added it to all 5 locales (Spanish 'Radar de Novedades'
per the iOS screenshot; other languages match Qobuz's own strings
from the Android resources).

In ForYouTab, the section is now placed immediately after Your Mixes
instead of before Essentials — personal-feed sections (DailyQ / WeeklyQ
/ FavQ / TopQ + followed-artist releases) now sit together at the top.

HomeView section title switched to $t('home.releaseWatch') to match
the naming convention other sections already use.

cc
2026-04-14 08:22:23 -06:00
vicrodh 37c64d252c fix(api): project /favorite/getNewReleases envelope to SearchResultsPage
The endpoint returns V2GenericListDto<AlbumDto> which is just
{has_more: bool, items: [...]} — no total/offset/limit. Our caller
wants SearchResultsPage<Album> to stay compatible with the rest of
the album-list flow, so map items through directly and synthesise
total from has_more + items.len() + the offset we asked for.

Endpoint itself returns 200 OK with a real feed now (confirmed in
logs with Godsmack etc. on the test account).

cc
2026-04-14 08:01:30 -06:00
vicrodh ce375008ab fix(api): Release Watch lives at /favorite/getNewReleases?type=artists
The inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml, source:
x20/b.java) documents the endpoint as /favorite/getNewReleases with
three required query params:

  @GET favorite/getNewReleases
  - type: String   (artists | labels | awards — matches the three
                    tabs in the mobile UI)
  - limit: Integer
  - offset: Integer

My earlier attempts at /albums/releaseWatch were chasing the Android
route-table entry for the Release Watch *screen*, not the HTTP call
that screen makes. Different layer.

Also removes my made-up speculation about mobile app_id gating — the
/favorite/* endpoints are standard web-API calls the existing client
already knows how to sign (via authenticated_headers + /0.2/ base URL).

v2_get_release_watch now takes an optional releaseType arg defaulting
to 'artists' so callers can switch tabs later without another command.

cc
2026-04-14 07:47:42 -06:00
vicrodh 541c3613c9 chore(api): promote release-watch API logs to info for diagnosability
The debug-level log lines for status + response keys + raw body never
printed at default log level, leaving zero observability on the call.
Bumped to info and added body capture so a non-success status or a
deserialize mismatch dumps the first 400-600 chars, which will tell
us whether the endpoint returned {albums:{items:[]}}, {items:[]}, an
auth error, or something else entirely.

cc
2026-04-14 07:26:59 -06:00
vicrodh b0e724d428 fix(api): call /albums/releaseWatch as REST, not signed RPC
Third try's the charm. The previous attempt called the endpoint via
signed_get_auth (RPC-style with request_sig + request_ts), which
yielded 'no Service found with those values' — the RPC dispatcher
simply doesn't know this path.

The /albums/* plural namespace (releaseWatch, favoriteAlbums,
purchasedAlbums, similarTo, essential, artistAlbums, discoverAlbums)
is REST-style, not RPC. QBZ already uses the same pattern for the
/radio/* endpoints in src-tauri/src/api/client.rs: plain
.get(url).headers(authenticated_headers()).query(...) with X-App-Id
and X-User-Auth-Token, and no signing. Same pattern here.

Also reverted the release_watch container addition to
DiscoverContainers — /discover/index does not return it alongside
the editorial sections, confirmed by the empty result when reading
from there even with follows set.

Radio and Artist Spotlight work in QBZ via this same REST + auth
headers pattern despite being 'mobile' features; Release Watch
should too.

cc
2026-04-13 23:23:07 -06:00
vicrodh 60e0861e1d refactor(discover): read Release Watch from /discover/index response
The initial implementation added a dedicated get_release_watch client
method calling /albums/releaseWatch, but that path is only routed on
the mobile API; the web API (api.json/0.2) that qbz-qobuz speaks to
returned 'no Service found with those values'.

The correct source is already available: /discover/index — which we
were already calling for newReleases/pressAwards/qobuzissimes/etc —
also returns a release_watch container when the user has follows. We
were silently dropping it because DiscoverContainers didn't declare
the field.

Now:
- DiscoverContainers (Rust + TS mirror) declares the release_watch
  field with a serde alias that accepts both camelCase and snake_case
  spellings, so future naming churn upstream is tolerated
- HomeView reads it in fetchAllDiscoverData next to the other editorial
  sections (one less network round-trip)
- ForYouTab calls v2_get_discover_index and extracts only the
  release_watch container
- Reverted the custom /albums/releaseWatch endpoint, client method,
  core passthrough, bridge wrapper and v2_get_release_watch command

Section identity (homeSettingsStore id, i18n keys, render blocks)
is unchanged.

cc
2026-04-13 22:49:34 -06:00
vicrodh 9945e93e0c fix(api): tolerate unknown release watch response shape
First iteration hard-parsed the /albums/releaseWatch response as
SearchResultsPage<Album>, which failed with 'missing field items'
because the wrapper key (albums / releaseWatch / release_watch /
bare items) differs across Qobuz API deployments and is not
publicly documented.

Now walks a candidate list of wrapper keys, accepts both
{items: [...]} objects and bare arrays, falls back to root-level
parsing, and finally logs the response shape + returns an empty
page rather than propagating an error that would kill the whole
Discover screen.

cc
2026-04-13 22:37:28 -06:00
vicrodh ea216adf0f feat(discover): add Release Watch section to Home and For You
The Qobuz mobile client (v9.7) surfaces a 'Release Watch' /
'Radar de Novedades' feed in its Discover screens — new releases
from the artists, labels and award series the user follows. We
didn't have parity for it. This adds it as a module in both
Discover tabs.

Backend:
- qbz-qobuz: new ALBUMS_RELEASE_WATCH endpoint constant
  (/albums/releaseWatch, plural as in mobile) and signed_get_auth
  client method returning a SearchResultsPage<Album>. Signing
  method is 'albumsreleaseWatch' per the Qobuz secret protocol.
- qbz-core: passthrough get_release_watch on QbzCore.
- src-tauri: core_bridge wrapper, new v2_get_release_watch Tauri
  command registered in lib.rs. Filters through the artist
  blacklist like other album endpoints.

Frontend:
- HomeView: new renderable section 'releaseWatch' in the home tab
  (default visible, placed right after New Releases). Parallel
  fetch alongside the existing discover_index call; mapped via
  the existing toAlbumCard helper.
- ForYouTab: section appears ahead of 'Essentials' since followed
  artists/labels are typically more personal.
- homeSettingsStore: new HomeSectionId 'releaseWatch' in the type
  union and DEFAULT_SECTIONS so the user can toggle/reorder it
  from home customization.
- i18n: discover.releaseWatch.title + subtitle added to all five
  locales, using Qobuz's own translations where they exist
  ('Radar de Novedades' for Spanish, 'Release-Radar' for German)
  with the 'followed artists/labels/awards' subtitle copy adapted
  from the mobile app's strings.xml resources.

cc
2026-04-13 22:33:59 -06:00
vicrodh 808b9db0db fix(offline): real offline mode — snapshot streaming, block network, diag logs (#279)
Addresses the reported behavior where tracks marked available offline
still buffered from Qobuz after the v1.2.2 upgrade. Three layers:

1. **Snapshot / restore stream-first-track.** offline_settings gains a
   new nullable column pre_offline_stream_first_track (SQLite ALTER
   migration). v2_set_manual_offline now coordinates with
   AudioSettingsState: entering offline mode stashes the user's current
   stream_first_track preference and forces it to false; exiting
   restores the snapshot. User's preference survives the round-trip
   even across app restarts.

2. **Explicit offline refusal.** v2_play_track now takes
   OfflineState, reads manual_offline_mode up front, and — when every
   local cache miss has been exhausted — returns a new
   RuntimeError::TrackNotAvailableOffline instead of silently reaching
   for the network. Low-quality offline hits still play (better than
   nothing). The playback path also masks stream_first_enabled while
   offline as a belt-and-suspenders defense.

3. **Cache-miss visibility.** Every call site that consults the
   offline cache DB now logs [V2/CACHE STALE] at warn level when the
   DB says a track is cached but the filesystem path is missing. This
   made the issue #279 behavior invisible; the orphan entries from
   per-user isolation migration (aab3e6764) are now diagnosable from
   logs.

Frontend:
- playbackService surfaces TrackNotAvailableOffline as a toast ('…
  isn't saved for offline playback') instead of the generic error
- SettingsView listens for a new audio-settings-changed event so the
  stream-first-track toggle in the UI flips when the offline toggle
  mutates it
- i18n key offline.trackNotAvailableOffline added to all 5 locales

cc
2026-04-13 22:13:16 -06:00
vicrodh 722696df34 fix(window): clamp persisted size to fit the largest available monitor
The old Linux-only guard only rejected absurd sizes (>8K) as corruption
sentinels; anything under that was passed through to the builder
verbatim. So a size saved on a 4K display would open on a 1080p display
at full 4K dimensions — off-screen, unreachable when decorations are
hidden.

Added a second clamp inside setup() that queries app.available_monitors()
after Tauri has the display connection, takes the largest logical size
across all connected monitors, and shrinks the persisted size to 95% of
that when it overflows. The original 8K corruption guard stays as a
first pass (runs before setup, catches corrupt DB rows before we even
open a display connection).

Works on all platforms — no cfg gating, since 'saved size bigger than
any monitor' is a user-visible problem everywhere, and the 8K guard
also protects macOS/Windows against a corrupt settings row.

cc
2026-04-13 21:51:40 -06:00
vicrodh 10bc0eb660 feat(tray): use ksni on Linux for working left-click toggle (#310)
Tauri's default tray uses libayatana-appindicator, which by design does
not dispatch primary-click events to its client — libayatana delegates
every left-click to the libdbusmenu-managed menu. The existing
show_menu_on_left_click(false) setting and TrayIconEvent::Click handler
never fire on Linux. Confirmed present in tray-icon 0.21.x and 0.22.0
(the newest release).

Switch the Linux backend to ksni, a pure-Rust StatusNotifierItem
implementation that exposes the full SNI protocol including Activate
(left-click), SecondaryActivate (middle), and ContextMenu. Same SNI
compatibility as the previous backend — works wherever the panel
already handled our tray (KDE, GNOME+AppIndicator, XFCE/Cinnamon/MATE,
wlroots trays). Flatpak just needs the --talk-name=org.kde.
StatusNotifierWatcher permission which is already in our manifest.

Tauri's tray stays on macOS (no Windows client is shipped). The
dispatch lives in tray.rs behind cfg(target_os); Linux delegates to
the new tray_linux_ksni module. Playback-control menu events
(tray:play_pause, tray:next, tray:previous) still emit into the main
webview window, so frontend handlers keep working unchanged.

cc
2026-04-13 21:41:20 -06:00
vicrodh 247a565acd fix(ui): remove silk animations in ForYouTab mix cards
Missed ForYouTab.svelte in the previous pass — it has its own copy of
the mix-gradient-*::before + @keyframes silk-* definitions used by
the 'Your Mixes' row inside the For You tab. Same treatment: drop
the 4 animation properties and the 4 keyframe blocks, keep the
static gradients and remove the will-change: transform hint.

cc
2026-04-13 20:56:20 -06:00
vicrodh 40f1aa3442 fix(ui): remove silk animations from Your Mixes covers
The silk-daily/weekly/favq/topq keyframe animations on DailyQ, WeeklyQ,
FavQ and TopQ cover art (and their HomeView preview cards) were
practically invisible — 30s transforms of ±8% translate + small
rotation are below most users' motion perception thresholds — but
produced visible glitches on some hardware when the transform
compositor layer got evicted under memory pressure (e.g. during
scroll with many visible mix cards).

Dropping the animation, keeping the static silk gradients. The
will-change: transform hint goes too since there's nothing animating
anymore.

cc
2026-04-13 20:44:01 -06:00
vicrodh 0898340aae fix(ui): keep QconnectBadge compact at full bar height
Previously the compact variant set height: 36px, visibly shorter than
the quality badge + audio badges stack next to it. Only the width
needed to shrink; height inherits from the parent (100%) like the
full variant so the badge aligns with its neighbors.

cc
2026-04-13 20:38:00 -06:00
vicrodh 014af6fbfb fix(build): stop infinite tauri dev rebuild loop on auto-updater.json
The build script unconditionally rewrote capabilities/auto-updater.json
on every run, which bumped the file's mtime. tauri-build emits
cargo:rerun-if-changed=capabilities so any touch in that directory
re-runs this script, and 'tauri dev' also watches that folder for
source reloads. The two watchers fed each other: compile → rewrite
(same bytes, fresh mtime) → cargo marks build.rs dirty → recompile
→ tauri dev sees mtime change → request another build → repeat.

Now we read the existing file first and only write when the content
actually differs. mtime stays stable when the generated text is
identical across runs, so neither cargo nor tauri dev observe a
change and the loop terminates.

cc
2026-04-13 20:27:44 -06:00
vicrodh 568dc31df9 fix(ui): collapse right-section to hamburger at narrow widths (#303)
When the now-playing bar is narrower than 1100px (reported overlap at
half a 1866px display), the right-section now collapses into two
elements:

- An overflow button (…) that opens a popup menu containing Cast,
  Qobuz Connect, Lyrics, Mini-player, Full-screen, and Normalization.
- A mute/volume button that opens a vertical XP-style slider popup
  upward; the horizontal inline slider is only rendered at wide
  widths.

Detection uses a ResizeObserver on the bar element so the breakpoint
reacts to the actual space the bar receives (sidebar state, window
chrome, etc.) rather than a viewport media query. In the same narrow
mode QualityBadge and QconnectBadge switch to their compact variants
so the badges remain visible without the controls overlapping.

Queue button and offline/degraded indicators stay visible in both
layouts. Volume lock (ALSA hw:) keeps the existing inline disabled
variant so the 100% indicator is always visible.

i18n key player.moreControls added for all 5 locales.

cc
2026-04-13 20:14:16 -06:00
vicrodh 160dc7a52a feat(ui): compact variants for QualityBadge and QconnectBadge
QualityBadge.compact now keeps the icon + color tier identity and the
downgrade/bit-perfect indicators; drops the tier-label row and shortens
'24-bit / 96 kHz' to '24/96' so the badge fits ~70px. The old plain-
text compact mode (inline tracklist usage) is replaced by this richer
variant.

QconnectBadge.compact is a 36x36 icon-only button; popup and color
behavior are unchanged. Hover title still surfaces the full state.

Both variants are opt-in via a 'compact' prop so existing callers are
unaffected; NowPlayingBar will flip them when the bar collapses (next
commit).

cc
2026-04-13 20:14:07 -06:00
vicrodh cc21a82d6c fix(ui): refresh audio output badges when playback starts
AudioOutputBadges (DAC/HW/EXC/CAST) used to update only on mouseover
or during the 1 Hz polling loop — which itself was gated to
dac_passthrough or ALSA, so PipeWire users without passthrough never
saw the active device reflect a new stream.

The comment in the file noted that an earlier $effect watching
is_playing caused an infinite loop because loadStatus() writes
outputStatus, which re-triggered the effect. That's fixed here by
subscribing to the player store with manual edge detection: we only
call loadStatus() when is_playing transitions false→true or when
track_id changes. Neither signal is written by loadStatus(), so
there is no loop.

cc
2026-04-13 19:50:59 -06:00
vicrodh 33761c6462 feat(ui): surface BitPerfectMode in QualityBadge (#288)
QualityBadge now has three new visual states wired off the
bit_perfect_mode field the backend emits in PlaybackEvent:

- DirectHardware → subtle green check next to the tier label,
  border tinted green. Reassurance that audiophile listeners are
  getting the stream untouched.
- PluginFallback → purple down-arrow + new tooltip explaining the
  DAC doesn't support the source rate so ALSA (plughw) is doing
  software resampling. The tooltip's suggested fix is explicit
  about the mitigation (pick lower-rate source or a broader DAC)
  since this is a hardware-capability constraint, not a user
  setting issue.
- Disabled / null → no change.

Plumbed through the existing playerStore subscription pattern so
the indicator reflects the stream the moment it opens. i18n keys
added for all 5 locales (en/es/de/fr/pt).

cc
2026-04-13 19:38:06 -06:00
vicrodh 8311cef821 feat(player): expose BitPerfectMode in PlaybackEvent (#288)
Adds a bit_perfect_mode field to SharedState and PlaybackEvent so the
frontend can display whether the active stream is:

- DirectHardware — ALSA hw: direct access (bit-perfect guaranteed)
- PluginFallback — ALSA plughw: with software resample (bit-perfect
  lost; happens when the DAC doesnt support the stream's sample
  rate, e.g., the Vanatoo case in #288)
- Disabled — shared system path (pipewire/pulse/CPAL)

try_init_stream_with_backend now takes the SharedState so it can
record the mode the moment the stream is successfully opened,
whether via ALSA direct or the CPAL/Rodio fallback.

cc
2026-04-13 19:33:03 -06:00
vicrodh 9c28960600 fix(audio): clearer error when CPAL cannot open an enumerated device (#288)
The old 'Device not found' error fired for two very different
situations: the device truly missing (unplugged, renamed) vs. present
in /proc/asound but not loadable by CPAL at the requested sample rate
or by the stored name format. The latter sent users on wild goose
chases.

Now we distinguish the cases: if /proc/asound still reports the card,
the message points at the likely causes (rate/format mismatch or
ALSA name mismatch) and suggests the plughw plugin, which is the
workaround we already apply automatically in Fix 2.

cc
2026-04-13 19:20:35 -06:00
vicrodh cba5b76bb3 fix(audio): fallback to plughw instead of CPAL when DAC rate unsupported (#288)
When /proc/asound reports that the selected hw: device does not
natively support the track's sample rate (e.g., 96 kHz track on a
48 kHz-only Vanatoo Transparent Zero), the previous code returned
None from try_create_direct_stream. The caller then dispatched to
CPAL's create_output_stream, where the CPAL ALSA host could not
locate the device by its /proc/asound name and failed with the
misleading error 'Device not found'.

The same function already has a plughw: fallback path further down
that wraps the hw device with ALSA's plug plugin — the latter
resamples in ALSA space and is the correct behavior for rate
mismatches. This change makes the /proc/asound pre-check set a
local flag that skips the hw attempt, falling through to the
existing plughw branch instead of returning None.

Sound now comes out. Bit-perfect is lost (by necessity) and the
mode reported back to the player is PluginFallback, which lets the
UI surface the degradation (wired up in Fix 4).

cc
2026-04-13 19:17:59 -06:00