Merge pre-release into main for v1.2.7 'Accolade Watch (diagnostics + carousel fix)'

This commit is contained in:
vicrodh committed 2026-04-16 12:03:29 -06:00
commit 4d7fb8a4a4
32 files changed
+1673 -90

No files matched your search

+5 -5
View File
@@ -3209,9 +3209,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
@@ -3269,7 +3269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
"rand 0.10.0",
"rand 0.10.1",
]
[[package]]
@@ -3417,7 +3417,7 @@ dependencies = [
"cpal",
"dasp_sample",
"num-rational",
"rand 0.10.0",
"rand 0.10.1",
"rand_distr",
"rtrb",
"symphonia",
@@ -4187,7 +4187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
+147
View File
@@ -0,0 +1,147 @@
# 1.2.7 — Accolade Watch (diagnostics + carousel fix)
Two things drove this release, both of them corrections:
A defensive fix for the Discover carousel click-through regression introduced in 1.2.6: the mouse-drag scroll used implicit pointer capture on `pointerdown`, which on some WebKitGTK + compositor combinations retargeted the subsequent click to the scroll container instead of the card — so opening albums, artists, or playlists from Home / Editor's Picks / For You quietly stopped working. Capture is now deferred until a real drag is in progress; normal clicks fall through cleanly.
A big expansion of **System Diagnostics**: the one-shot snapshot now reports OS / distro / install method / key library versions (WebKitGTK, GTK, glibc, ALSA, PipeWire, PulseAudio), live playback state, the Qobuz Connect role (controller / local renderer / observer), and — opt-out toggle in the View Logs modal — an optional 10-second Chromecast + DLNA scan, all bundled into the same paste upload. Bug reports can ship one link instead of three, and no sensitive data (no track/user/device IDs) ever reaches the paste.
My apologies for stacking three releases where one would have been enough — I misjudged the scope and timing of each batch.
Everything from 1.2.6 and 1.2.5 below is included as well.
---
A quick follow-up on the heels of 1.2.5. Two things drove this release:
- A long-overdue **accessibility pass**: four new WCAG-compliant themes (including a true high-contrast mode for severely vision-impaired users) and mouse-drag scrolling on every horizontal album row.
- A **regression rescue**: Infinite Play (the ∞ toggle in the queue) had silently lost its backend wiring during the V2 migration — the toggle persisted in localStorage but the queue never refilled and the mode was never sent to the backend. Reimplemented from scratch in V2 with no legacy wrappers.
Everything from 1.2.5 below is included as well; if you're upgrading from 1.2.4 or earlier, this is what you're getting.
---
## What's new in 1.2.6
### Accessibility (discussion #313)
- **Four new WCAG-compliant themes** in Settings > Appearance > Theme:
- **WCAG Light** — AAA contrast (7:1+) on every text level, blue accent
- **WCAG Dark** — AAA contrast on dark backgrounds
- **High Contrast** — pure white on pure black with yellow accent (21:1 ratio). Built explicitly for severely vision-impaired users
- **Colorblind** — deuteranopia-safe palette (Wong colors), no red/green adjacency
- **Mouse-drag scrolling on horizontal album rows** — click and drag sideways on Home, For You, Award and Label views, just like on iPad or in a browser. Arrow buttons still work as before
- **Qobuz Connect badge always visible** (#318) — even with an empty queue (e.g. on app start) you can connect to a remote renderer instead of having to start playing something first; the badge keeps its size across responsive breakpoints
### Regression fixes
- **Infinite Play (∞) restored** (#319) — the toggle now persists across restarts and refills the queue with related tracks (Qobuz `/radio/artist`, seeded from your last played tracks) when the queue ends, instead of stopping. V2 reimplementation
- **Gentoo `qbz-bin` ebuild** — now correctly inherits `xdg-utils`, so the hicolor icon cache and the desktop database are refreshed on install/uninstall. This was the cause of stale icons after upgrading to 1.2.5
### Branding polish
- **App icon resized to fill the canvas** (matches Steam/Chrome/Papirus footprint, ~97%) with a subtle blue rim and inner highlight so the matte-vinyl disc doesn't disappear on dark backgrounds; new 512x512 PNG export added
- **Tray icons regenerated** to match the new vinyl Q (the v1.2.5 refresh missed the monochromatic tray glyphs and the Snap launcher)
- **Login screen** — drop-shadow under the logo removed (the new icon's edge rim and inner light make it redundant)
---
## What 1.2.5 brought (still applies)
After a rough week of real-world work, this release brought a dedicated Award view, labels you can follow, and one of the most anticipated sections from the mobile apps landing on the desktop at last: **Release Watch**.
On the functionality side, you can drag and drop any track — or a multi-selection — from any listing onto the sidebar playlists. The rest of the changes are mostly architectural and not user-facing, but they show up as overall stability.
Regarding Qobuz Connect: I know there's still a lot of room for improvement. Most of it is WIP and will start landing in the coming releases.
I want to mention again that the project has been receiving donations. I had linked the accounts to an email I don't check very often and only noticed last week — I'm overwhelmed (in the good way) and genuinely flattered. I haven't fully decided how to use the funds yet, but part will definitely go toward infrastructure. When I started the project, the free tiers seemed more than enough; now we have hundreds of installations on Snapcraft, thousands on Flathub, and a steady stream of direct downloads from GitHub releases. Since qbz has zero telemetry, I can only keep a partial count — it's not a number I ever imagined reaching. These donations will let me make sure the Cloudflare workers that power the MusicBrainz and Last.fm integrations stay up even if we start to brush against the free-tier limits. I couldn't be more grateful to every sponsor, donor, and everyone who supports the project through code, feedback, or simply by using the app.
Saludos a todos y muchas gracias.
### Accolades (Awards + Press)
- **Dedicated Award page** — new `AwardView` with header matching the Label/Album conventions, gold press ribbon, and an "Other awards" carousel; wired to V2 commands for `/award/page` and `/award/getAlbums`
- **AwardAlbumsView** — See-all grid for every award, follow-award as a first-class entity
- **Award resolution by name** — when `/album/get` omits the award id, the backend now resolves it via `/award/explore`, normalises ids end-to-end as strings, and tolerates inconsistent Qobuz shapes on `AwardPageData` / `AwardMagazine`
- **Editorial seed** — `/award/explore` seeds across all locales with diacritic-insensitive lookup; hard-coded seed removed
- **Album-view right sidebar** — shows the full awards stack plus the album's label
- **Album ribbons extended to press accolades** — last award wins on the card; the dedicated sidebar still shows the full stack
- **Home cleanup** — editorial ribbons added for Qobuzissime and Album of the Week on the home rails
### Release Watch
- **Dedicated tabbed view** — mirrors the Qobuz mobile layout with its own entry in Discover; moved below Your Mixes in the home grid
- **Backend** — `/favorite/getNewReleases?type=artists` as REST (not signed RPC), projected onto `SearchResultsPage`, artist field backfilled from `artists[0]`
- **Persistence** — Release Watch now survives home-cache invalidations so it renders immediately on revisit
### Labels
- **Follow / unfollow labels** — mirrors Follow Artist; heart overlay on `LabelView`'s more-labels cards
- **Favorites → Labels tab** — new tab in the Favorites view
### Visual identity
- **New matte-vinyl logo** applied to every icon asset: Tauri standard sizes, Windows Store squares, Android icons and mipmaps, `static/`, README, tray
- **Monochrome symbolic variants** — `tray-light.png` (black glyph) and `tray-dark.png` (white glyph) shipped alongside `tray.png`; the Linux tray picks the matching variant at runtime by probing GNOME `color-scheme`, GTK `prefer-dark-theme`, and KDE `kdeglobals`
- **Login screen refresh** — logo resized to 175px, plus QBZ and "Qobuz™ Player" branding under the mark; `app.tagline` rewritten in en/es
- **About dialog** — fixed a duplicated "License" label (the second row was meant to show the platform); codename updated to "Accolade Watch"
- **DAC Setup Wizard icon contrast** — button icon drops `invert(1)` so it reads on light accent themes (Catppuccin Lavender, Dracula…); the tooltip copy of the same icon now has `invert(1)` so it reads on the dark surface bg
### Desktop integration (Linux)
- **Window controls auto-detect** — new `desktop_theme` Rust module reads `kwinrc`, `kdeglobals`, and `klassyrc`; a runtime-derived "Klassy (auto-detect)" or "Plasma (auto-detect)" preset mirrors the system decoration colors and button shape when Plasma is detected, and stays hidden otherwise
- **Klassy button shapes** — `mapKlassyShapeToQbz()` maps Klassy's `IntegratedRoundedRectangle`, `FullHeightRectangle`, `FullHeightRoundedRectangle`, `Tab`, `Circle`, `Square` onto QBZ's `ButtonShape` set; adds a new `full-height-rounded` variant to cover Klassy's most common preset
- **Opt-in rounded window corners** — "Match system window chrome" toggle persists to `window_settings.db` and gates the Tauri window transparency decision at startup; on next launch the main window is rebuilt transparent and the detected corner radius (10 Klassy, 6 Breeze, 12 Adwaita, 8 fallback) is applied via `clip-path` + `border-radius` with GPU compositing for clean anti-aliasing on WebKitGTK
- **ksni tray on Linux** — replaces the libayatana-appindicator path so left-click actually toggles the window (issue #310)
- **Window size clamp** — persisted sizes that exceed the largest available monitor now clamp to fit instead of opening off-screen
### Streaming, audio, and player
- **Request signing expanded** — all remaining Qobuz API endpoints and every search endpoint now sign their requests; stronger compliance and fewer 403 edge cases
- **BitPerfectMode surfaced in QualityBadge** (#288) — mode now flows through `PlaybackEvent` and the badge updates on playback start
- **DAC rate unsupported fallback** (#288) — track quality is downgraded and the backend falls back to `plughw` instead of CPAL when the DAC can't handle a requested rate; clearer error when CPAL cannot open an enumerated device
- **Output device missing notification** (#307) — user gets a toast when the selected output device disappears
- **Audio output badges refresh** on playback start
- **ALSA Direct `hw` immersive volume lock** — volume slider is disabled in the immersive player when ALSA Direct hw is active (bit-perfect lock)
- **ALSA default plugin** — `alsa_plugin` defaults to `Hw` when switching to the ALSA backend
- **Gapless loop-one fix** — logic moved from backend to frontend; loop-one now works with gapless playback
- **Gapless local-library fallback** — tries local library if not in any cache; fails silently when track isn't found
- **ALSA engine shutdown** — engine stops before dropping the ALSA stream on format changes
- **Real offline mode** (#279) — snapshot streaming, network blocked, diagnostic logs
- **Player fixes** — `durationSecs` passed on session-restore first play so the seekbar advances (previously stuck); qconnect always persists local session to preserve track-level restore (#304)
### Updater (opt-in, non-store builds)
- **tauri-plugin-updater integrated** — backend + frontend; `UpdateProgressModal` added with i18n; wired into app bootstrap and Settings
- **Gated behind Cargo feature `updater`** — sandboxed builds (Flathub, Snap) pass `--no-default-features` so the updater stays disabled for store-managed installs
- **Signed manifest** — minisign public key baked in; CI signs artifacts and consolidates the update manifest; updater manifest path no longer triggers an infinite `tauri dev` rebuild loop
### UI fixes and polish
- **Smart positioning for sidebar menus** — the per-playlist right-click menu now flips above the cursor when it would fall below the window; the Sort submenu inside the general playlists menu uses `use:portal` to escape clipping by `.dropdown-menu`
- **Genre filter popup** — collision check reads the CSS `max-height` (530/630 width, 500/700 height) instead of the measured rect so the popup flips above the trigger or clamps to the viewport when content grows asynchronously
- **Right-section collapse** (#303) — hamburger at narrow widths instead of overflow
- **QualityBadge / QconnectBadge compact variants** — keeps them at full bar height without overflow
- **Silk animations removed** from ForYouTab mix cards and Your Mixes covers — performance cost outweighed the visual benefit and they caused minor rendering glitches
- **Multi-select drag** enabled in all views; track drag & drop extended to artist and search views and onto sidebar playlists; compact drag ghost with artist + album
- **Lyrics active line** uses theme accent color
- **For-You load order** — waits for `topArtists`/`recentAlbums` before loading dependent rails
- **Banner layout** uses a CSS variable; i18n key fixed; responsive height
- **Favorites Select All** — new checkbox in multi-select mode across track views
### Internal architecture
- **`commands_v2` split into a module directory** — the former single-file module is now nineteen focused files, easier to navigate and review
- **Custom device name** persists across restarts for QConnect
- **Prefetch perf** — cache depth increased to 5 tracks with 2 concurrent CMAF segment downloads in parallel
- **Dependency bumps** — tauri-plugin-dialog 2.7.0, tauri-plugin-deep-link, rodio 0.22.2, vite 8.0.8, svelte 5.55.2, sveltejs/kit 2.57.1, rand, jsdom 29.0.2, notify-rust 4.14.0
### Packaging
- **Snap** — MPRIS slot restored after snapcraft approval; exclusive-audio note added to the description
- **CI ARM64** — native ARM runner replaces QEMU for the aarch64 build; targets glibc 2.35; dropped OpenSSL in favour of rustls across all crates
---
Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.6...v1.2.7
+1 -1
View File
@@ -15,7 +15,7 @@
# VERSION BUMP: update version, rev, and hashes
# when tagging a new release.
# ──────────────────────────────────────────────
qbzVersion = "1.2.6";
qbzVersion = "1.2.7";
qbzRev = "v${qbzVersion}";
srcHash = ""; # nix build will report the correct hash on first run
npmHash = ""; # nix build will report the correct hash on first run
+57 -49
View File
@@ -1,12 +1,12 @@
{
"name": "qbz-nix",
"version": "1.2.6",
"version": "1.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qbz-nix",
"version": "1.2.6",
"version": "1.2.7",
"license": "MIT",
"dependencies": {
"@icons-pack/svelte-simple-icons": "^7.2.0",
@@ -27,11 +27,11 @@
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"jsdom": "^29.0.2",
"svelte": "^5.55.2",
"svelte": "^5.55.4",
"svelte-check": "^4.4.6",
"typescript": "~5.9.3",
"vite": "^8.0.8",
"vitest": "^4.1.2"
"vitest": "^4.1.4"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -1610,16 +1610,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1628,13 +1628,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.2",
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1665,9 +1665,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1678,13 +1678,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.2",
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
@@ -1692,14 +1692,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1708,9 +1708,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1718,13 +1718,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2898,9 +2898,9 @@
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.2.tgz",
"integrity": "sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==",
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -3242,19 +3242,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
"@vitest/pretty-format": "4.1.2",
"@vitest/runner": "4.1.2",
"@vitest/snapshot": "4.1.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -3282,10 +3282,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.2",
"@vitest/browser-preview": "4.1.2",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/ui": "4.1.2",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -3309,6 +3311,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "qbz-nix",
"version": "1.2.6",
"version": "1.2.7",
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
"type": "module",
"scripts": {
@@ -44,11 +44,11 @@
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"jsdom": "^29.0.2",
"svelte": "^5.55.2",
"svelte": "^5.55.4",
"svelte-check": "^4.4.6",
"typescript": "~5.9.3",
"vite": "^8.0.8",
"vitest": "^4.1.2"
"vitest": "^4.1.4"
},
"overrides": {
"cookie": "^0.7.0",
@@ -69,6 +69,17 @@
</screenshot>
</screenshots>
<releases>
<release version="1.2.7" date="2026-04-16">
<description>
<p>Follow-up to 1.2.5 and 1.2.6: a defensive fix and a diagnostics expansion.</p>
<ul>
<li>Defensive fix for the Discover carousel click-through regression in 1.2.6: mouse-drag scrolling no longer captures the pointer until a real drag is in progress, so clicks on album/artist/playlist cards reach the card's onClick across every WebKitGTK + compositor combo</li>
<li>System Diagnostics expansion: one-shot snapshot now reports OS / distro / install method / key library versions (WebKitGTK, GTK, glibc, ALSA, PipeWire, PulseAudio), live playback state, Qobuz Connect role (controller / local renderer / observer), and — opt-out toggle in View Logs — an optional 10s Chromecast + DLNA scan, all bundled into the same paste upload</li>
<li>Favorites sidebar backfill: users whose persisted tab order predated the Labels tab were missing the Labels entry; order is now diffed against the canonical default and repaired on load</li>
<li>Misc: dependency bumps (svelte 5.55.4, vitest 4.1.4, rand 0.10.1 for GHSA-cq8v-f236-94qc)</li>
</ul>
</description>
</release>
<release version="1.2.6" date="2026-04-15">
<description>
<p>Accessibility, polish, and a couple of regression fixes.</p>
+1 -1
View File
@@ -3,7 +3,7 @@
name: qbz-player
title: "QBZ — Native Qobuz Client for Linux"
version: '1.2.6'
version: '1.2.7'
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
description: |
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
+1 -1
View File
@@ -4910,7 +4910,7 @@ dependencies = [
[[package]]
name = "qbz-nix"
version = "1.2.6"
version = "1.2.7"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "qbz-nix"
version = "1.2.6"
version = "1.2.7"
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
authors = ["blitzkriegfc"]
edition = "2021"
+1
View File
@@ -28,6 +28,7 @@
"core:window:allow-inner-size",
"core:window:allow-inner-position",
"core:window:allow-set-position",
"core:window:allow-set-title",
"core:webview:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-webview-close",
+163
View File
@@ -166,3 +166,166 @@ pub fn v2_get_runtime_diagnostics(
app_version: env!("CARGO_PKG_VERSION").to_string(),
})
}
// ==================== System Info ====================
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemInfo {
pub os: String,
pub arch: String,
pub kernel_version: Option<String>,
pub distro_id: Option<String>,
pub distro_version_id: Option<String>,
pub distro_pretty_name: Option<String>,
pub install_method: String,
pub flatpak_runtime: Option<String>,
pub flatpak_runtime_version: Option<String>,
pub webkit2gtk_version: Option<String>,
pub gtk_version: Option<String>,
pub glibc_version: Option<String>,
pub alsa_version: Option<String>,
pub pipewire_version: Option<String>,
pub pulseaudio_version: Option<String>,
}
/// Parse /etc/os-release (or the Flatpak host equivalent) into key/value pairs.
fn read_os_release() -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
// Try Flatpak-exposed host file first, then the normal path.
let candidates = ["/run/host/os-release", "/etc/os-release"];
for path in candidates {
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines() {
if let Some(idx) = line.find('=') {
let key = line[..idx].trim().to_string();
let mut value = line[idx + 1..].trim().to_string();
if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
value = value[1..value.len() - 1].to_string();
}
map.insert(key, value);
}
}
if !map.is_empty() {
return map;
}
}
}
map
}
fn detect_kernel_version() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|s| s.trim().to_string())
}
fn detect_install_method() -> (String, Option<String>, Option<String>) {
// (method, flatpak_runtime, flatpak_runtime_version)
if std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists() {
let mut runtime = None;
let mut runtime_version = None;
if let Ok(text) = std::fs::read_to_string("/.flatpak-info") {
for line in text.lines() {
if let Some(value) = line.strip_prefix("runtime=") {
let v = value.trim();
if let Some(slash) = v.rfind('/') {
runtime = Some(v[..slash].to_string());
runtime_version = Some(v[slash + 1..].to_string());
} else {
runtime = Some(v.to_string());
}
}
}
}
return ("flatpak".to_string(), runtime, runtime_version);
}
if std::env::var("SNAP").is_ok() {
return ("snap".to_string(), None, None);
}
if std::env::var("APPIMAGE").is_ok() {
return ("appimage".to_string(), None, None);
}
if cfg!(debug_assertions) {
return ("dev".to_string(), None, None);
}
("native".to_string(), None, None)
}
/// Extract the best-available version string from the filename of a shared
/// library loaded by the current process. Looks for patterns like
/// `libfoo.so.0.15.7` → `0.15.7`, or `libfoo.so.2` → `2`.
/// Returns `None` if the library isn't mapped.
fn detect_loaded_lib_version(lib_name_stem: &str) -> Option<String> {
let maps = std::fs::read_to_string("/proc/self/maps").ok()?;
let mut best: Option<String> = None;
for line in maps.lines() {
// Last column is the path (may contain spaces, very rare).
let path = line.splitn(6, ' ').nth(5).unwrap_or("").trim();
if path.is_empty() {
continue;
}
let filename = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if !filename.starts_with(lib_name_stem) {
continue;
}
// filename example: "libwebkit2gtk-4.1.so.0.15.7"
// Strip the "lib_name_stem.so" prefix and leading dot.
let tail = match filename.split_once(".so") {
Some((_, rest)) => rest.trim_start_matches('.'),
None => continue,
};
if tail.is_empty() {
continue;
}
// Resolve symlink target if possible — often the real file carries
// a fuller version than the SONAME alias.
if let Ok(real) = std::fs::canonicalize(path) {
if let Some(real_name) = real.file_name().and_then(|s| s.to_str()) {
if let Some((_, rest)) = real_name.split_once(".so") {
let real_tail = rest.trim_start_matches('.');
if !real_tail.is_empty() {
best = Some(real_tail.to_string());
continue;
}
}
}
}
best.get_or_insert_with(|| tail.to_string());
}
best
}
#[tauri::command]
pub fn v2_get_system_info() -> Result<SystemInfo, RuntimeError> {
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
let (install_method, flatpak_runtime, flatpak_runtime_version) = detect_install_method();
let osr = read_os_release();
Ok(SystemInfo {
os,
arch,
kernel_version: detect_kernel_version(),
distro_id: osr.get("ID").cloned(),
distro_version_id: osr.get("VERSION_ID").cloned(),
distro_pretty_name: osr
.get("PRETTY_NAME")
.cloned()
.or_else(|| osr.get("NAME").cloned()),
install_method,
flatpak_runtime,
flatpak_runtime_version,
// Runtime shared-library versions, parsed from /proc/self/maps.
webkit2gtk_version: detect_loaded_lib_version("libwebkit2gtk-4.1"),
gtk_version: detect_loaded_lib_version("libgtk-3")
.or_else(|| detect_loaded_lib_version("libgtk-4")),
glibc_version: detect_loaded_lib_version("libc"),
alsa_version: detect_loaded_lib_version("libasound"),
pipewire_version: detect_loaded_lib_version("libpipewire-0.3"),
pulseaudio_version: detect_loaded_lib_version("libpulse"),
})
}
+1
View File
@@ -1361,6 +1361,7 @@ pub fn run() {
commands_v2::v2_set_visualizer_enabled,
commands_v2::v2_get_developer_settings,
commands_v2::v2_get_runtime_diagnostics,
commands_v2::v2_get_system_info,
commands_v2::v2_set_developer_force_dmabuf,
commands_v2::v2_get_graphics_settings,
commands_v2::v2_get_graphics_startup_status,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "QBZ",
"version": "1.2.6",
"version": "1.2.7",
"identifier": "com.blitzfc.qbz",
"build": {
"beforeDevCommand": "npm run dev",
+9 -5
View File
@@ -1586,16 +1586,20 @@ select {
.action-btn-circle.primary {
width: 44px;
height: 44px;
background: var(--accent-primary);
border: none;
/* Resting = the previous hover look: --accent-hover fill, white glyph,
white 1px ring. Hover flips to the --accent-primary fill with the
glyph now in --accent-hover (the previous resting style). */
background: var(--accent-hover);
color: var(--text-primary);
box-shadow: inset 0 0 0 2px var(--border-subtle);
transition: background 150ms ease, box-shadow 200ms ease;
box-shadow: inset 0 0 0 1px var(--text-primary);
transition: background 150ms ease, color 150ms ease;
}
.action-btn-circle.primary:hover {
background: var(--accent-hover);
box-shadow: inset 0 0 0 2px var(--text-primary);
background: var(--accent-primary);
color: var(--accent-hover);
box-shadow: inset 0 0 0 1px var(--text-primary);
}
.action-btn-circle.is-active {
+15 -1
View File
@@ -78,6 +78,13 @@
let error = $state<string | null>(null);
let discoveryStarted = $state(false);
let connecting = $state(false);
// Multicast discovery (DLNA / Chromecast) commonly needs 5-15s before
// devices announce themselves. `loading` only covers the first poll, so we
// keep a separate `scanning` flag that stays true long enough to show a
// spinner instead of a premature "No devices found" message.
let scanning = $state(false);
let scanTimeout: ReturnType<typeof setTimeout> | null = null;
const SCAN_DURATION_MS = 15000;
// Cast state from store
let castState = $state(getCastState());
@@ -122,6 +129,9 @@
loading = true;
error = null;
discoveryStarted = true;
scanning = true;
if (scanTimeout) clearTimeout(scanTimeout);
scanTimeout = setTimeout(() => { scanning = false; }, SCAN_DURATION_MS);
try {
// Start discovery protocols in parallel
@@ -136,11 +146,15 @@
} catch (err) {
error = String(err);
loading = false;
scanning = false;
if (scanTimeout) { clearTimeout(scanTimeout); scanTimeout = null; }
}
}
async function stopDiscovery() {
discoveryStarted = false;
scanning = false;
if (scanTimeout) { clearTimeout(scanTimeout); scanTimeout = null; }
try {
await Promise.allSettled([
invoke('v2_cast_stop_discovery'),
@@ -468,7 +482,7 @@
</div>
{/if}
</div>
{:else if loading && devices().length === 0}
{:else if devices().length === 0 && (loading || scanning)}
<div class="loading">
<LoaderCircle size={32} class="spin" />
<p>{$t('toast.loadingDevices')}</p>
+435 -6
View File
@@ -1,9 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
import { t } from '$lib/i18n';
import { RefreshCw, Copy, Check, ChevronDown, ChevronRight } from 'lucide-svelte';
import { RefreshCw, Copy, Check, ChevronDown, ChevronRight, Radio, LoaderCircle } from 'lucide-svelte';
import {
getCurrentTrack,
getIsPlaying,
getCurrentTime,
getDuration,
getVolume
} from '$lib/stores/playerStore';
import type { QconnectConnectionStatus, QconnectSessionSnapshot } from '$lib/services/qconnectRuntime';
interface RuntimeDiagnostics {
audioOutputDevice: string | null;
@@ -50,7 +58,72 @@
status: 'match' | 'mismatch' | 'info';
}
interface SystemInfo {
os: string;
arch: string;
kernelVersion: string | null;
distroId: string | null;
distroVersionId: string | null;
distroPrettyName: string | null;
installMethod: string;
flatpakRuntime: string | null;
flatpakRuntimeVersion: string | null;
webkit2gtkVersion: string | null;
gtkVersion: string | null;
glibcVersion: string | null;
alsaVersion: string | null;
pipewireVersion: string | null;
pulseaudioVersion: string | null;
}
interface PlaybackSnapshot {
isPlaying: boolean;
volumePercent: number;
positionSecs: number;
durationSecs: number;
hasTrack: boolean;
trackTitle: string | null;
trackArtist: string | null;
trackAlbum: string | null;
trackQuality: string | null;
trackFormat: string | null;
trackBitDepth: number | null;
trackSamplingRate: number | null;
trackIsLocal: boolean | null;
trackSource: string | null;
}
interface QconnectDiag {
running: boolean;
transport_connected: boolean;
hasEndpoint: boolean;
lastError: string | null;
role: 'none' | 'controller' | 'local-renderer' | 'observer';
activeRendererName: string | null;
activeRendererBrand: string | null;
activeRendererModel: string | null;
rendererCount: number;
}
interface CastDeviceBrief {
name: string;
protocol: string;
}
interface CastScanResult {
chromecastCount: number;
dlnaCount: number;
devices: CastDeviceBrief[];
durationMs: number;
error: string | null;
}
let diagnostics = $state<RuntimeDiagnostics | null>(null);
let systemInfo = $state<SystemInfo | null>(null);
let playback = $state<PlaybackSnapshot | null>(null);
let qconnect = $state<QconnectDiag | null>(null);
let castScan = $state<CastScanResult | null>(null);
let castScanning = $state(false);
let loading = $state(false);
let copied = $state(false);
let error = $state<string | null>(null);
@@ -59,6 +132,132 @@
let audioOpen = $state(true);
let graphicsOpen = $state(true);
let envOpen = $state(false);
let playbackOpen = $state(true);
let qconnectOpen = $state(true);
let castOpen = $state(true);
let systemOpen = $state(true);
const CAST_SCAN_DURATION_MS = 10000;
// Redact things that look like UUIDs / hex IDs / tokens (defensive: keep paste
// logs free of anything CodeQL or paste-site filters might flag as secrets).
function redactIdLike(value: string | null | undefined): string | null {
if (!value) return null;
// Strip 32+ char hex strings and UUID shapes.
return value
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
.replace(/\b[0-9a-f]{32,}\b/gi, '<hex>');
}
function snapshotPlayback(): PlaybackSnapshot {
const track = getCurrentTrack();
return {
isPlaying: getIsPlaying(),
volumePercent: Math.round(getVolume()),
positionSecs: Math.round(getCurrentTime()),
durationSecs: Math.round(getDuration()),
hasTrack: !!track,
trackTitle: track?.title ?? null,
trackArtist: track?.artist ?? null,
trackAlbum: track?.album ?? null,
trackQuality: track?.quality ?? null,
trackFormat: track?.format ?? null,
trackBitDepth: track?.bitDepth ?? null,
trackSamplingRate: track?.samplingRate ?? null,
trackIsLocal: track?.isLocal ?? null,
trackSource: track?.source ?? null,
};
}
async function snapshotQconnect(): Promise<QconnectDiag> {
const empty: QconnectDiag = {
running: false,
transport_connected: false,
hasEndpoint: false,
lastError: null,
role: 'none',
activeRendererName: null,
activeRendererBrand: null,
activeRendererModel: null,
rendererCount: 0,
};
try {
const status = await invoke<QconnectConnectionStatus>('v2_qconnect_get_status');
const session = await invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot');
const out: QconnectDiag = { ...empty };
out.running = !!status.running;
out.transport_connected = !!status.transport_connected;
out.hasEndpoint = !!status.endpoint_url;
out.lastError = status.last_error ? redactIdLike(String(status.last_error)) : null;
out.rendererCount = session?.renderers?.length ?? 0;
const activeId = session?.active_renderer_id ?? null;
const localId = session?.local_renderer_id ?? null;
if (activeId !== null && activeId !== undefined) {
const active = session?.renderers?.find((r) => r.renderer_id === activeId);
out.activeRendererName = active?.friendly_name ?? null;
out.activeRendererBrand = active?.brand ?? null;
out.activeRendererModel = active?.model ?? null;
if (activeId === localId) {
out.role = 'local-renderer';
} else {
out.role = 'controller';
}
} else if (out.running || out.transport_connected) {
out.role = 'observer';
}
return out;
} catch {
return empty;
}
}
async function runCastScan(): Promise<void> {
if (castScanning) return;
castScanning = true;
const started = Date.now();
let startError: string | null = null;
try {
await Promise.allSettled([
invoke('v2_cast_start_discovery'),
invoke('v2_dlna_start_discovery'),
]);
} catch (err) {
startError = String(err);
}
await new Promise((resolve) => setTimeout(resolve, CAST_SCAN_DURATION_MS));
let chromecastDevices: { name?: string }[] = [];
let dlnaDevices: { name?: string }[] = [];
try {
const [cc, dlna] = await Promise.allSettled([
invoke<{ name?: string }[]>('v2_cast_get_devices'),
invoke<{ name?: string }[]>('v2_dlna_get_devices'),
]);
if (cc.status === 'fulfilled') chromecastDevices = cc.value ?? [];
if (dlna.status === 'fulfilled') dlnaDevices = dlna.value ?? [];
} catch {
// ignore
}
try {
await Promise.allSettled([
invoke('v2_cast_stop_discovery'),
invoke('v2_dlna_stop_discovery'),
]);
} catch {
// ignore
}
const devices: CastDeviceBrief[] = [
...chromecastDevices.map((d) => ({ name: d.name ?? '<unnamed>', protocol: 'chromecast' })),
...dlnaDevices.map((d) => ({ name: d.name ?? '<unnamed>', protocol: 'dlna' })),
];
castScan = {
chromecastCount: chromecastDevices.length,
dlnaCount: dlnaDevices.length,
devices,
durationMs: Date.now() - started,
error: startError,
};
castScanning = false;
}
function bool(val: boolean): string {
return val ? 'ON' : 'OFF';
@@ -132,9 +331,23 @@
loading = true;
error = null;
try {
diagnostics = await invoke<RuntimeDiagnostics>('v2_get_runtime_diagnostics');
} catch (err) {
error = String(err);
const [diagRes, sysRes, qcRes] = await Promise.allSettled([
invoke<RuntimeDiagnostics>('v2_get_runtime_diagnostics'),
invoke<SystemInfo>('v2_get_system_info'),
snapshotQconnect(),
]);
if (diagRes.status === 'fulfilled') {
diagnostics = diagRes.value;
} else {
error = String(diagRes.reason);
}
if (sysRes.status === 'fulfilled') {
systemInfo = sysRes.value;
}
if (qcRes.status === 'fulfilled') {
qconnect = qcRes.value;
}
playback = snapshotPlayback();
} finally {
loading = false;
}
@@ -145,6 +358,10 @@
try {
const exportData = {
...diagnostics,
systemInfo,
playback,
qconnect,
castScan,
exportedAt: new Date().toISOString(),
};
await copyToClipboard(JSON.stringify(exportData, null, 2));
@@ -152,7 +369,9 @@
setTimeout(() => { copied = false; }, 1500);
} catch {
try {
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
await navigator.clipboard.writeText(
JSON.stringify({ ...diagnostics, systemInfo, playback, qconnect, castScan }, null, 2)
);
copied = true;
setTimeout(() => { copied = false; }, 1500);
} catch { /* ignore */ }
@@ -165,6 +384,81 @@
loadDiagnostics();
}
}
onDestroy(() => {
// (intentional) don't leave any subscriptions behind
});
function systemRows(s: SystemInfo): DiagRow[] {
const rows: DiagRow[] = [
{ label: 'OS', saved: '—', runtime: s.os, status: 'info' },
{ label: 'Arch', saved: '—', runtime: s.arch, status: 'info' },
{ label: 'Kernel', saved: '—', runtime: str(s.kernelVersion), status: 'info' },
{ label: 'Distro', saved: '—', runtime: str(s.distroPrettyName), status: 'info' },
{ label: 'Distro ID', saved: '—', runtime: str(s.distroId), status: 'info' },
{ label: 'Distro Version', saved: '—', runtime: str(s.distroVersionId), status: 'info' },
{ label: 'Install Method', saved: '—', runtime: s.installMethod, status: 'info' },
];
if (s.flatpakRuntime) {
rows.push({ label: 'Flatpak Runtime', saved: '—', runtime: `${s.flatpakRuntime} ${str(s.flatpakRuntimeVersion)}`, status: 'info' });
}
rows.push(
{ label: 'WebKit2GTK', saved: '—', runtime: str(s.webkit2gtkVersion), status: 'info' },
{ label: 'GTK', saved: '—', runtime: str(s.gtkVersion), status: 'info' },
{ label: 'glibc', saved: '—', runtime: str(s.glibcVersion), status: 'info' },
{ label: 'ALSA', saved: '—', runtime: str(s.alsaVersion), status: 'info' },
{ label: 'PipeWire', saved: '—', runtime: str(s.pipewireVersion), status: 'info' },
{ label: 'PulseAudio', saved: '—', runtime: str(s.pulseaudioVersion), status: 'info' },
);
return rows;
}
function playbackRows(p: PlaybackSnapshot): DiagRow[] {
return [
{ label: 'Playing', saved: '—', runtime: bool(p.isPlaying), status: 'info' },
{ label: 'Volume', saved: '—', runtime: `${p.volumePercent}%`, status: 'info' },
{ label: 'Position / Duration', saved: '—', runtime: `${p.positionSecs}s / ${p.durationSecs}s`, status: 'info' },
{ label: 'Has Track', saved: '—', runtime: bool(p.hasTrack), status: 'info' },
{ label: 'Track Title', saved: '—', runtime: str(p.trackTitle), status: 'info' },
{ label: 'Track Artist', saved: '—', runtime: str(p.trackArtist), status: 'info' },
{ label: 'Track Album', saved: '—', runtime: str(p.trackAlbum), status: 'info' },
{ label: 'Track Source', saved: '—', runtime: str(p.trackSource), status: 'info' },
{ label: 'Track Is Local', saved: '—', runtime: p.trackIsLocal === null ? '—' : bool(p.trackIsLocal), status: 'info' },
{ label: 'Track Quality', saved: '—', runtime: str(p.trackQuality), status: 'info' },
{ label: 'Track Format', saved: '—', runtime: str(p.trackFormat), status: 'info' },
{ label: 'Track Bit Depth', saved: '—', runtime: p.trackBitDepth ? `${p.trackBitDepth}-bit` : '—', status: 'info' },
{ label: 'Track Sample Rate', saved: '—', runtime: p.trackSamplingRate ? `${p.trackSamplingRate} Hz` : '—', status: 'info' },
];
}
function qconnectRows(q: QconnectDiag): DiagRow[] {
return [
{ label: 'Running', saved: '—', runtime: bool(q.running), status: 'info' },
{ label: 'Transport Connected', saved: '—', runtime: bool(q.transport_connected), status: 'info' },
{ label: 'Has Endpoint', saved: '—', runtime: bool(q.hasEndpoint), status: 'info' },
{ label: 'Role', saved: '—', runtime: q.role, status: 'info' },
{ label: 'Active Renderer', saved: '—', runtime: str(q.activeRendererName), status: 'info' },
{ label: 'Renderer Brand', saved: '—', runtime: str(q.activeRendererBrand), status: 'info' },
{ label: 'Renderer Model', saved: '—', runtime: str(q.activeRendererModel), status: 'info' },
{ label: 'Visible Renderers', saved: '—', runtime: String(q.rendererCount), status: 'info' },
{ label: 'Last Error', saved: '—', runtime: str(q.lastError), status: 'info' },
];
}
function castRows(c: CastScanResult): DiagRow[] {
const rows: DiagRow[] = [
{ label: 'Chromecast devices', saved: '—', runtime: String(c.chromecastCount), status: 'info' },
{ label: 'DLNA devices', saved: '—', runtime: String(c.dlnaCount), status: 'info' },
{ label: 'Scan duration', saved: '—', runtime: `${Math.round(c.durationMs / 1000)}s`, status: 'info' },
];
if (c.error) {
rows.push({ label: 'Scan error', saved: '—', runtime: c.error, status: 'mismatch' });
}
for (const d of c.devices) {
rows.push({ label: `• ${d.protocol}`, saved: '—', runtime: d.name, status: 'info' });
}
return rows;
}
</script>
<div class="diagnostics-panel">
@@ -199,6 +493,132 @@
{#if diagnostics}
<div class="diag-version">QBZ v{diagnostics.appVersion}</div>
<!-- System Info Section -->
{#if systemInfo}
<button class="section-toggle" onclick={() => systemOpen = !systemOpen}>
{#if systemOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{$t('settings.developer.diagnostics.sectionSystem')}
</button>
{#if systemOpen}
<table class="diag-table">
<thead>
<tr>
<th>{$t('settings.developer.diagnostics.colSetting')}</th>
<th colspan="2">{$t('settings.developer.diagnostics.colRuntime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each systemRows(systemInfo) as row (row.label)}
<tr>
<td class="label-cell">{row.label}</td>
<td class="value-cell" colspan="2">{row.runtime}</td>
<td class="status-cell info">·</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<!-- Playback Section -->
{#if playback}
<button class="section-toggle" onclick={() => playbackOpen = !playbackOpen}>
{#if playbackOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{$t('settings.developer.diagnostics.sectionPlayback')}
</button>
{#if playbackOpen}
<table class="diag-table">
<thead>
<tr>
<th>{$t('settings.developer.diagnostics.colSetting')}</th>
<th colspan="2">{$t('settings.developer.diagnostics.colRuntime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each playbackRows(playback) as row (row.label)}
<tr>
<td class="label-cell">{row.label}</td>
<td class="value-cell" colspan="2">{row.runtime}</td>
<td class="status-cell info">·</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<!-- Qobuz Connect Section -->
{#if qconnect}
<button class="section-toggle" onclick={() => qconnectOpen = !qconnectOpen}>
{#if qconnectOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{$t('settings.developer.diagnostics.sectionQconnect')}
</button>
{#if qconnectOpen}
<table class="diag-table">
<thead>
<tr>
<th>{$t('settings.developer.diagnostics.colSetting')}</th>
<th colspan="2">{$t('settings.developer.diagnostics.colRuntime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each qconnectRows(qconnect) as row (row.label)}
<tr>
<td class="label-cell">{row.label}</td>
<td class="value-cell" colspan="2">{row.runtime}</td>
<td class="status-cell info">·</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<!-- Cast Discovery Section -->
<button class="section-toggle" onclick={() => castOpen = !castOpen}>
{#if castOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
{$t('settings.developer.diagnostics.sectionCast')}
</button>
{#if castOpen}
<div class="diag-actions" style="margin: 8px 0 12px;">
<button class="diag-btn" onclick={runCastScan} disabled={castScanning}>
<Radio size={14} class={castScanning ? 'spinning' : ''} />
{castScanning
? $t('settings.developer.diagnostics.castScanning')
: $t('settings.developer.diagnostics.castScan')}
</button>
</div>
{#if castScanning && !castScan}
<div class="cast-scan-placeholder">
<LoaderCircle size={20} class="spinning" />
<span>{$t('settings.developer.diagnostics.castScanning')}</span>
</div>
{/if}
{#if castScan}
<table class="diag-table">
<thead>
<tr>
<th>{$t('settings.developer.diagnostics.colSetting')}</th>
<th colspan="2">{$t('settings.developer.diagnostics.colRuntime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each castRows(castScan) as row (row.label + row.runtime)}
<tr>
<td class="label-cell">{row.label}</td>
<td class="value-cell" colspan="2">{row.runtime}</td>
<td class="status-cell {row.status}">{row.status === 'mismatch' ? '✗' : '·'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
<!-- Audio Section -->
<button class="section-toggle" onclick={() => audioOpen = !audioOpen}>
{#if audioOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
@@ -422,6 +842,15 @@
animation: spin 1s linear infinite;
}
.cast-scan-placeholder {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
color: var(--text-muted);
font-size: 12px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
+49 -9
View File
@@ -10,8 +10,12 @@
let { title, header, children }: Props = $props();
const DRAG_THRESHOLD_PX = 5;
let scrollContainer: HTMLDivElement;
let isDragging = $state(false);
let pointerIsDown = false;
let activePointerId = -1;
let dragStartX = 0;
let dragStartScroll = 0;
let dragDistance = 0;
@@ -33,28 +37,59 @@
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
isDragging = true;
// Arm drag detection but do NOT setPointerCapture yet. Capturing here
// redirects the subsequent click away from the card under the pointer,
// which breaks card/play-button clicks inside carousels (issue #323).
pointerIsDown = true;
activePointerId = e.pointerId;
dragStartX = e.clientX;
dragStartScroll = scrollContainer.scrollLeft;
dragDistance = 0;
scrollContainer.setPointerCapture(e.pointerId);
isDragging = false;
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
if (!pointerIsDown || e.pointerId !== activePointerId) return;
const dx = e.clientX - dragStartX;
dragDistance = Math.abs(dx);
scrollContainer.scrollLeft = dragStartScroll - dx;
if (!isDragging && dragDistance > DRAG_THRESHOLD_PX) {
// Cross the threshold → now it's a real drag. Capture the pointer so
// we keep getting move events even if the cursor leaves the container.
isDragging = true;
try {
scrollContainer.setPointerCapture(e.pointerId);
} catch {
// Some pointer types (e.g. synthesised mouse on old WebKit) may
// reject capture — continue tracking via bubble events.
}
}
if (isDragging) {
scrollContainer.scrollLeft = dragStartScroll - dx;
}
}
function onPointerUp(e: PointerEvent) {
if (!isDragging) return;
isDragging = false;
scrollContainer.releasePointerCapture(e.pointerId);
if (!pointerIsDown) return;
pointerIsDown = false;
if (isDragging) {
isDragging = false;
try {
scrollContainer.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
activePointerId = -1;
}
function onClickCapture(e: MouseEvent) {
if (dragDistance > 5) e.preventDefault();
// Only swallow the click that would otherwise follow a real drag.
if (dragDistance > DRAG_THRESHOLD_PX) {
e.preventDefault();
e.stopPropagation();
}
// Reset so the next unrelated click passes through cleanly.
dragDistance = 0;
}
const hasHeader = $derived(!!title || !!header);
@@ -154,11 +189,16 @@
overflow-x: auto;
overflow-y: hidden;
cursor: grab;
/* Never select text inside carousels: drag-to-scroll would otherwise
highlight titles/artists in the first few pixels before the drag
threshold kicks in. Text is still selectable in the dedicated
album/artist/playlist views. */
user-select: none;
-webkit-user-select: none;
}
.scroll-container.dragging {
cursor: grabbing;
user-select: none;
}
.content {
+76 -2
View File
@@ -3,9 +3,11 @@
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
import { t } from '$lib/i18n';
import Modal from './Modal.svelte';
import Toggle from './Toggle.svelte';
import { getConsoleLogsAsText } from '$lib/stores/consoleLogStore';
import { showToast } from '$lib/stores/toastStore';
import { LoaderCircle, Copy, Check } from 'lucide-svelte';
import { collectDiagnosticsText } from '$lib/services/diagnosticsSnapshot';
interface Props {
isOpen: boolean;
@@ -14,6 +16,22 @@
let { isOpen, onClose }: Props = $props();
// Opt-out toggle: when ON (default) the Terminal paste upload bundles the
// System Diagnostics snapshot + a 10s Chromecast/DLNA scan into the same
// paste, so bug reporters share ONE link instead of two.
// Persisted in localStorage.
const BUNDLE_KEY = 'qbz-paste-bundle-diagnostics';
let bundleDiagnostics = $state(true);
try {
const saved = localStorage.getItem(BUNDLE_KEY);
if (saved !== null) bundleDiagnostics = saved === 'true';
} catch { /* ignore */ }
function setBundleDiagnostics(value: boolean) {
bundleDiagnostics = value;
try { localStorage.setItem(BUNDLE_KEY, String(value)); } catch { /* ignore */ }
}
let activeTab = $state<'terminal' | 'console'>('terminal');
let terminalLogs = $state('');
let consoleLogs = $state('');
@@ -24,6 +42,7 @@
let consoleUrl = $state('');
let copiedTerminal = $state(false);
let copiedConsole = $state(false);
let bundleStage = $state<'idle' | 'collecting' | 'uploading'>('idle');
async function loadLogs() {
isLoading = true;
@@ -44,11 +63,28 @@
});
async function uploadTab(tab: 'terminal' | 'console') {
const content = tab === 'terminal' ? terminalLogs : consoleLogs;
let content = tab === 'terminal' ? terminalLogs : consoleLogs;
if (tab === 'terminal') isUploadingTerminal = true;
else isUploadingConsole = true;
try {
// Bundle the diagnostics snapshot + a 10s cast scan into the terminal
// paste when the toggle is on. Console pastes stay lean.
if (tab === 'terminal' && bundleDiagnostics) {
bundleStage = 'collecting';
try {
const diag = await collectDiagnosticsText({ includeCastScan: true, castScanMs: 10000 });
content = [
'===== QBZ DIAGNOSTICS =====',
diag,
'===== TERMINAL LOG =====',
content,
].join('\n\n');
} catch (e) {
console.warn('[LogsModal] Failed to collect diagnostics for bundle:', e);
}
}
bundleStage = 'uploading';
const url: string = await invoke('v2_upload_logs_to_paste', { content });
if (tab === 'terminal') terminalUrl = url;
else consoleUrl = url;
@@ -59,6 +95,7 @@
} finally {
if (tab === 'terminal') isUploadingTerminal = false;
else isUploadingConsole = false;
bundleStage = 'idle';
}
}
@@ -106,6 +143,18 @@
{#snippet footer()}
<div class="footer-content">
<div class="footer-left">
{#if activeTab === 'terminal'}
<div class="bundle-row">
<Toggle
enabled={bundleDiagnostics}
onchange={(v) => setBundleDiagnostics(v)}
/>
<div class="bundle-text">
<span class="bundle-label">{$t('settings.developer.bundleDiagnostics')}</span>
<small class="bundle-note">{$t('settings.developer.bundleDiagnosticsDesc')}</small>
</div>
</div>
{/if}
<button
class="upload-btn"
onclick={() => uploadTab(activeTab)}
@@ -113,7 +162,11 @@
>
{#if (activeTab === 'terminal' ? isUploadingTerminal : isUploadingConsole)}
<LoaderCircle size={14} class="spin" />
{$t('settings.developer.uploading')}
{#if activeTab === 'terminal' && bundleStage === 'collecting'}
{$t('settings.developer.collectingDiagnostics')}
{:else}
{$t('settings.developer.uploading')}
{/if}
{:else}
{$t('settings.developer.uploadTab', { values: { tab: activeTab === 'terminal' ? $t('settings.developer.tabTerminal') : $t('settings.developer.tabConsole') } })}
{/if}
@@ -223,6 +276,27 @@
gap: 8px;
}
.bundle-row {
display: flex;
align-items: center;
gap: 10px;
}
.bundle-text {
display: flex;
flex-direction: column;
}
.bundle-label {
font-size: 13px;
color: var(--text-primary);
}
.bundle-note {
font-size: 11px;
color: var(--text-muted);
}
.upload-btn {
display: flex;
align-items: center;
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { t } from '$lib/i18n';
import Modal from './Modal.svelte';
import { FileText, ExternalLink } from 'lucide-svelte';
interface Props {
isOpen: boolean;
onClose: () => void;
onGoToLogs: () => void;
onCreateIssue: () => void;
}
let { isOpen, onClose, onGoToLogs, onCreateIssue }: Props = $props();
const HIDE_KEY = 'qbz-hide-report-issue-modal';
let hideNextTime = $state(false);
function persistHidePreference() {
try {
if (hideNextTime) localStorage.setItem(HIDE_KEY, 'true');
else localStorage.removeItem(HIDE_KEY);
} catch { /* ignore */ }
}
function handleGoToLogs() {
persistHidePreference();
onGoToLogs();
}
function handleCreateIssue() {
persistHidePreference();
onCreateIssue();
}
</script>
<Modal {isOpen} {onClose} title={$t('reportIssue.title')} maxWidth="520px">
{#snippet children()}
<p class="intro">{$t('reportIssue.intro')}</p>
{/snippet}
{#snippet footer()}
<div class="footer">
<label class="hide-row">
<input type="checkbox" bind:checked={hideNextTime} />
<span class="hide-label">{$t('reportIssue.hideNextTime')}</span>
</label>
<div class="actions">
<button class="btn secondary" onclick={handleGoToLogs}>
<FileText size={14} />
{$t('reportIssue.goToLogs')}
</button>
<button class="btn primary" onclick={handleCreateIssue}>
<ExternalLink size={14} />
{$t('reportIssue.createIssue')}
</button>
</div>
</div>
{/snippet}
</Modal>
<style>
.intro {
margin: 0 0 8px;
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
.footer {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.hide-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
}
.hide-row input[type="checkbox"] {
accent-color: var(--accent-primary);
}
.hide-label {
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.btn.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--bg-tertiary);
}
.btn.secondary:hover {
background: var(--bg-hover);
}
.btn.primary {
background: var(--accent-primary);
color: var(--text-primary);
border: 1px solid var(--accent-primary);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
</style>
+15 -1
View File
@@ -973,7 +973,21 @@
async function loadFavoritesPreferences() {
try {
const prefs = await invoke<FavoritesPreferences>('v2_get_favorites_preferences');
favoritesTabOrder = prefs.tab_order || ['tracks', 'albums', 'artists', 'labels', 'playlists'];
const defaultOrder = ['tracks', 'albums', 'artists', 'labels', 'playlists'];
let order = prefs.tab_order || defaultOrder;
// Backfill any tab the user's persisted order is missing (e.g. sessions
// created before `labels` was added as a favorites tab). Preserve the
// user's existing order; append missing tabs in their default position.
const missing = defaultOrder.filter((tab) => !order.includes(tab));
if (missing.length > 0) {
const merged = [...order];
for (const tab of missing) {
const defaultIdx = defaultOrder.indexOf(tab);
merged.splice(defaultIdx, 0, tab);
}
order = merged;
}
favoritesTabOrder = order;
} catch (err) {
console.debug('[Sidebar] Failed to load favorites preferences:', err);
}
+37 -2
View File
@@ -2,6 +2,8 @@
import { Settings, SlidersHorizontal, Keyboard, Bug, BookOpen, CircleHelp } from 'lucide-svelte';
import { openUrl } from '@tauri-apps/plugin-opener';
import { t } from '$lib/i18n';
import ReportIssueModal from './ReportIssueModal.svelte';
import { setSettingsIntent } from '$lib/stores/settingsIntentStore';
interface Props {
username: string;
@@ -16,6 +18,25 @@
let isMenuOpen = $state(false);
let menuRef: HTMLDivElement | null = $state(null);
let showReportModal = $state(false);
const GITHUB_ISSUES_URL = 'https://github.com/vicrodh/qbz/issues/new?template=bug_report.yml';
const HIDE_REPORT_MODAL_KEY = 'qbz-hide-report-issue-modal';
function openGithubIssue() {
openUrl(GITHUB_ISSUES_URL).catch((err) => console.error('Failed to open URL:', err));
}
function handleReportGoToLogs() {
showReportModal = false;
setSettingsIntent({ section: 'developer', openLogs: true });
onSettingsClick();
}
function handleReportCreateIssue() {
showReportModal = false;
openGithubIssue();
}
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
@@ -43,8 +64,15 @@
function handleReportBug() {
closeMenu();
openUrl('https://github.com/vicrodh/qbz/issues/new?template=bug_report.yml')
.catch(err => console.error('Failed to open URL:', err));
let skipModal = false;
try {
skipModal = localStorage.getItem(HIDE_REPORT_MODAL_KEY) === 'true';
} catch { /* ignore */ }
if (skipModal) {
openGithubIssue();
} else {
showReportModal = true;
}
}
function handleDocumentation() {
@@ -132,6 +160,13 @@
</div>
</div>
<ReportIssueModal
isOpen={showReportModal}
onClose={() => showReportModal = false}
onGoToLogs={handleReportGoToLogs}
onCreateIssue={handleReportCreateIssue}
/>
<style>
.user-card {
display: flex;
+2 -2
View File
@@ -485,8 +485,8 @@
}
.logo-img {
width: 175px;
height: 175px;
width: 140px;
height: 140px;
object-fit: contain;
}
@@ -14,6 +14,7 @@
import RemoteControlSetupGuide from '../RemoteControlSetupGuide.svelte';
import LogsModal from '../LogsModal.svelte';
import DiagnosticsPanel from '../DiagnosticsPanel.svelte';
import { consumeSettingsIntent } from '$lib/stores/settingsIntentStore';
import { platform } from '$lib/utils/platform';
import VolumeSlider from '../VolumeSlider.svelte';
import UpdateCheckResultModal from '../updates/UpdateCheckResultModal.svelte';
@@ -41,6 +42,14 @@
import MigrationModal from '../MigrationModal.svelte';
import { getDevicePrettyName } from '$lib/utils/audioDeviceNames';
import { getUserItem, setUserItem, removeUserItem } from '$lib/utils/userStorage';
import {
initWindowTitleStore,
getWindowTitleEnabled,
getWindowTitleTemplate,
setWindowTitleEnabled,
setWindowTitleTemplate,
DEFAULT_WINDOW_TITLE_TEMPLATE,
} from '$lib/stores/windowTitleStore';
import { getConfig as getImmersiveConfig, setConfig as setImmersiveConfig } from '$lib/immersive';
import { ZOOM_OPTIONS, findZoomOption, getZoomLevelFromOption } from '$lib/utils/zoom';
import { getZoom, setZoom, subscribeZoom } from '$lib/stores/zoomStore';
@@ -987,6 +996,10 @@
let systemNotificationsEnabled = $state(true);
let language = $state('Auto');
// Window title (OS title bar) preference — opt-in track metadata
let windowTitleEnabled = $state(false);
let windowTitleTemplate = $state(DEFAULT_WINDOW_TITLE_TEMPLATE);
// Title bar settings
let hideTitleBar = $state(getHideTitleBar());
let useSystemTitleBar = $state(getUseSystemTitleBar());
@@ -1404,6 +1417,20 @@
// Load saved settings on mount
onMount(() => {
// Cross-component intent: e.g. the Report Issue modal tells us to jump
// to Developer Mode and auto-open the Logs modal. Apply once and clear.
try {
const intent = consumeSettingsIntent();
if (intent?.section) {
activeSection = intent.section;
}
if (intent?.openLogs) {
showLogsModal = true;
}
} catch {
// Ignore — defensive
}
// Fire desktop theme detection in the background (Plasma/Klassy). Used
// to decide whether to expose the "Klassy/Plasma (auto-detect)" preset
// in the Appearance section. Non-blocking — failure just leaves the
@@ -1550,6 +1577,11 @@
loadSystemNotificationsPreference();
systemNotificationsEnabled = getSystemNotificationsEnabled();
// Load window title preference
initWindowTitleStore();
windowTitleEnabled = getWindowTitleEnabled();
windowTitleTemplate = getWindowTitleTemplate();
// Load playback preferences
loadPlaybackPreferences();
@@ -4474,6 +4506,32 @@
<span class="setting-label">{$t('settings.appearance.systemNotifications')}</span>
<Toggle enabled={systemNotificationsEnabled} onchange={(v) => { systemNotificationsEnabled = v; setSystemNotificationsEnabled(v); }} />
</div>
<div class="setting-row">
<span class="setting-label">{$t('settings.appearance.windowTitleShow')}</span>
<Toggle
enabled={windowTitleEnabled}
onchange={(v) => { windowTitleEnabled = v; setWindowTitleEnabled(v); }}
/>
</div>
{#if windowTitleEnabled}
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.windowTitleTemplate')}</span>
<small class="setting-note">{$t('settings.appearance.windowTitleTemplateHelp')}</small>
</div>
<input
type="text"
class="text-input"
value={windowTitleTemplate}
placeholder={DEFAULT_WINDOW_TITLE_TEMPLATE}
onchange={(e) => {
const next = e.currentTarget.value;
windowTitleTemplate = next;
setWindowTitleTemplate(next);
}}
/>
</div>
{/if}
<!-- Title bar toggles: hidden on macOS (always uses native overlay title bar) -->
{#if platform !== 'macos'}
<div class="setting-row">
+19
View File
@@ -978,6 +978,9 @@
"uiScale": "UI-Skalierung",
"inAppToasts": "In-App-Benachrichtigungen",
"systemNotifications": "Systembenachrichtigungen",
"windowTitleShow": "Titel im Fenstertitel anzeigen",
"windowTitleTemplate": "Titelvorlage",
"windowTitleTemplateHelp": "Platzhalter: {artist}, {track}, {album}",
"useSystemTitleBar": "System-Titelleiste verwenden",
"useSystemTitleBarDesc": "QBZ-eigene Titelleiste durch die native Fensterdekoration des Systems ersetzen. Änderung startet die App neu.",
"hideTitleBar": "Titelleiste ausblenden",
@@ -1352,6 +1355,9 @@
"uploadSuccess": "Log-URL in Zwischenablage kopiert",
"uploadError": "Fehler beim Hochladen der Logs",
"uploading": "Wird hochgeladen...",
"bundleDiagnostics": "Systemdiagnose einbeziehen",
"bundleDiagnosticsDesc": "Hängt einen Runtime-/System-Snapshot + einen 10s-Cast-Scan an das Terminal-Paste an, damit ein Link alles abdeckt.",
"collectingDiagnostics": "Diagnose wird erfasst...",
"bugReportHint": "Fehler melden? Laden Sie beide Tabs hoch und teilen Sie beide URLs — das hilft uns, Probleme schneller zu beheben. Danke!",
"verboseLogCapture": "Ausführliche Log-Erfassung",
"verboseLogCaptureDesc": "Alle Konsolen-Logs erfassen (log/info), nicht nur Fehler. Verwendet mehr Speicher.",
@@ -1369,6 +1375,12 @@
"sectionAudio": "Audio",
"sectionGraphics": "Grafik & Rendering",
"sectionEnv": "Umgebungsvariablen",
"sectionPlayback": "Wiedergabe",
"sectionSystem": "System",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Cast-Erkennung",
"castScan": "10s scannen",
"castScanning": "Scanne...",
"colSetting": "Einstellung",
"colSaved": "Gespeichert",
"colRuntime": "Laufzeit",
@@ -1695,6 +1707,13 @@
"awardUnavailable": "Detailansicht für diese Auszeichnung nicht verfügbar.",
"audioDeviceMissing": "Ausgabegerät '{device}' ist nicht mehr verfügbar. Standardgerät wird verwendet."
},
"reportIssue": {
"title": "Problem melden",
"intro": "Um die Behebung von Bugs und Problemen zu erleichtern, empfehlen wir, die Anwendungsprotokolle zu teilen. Wir haben uns sehr bemüht, die übermittelten Protokolle zu maskieren, damit keine sensiblen Daten enthalten sind. Der Vorgang ist manuell, niemals automatisch, damit du prüfen kannst, was geteilt wird.",
"goToLogs": "Zu den Protokollen",
"createIssue": "Problembericht erstellen",
"hideNextTime": "Nicht mehr anzeigen. Du kannst die Protokoll-Ansicht jederzeit unter Einstellungen > Entwicklermodus > Protokolle anzeigen öffnen."
},
"offline": {
"title": "Offline-Modus",
"message": "Verbinde dich mit dem Internet, um auf Qobuz™-Inhalte zuzugreifen.",
+19
View File
@@ -978,6 +978,9 @@
"uiScale": "UI Scale",
"inAppToasts": "In-app toasts notifications",
"systemNotifications": "System Notifications",
"windowTitleShow": "Show track in window title",
"windowTitleTemplate": "Title template",
"windowTitleTemplateHelp": "Placeholders: {artist}, {track}, {album}",
"useSystemTitleBar": "Use system title bar",
"useSystemTitleBarDesc": "Replace QBZ custom title bar with your system's native window decorations. Changing this will restart the app.",
"hideTitleBar": "Hide title bar",
@@ -1358,6 +1361,9 @@
"uploadSuccess": "Log URL copied to clipboard",
"uploadError": "Failed to upload logs",
"uploading": "Uploading...",
"bundleDiagnostics": "Include system diagnostics",
"bundleDiagnosticsDesc": "Attach a runtime/system snapshot + a 10s cast scan to the terminal paste so one link covers everything.",
"collectingDiagnostics": "Collecting diagnostics...",
"bugReportHint": "Reporting a bug? Upload both tabs and share both URLs — it helps us fix things faster. Thanks!",
"verboseLogCapture": "Verbose Log Capture",
"verboseLogCaptureDesc": "Capture all console logs (log/info), not just errors. Uses more memory.",
@@ -1375,6 +1381,12 @@
"sectionAudio": "Audio",
"sectionGraphics": "Graphics & Rendering",
"sectionEnv": "Environment Variables",
"sectionPlayback": "Playback",
"sectionSystem": "System",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Cast Discovery",
"castScan": "Run 10s scan",
"castScanning": "Scanning...",
"colSetting": "Setting",
"colSaved": "Saved",
"colRuntime": "Runtime",
@@ -1724,6 +1736,13 @@
"awardUnavailable": "Award detail not available for this entry.",
"audioDeviceMissing": "Output device '{device}' is no longer available. Using system default."
},
"reportIssue": {
"title": "Report an issue",
"intro": "To make bug and issue resolution easier, it's recommended to share the application logs. We have been careful to mask the logs we send so no sensitive data is included. The process is manual, never automatic, so you can verify what is being shared.",
"goToLogs": "Go to logs",
"createIssue": "Create issue report",
"hideNextTime": "Do not show this again, you can always access the log screen on Settings > Developer Mode > View logs"
},
"offline": {
"title": "Offline Mode",
"message": "Connect to the internet to access Qobuz™ content.",
+19
View File
@@ -978,6 +978,9 @@
"uiScale": "Escala de UI",
"inAppToasts": "Notificaciones en App",
"systemNotifications": "Notificaciones del Sistema",
"windowTitleShow": "Mostrar pista en título de ventana",
"windowTitleTemplate": "Plantilla del título",
"windowTitleTemplateHelp": "Marcadores: {artist}, {track}, {album}",
"useSystemTitleBar": "Usar barra de título del sistema",
"useSystemTitleBarDesc": "Reemplazar la barra de título personalizada de QBZ con las decoraciones nativas de tu sistema. Cambiar esto reiniciará la app.",
"hideTitleBar": "Ocultar barra de título",
@@ -1358,6 +1361,9 @@
"uploadSuccess": "URL del log copiada al portapapeles",
"uploadError": "Error al subir los logs",
"uploading": "Subiendo...",
"bundleDiagnostics": "Incluir diagnóstico del sistema",
"bundleDiagnosticsDesc": "Adjunta un snapshot de runtime/sistema + un escaneo de cast de 10s al paste del terminal para compartir todo con un solo enlace.",
"collectingDiagnostics": "Recopilando diagnóstico...",
"bugReportHint": "Si vas a reportar un bug, sube ambas pestanas y comparte las dos URLs — nos ayuda a resolver el problema mas rapido. Gracias!",
"verboseLogCapture": "Captura de logs detallada",
"verboseLogCaptureDesc": "Captura todos los logs de consola (log/info), no solo errores. Usa mas memoria.",
@@ -1375,6 +1381,12 @@
"sectionAudio": "Audio",
"sectionGraphics": "Gráficos y renderizado",
"sectionEnv": "Variables de entorno",
"sectionPlayback": "Reproducción",
"sectionSystem": "Sistema",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Descubrimiento Cast",
"castScan": "Escanear 10s",
"castScanning": "Escaneando...",
"colSetting": "Configuración",
"colSaved": "Guardado",
"colRuntime": "Ejecución",
@@ -1724,6 +1736,13 @@
"awardUnavailable": "No hay detalle disponible para este premio.",
"audioDeviceMissing": "El dispositivo '{device}' ya no está disponible. Usando el predeterminado del sistema."
},
"reportIssue": {
"title": "Reportar un problema",
"intro": "Para hacer más sencilla la resolución de bugs y problemas, es recomendable compartir los logs de la aplicación. Nos hemos encargado de ser muy cuidadosos y enmascarar los logs que se envían para no enviar ningún dato sensible; el proceso es manual, nada automático, para que tú puedas verificar lo que se comparte.",
"goToLogs": "Ir a los logs",
"createIssue": "Crear reporte",
"hideNextTime": "No volver a mostrar esto, siempre puedes acceder a la pantalla de logs en Ajustes > Modo Desarrollador > Ver logs"
},
"offline": {
"title": "Modo Offline",
"message": "Conecta a internet para acceder al contenido de Qobuz™.",
+19
View File
@@ -978,6 +978,9 @@
"uiScale": "Échelle de l'interface",
"inAppToasts": "Notifications toast intégrées",
"systemNotifications": "Notifications système",
"windowTitleShow": "Afficher la piste dans le titre",
"windowTitleTemplate": "Modèle du titre",
"windowTitleTemplateHelp": "Variables : {artist}, {track}, {album}",
"useSystemTitleBar": "Utiliser la barre de titre du système",
"useSystemTitleBarDesc": "Remplacer la barre de titre personnalisée de QBZ par les décorations natives de votre système. Ce changement redémarrera l'application.",
"hideTitleBar": "Masquer la barre de titre",
@@ -1352,6 +1355,9 @@
"uploadSuccess": "URL du log copiée dans le presse-papiers",
"uploadError": "Échec du téléversement des logs",
"uploading": "Téléversement...",
"bundleDiagnostics": "Inclure le diagnostic système",
"bundleDiagnosticsDesc": "Joint un instantané runtime/système + un scan cast de 10s au paste terminal pour tout partager en un seul lien.",
"collectingDiagnostics": "Collecte du diagnostic...",
"bugReportHint": "Vous signalez un bug ? Téléversez les deux onglets et partagez les deux URLs — cela nous aide a résoudre les problèmes plus vite. Merci !",
"verboseLogCapture": "Capture de logs détaillée",
"verboseLogCaptureDesc": "Capturer tous les logs de console (log/info), pas seulement les erreurs. Utilise plus de mémoire.",
@@ -1369,6 +1375,12 @@
"sectionAudio": "Audio",
"sectionGraphics": "Graphiques et rendu",
"sectionEnv": "Variables d'environnement",
"sectionPlayback": "Lecture",
"sectionSystem": "Système",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Découverte Cast",
"castScan": "Scan 10s",
"castScanning": "Analyse...",
"colSetting": "Paramètre",
"colSaved": "Enregistré",
"colRuntime": "Exécution",
@@ -1695,6 +1707,13 @@
"awardUnavailable": "Détail non disponible pour cette récompense.",
"audioDeviceMissing": "Le périphérique '{device}' n'est plus disponible. Utilisation du périphérique par défaut."
},
"reportIssue": {
"title": "Signaler un problème",
"intro": "Pour faciliter la résolution des bugs et des problèmes, il est recommandé de partager les journaux de l'application. Nous avons pris soin de masquer les journaux envoyés afin qu'aucune donnée sensible ne soit incluse. Le processus est manuel, jamais automatique, pour que vous puissiez vérifier ce qui est partagé.",
"goToLogs": "Aller aux journaux",
"createIssue": "Créer un rapport",
"hideNextTime": "Ne plus afficher. Vous pouvez toujours accéder à l'écran des journaux via Paramètres > Mode développeur > Voir les journaux."
},
"offline": {
"title": "Mode hors ligne",
"message": "Connectez-vous à Internet pour accéder au contenu Qobuz™.",
+19
View File
@@ -978,6 +978,9 @@
"uiScale": "Escala da Interface",
"inAppToasts": "Notificações internas do app",
"systemNotifications": "Notificações do Sistema",
"windowTitleShow": "Mostrar faixa no título",
"windowTitleTemplate": "Modelo do título",
"windowTitleTemplateHelp": "Marcadores: {artist}, {track}, {album}",
"useSystemTitleBar": "Usar barra de título do sistema",
"useSystemTitleBarDesc": "Substituir a barra de título personalizada do QBZ pelas decorações de janela nativas do seu sistema. Alterar isso reiniciará o app.",
"hideTitleBar": "Ocultar barra de título",
@@ -1352,6 +1355,9 @@
"uploadSuccess": "URL do log copiada para a área de transferência",
"uploadError": "Falha ao enviar logs",
"uploading": "Enviando...",
"bundleDiagnostics": "Incluir diagnóstico do sistema",
"bundleDiagnosticsDesc": "Anexa um snapshot de runtime/sistema + um escaneamento cast de 10s ao paste do terminal para compartilhar tudo em um único link.",
"collectingDiagnostics": "Coletando diagnóstico...",
"bugReportHint": "Reportando um bug? Envie ambas as abas e compartilhe as duas URLs — isso nos ajuda a corrigir mais rápido. Obrigado!",
"verboseLogCapture": "Captura Detalhada de Logs",
"verboseLogCaptureDesc": "Capturar todos os logs do console (log/info), não apenas erros. Usa mais memória.",
@@ -1369,6 +1375,12 @@
"sectionAudio": "Áudio",
"sectionGraphics": "Gráficos e renderização",
"sectionEnv": "Variáveis de ambiente",
"sectionPlayback": "Reprodução",
"sectionSystem": "Sistema",
"sectionQconnect": "Qobuz Connect",
"sectionCast": "Descoberta Cast",
"castScan": "Escanear 10s",
"castScanning": "Escaneando...",
"colSetting": "Configuração",
"colSaved": "Salvo",
"colRuntime": "Execução",
@@ -1718,6 +1730,13 @@
"awardUnavailable": "Detalhe não disponível para este prêmio.",
"audioDeviceMissing": "Dispositivo '{device}' não está mais disponível. Usando o padrão do sistema."
},
"reportIssue": {
"title": "Reportar um problema",
"intro": "Para facilitar a resolução de bugs e problemas, é recomendado compartilhar os logs da aplicação. Tivemos o cuidado de mascarar os logs enviados para que nenhum dado sensível seja incluído. O processo é manual, nunca automático, para que você possa verificar o que é compartilhado.",
"goToLogs": "Ir para os logs",
"createIssue": "Criar relato",
"hideNextTime": "Não mostrar novamente. Você sempre pode acessar a tela de logs em Configurações > Modo Desenvolvedor > Ver logs."
},
"offline": {
"title": "Modo Offline",
"message": "Conecte-se à internet para acessar o conteúdo do Qobuz™.",
+155
View File
@@ -0,0 +1,155 @@
/**
* diagnosticsSnapshot
*
* Collects the same payload the System Diagnostics panel exports, in a
* text-block format suited for attaching to a paste-site upload alongside
* the terminal log. Used by the "include diagnostics" toggle in the
* Developer Mode > View Logs modal so users can share one link instead
* of two.
*
* No sensitive data. Track fields exclude IDs/UUIDs; QConnect fields
* exclude endpoint URLs and renderer UUIDs; cast devices expose only
* friendly names (no hosts/ports).
*/
import { invoke } from '@tauri-apps/api/core';
import {
getCurrentTrack,
getIsPlaying,
getCurrentTime,
getDuration,
getVolume,
} from '$lib/stores/playerStore';
import type {
QconnectConnectionStatus,
QconnectSessionSnapshot,
} from '$lib/services/qconnectRuntime';
function redactIdLike(value: string | null | undefined): string | null {
if (!value) return null;
return value
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
.replace(/\b[0-9a-f]{32,}\b/gi, '<hex>');
}
function snapshotPlayback(): Record<string, unknown> {
const track = getCurrentTrack();
return {
isPlaying: getIsPlaying(),
volumePercent: Math.round(getVolume()),
positionSecs: Math.round(getCurrentTime()),
durationSecs: Math.round(getDuration()),
hasTrack: !!track,
trackTitle: track?.title ?? null,
trackArtist: track?.artist ?? null,
trackAlbum: track?.album ?? null,
trackQuality: track?.quality ?? null,
trackFormat: track?.format ?? null,
trackBitDepth: track?.bitDepth ?? null,
trackSamplingRate: track?.samplingRate ?? null,
trackIsLocal: track?.isLocal ?? null,
trackSource: track?.source ?? null,
};
}
async function snapshotQconnect(): Promise<Record<string, unknown>> {
try {
const status = await invoke<QconnectConnectionStatus>('v2_qconnect_get_status');
const session = await invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot');
const activeId = session?.active_renderer_id ?? null;
const localId = session?.local_renderer_id ?? null;
const active = activeId != null
? session?.renderers?.find((r) => r.renderer_id === activeId)
: null;
let role: 'none' | 'controller' | 'local-renderer' | 'observer' = 'none';
if (activeId != null) {
role = activeId === localId ? 'local-renderer' : 'controller';
} else if (status.running || status.transport_connected) {
role = 'observer';
}
return {
running: !!status.running,
transport_connected: !!status.transport_connected,
hasEndpoint: !!status.endpoint_url,
lastError: status.last_error ? redactIdLike(String(status.last_error)) : null,
role,
activeRendererName: active?.friendly_name ?? null,
activeRendererBrand: active?.brand ?? null,
activeRendererModel: active?.model ?? null,
rendererCount: session?.renderers?.length ?? 0,
};
} catch {
return { unavailable: true };
}
}
/**
* Run a brief multicast scan and report the friendly-name list per protocol.
* Does NOT persist cast state; stops discovery before returning.
*/
async function scanCastDevices(durationMs: number): Promise<Record<string, unknown>> {
const started = Date.now();
try {
await Promise.allSettled([
invoke('v2_cast_start_discovery'),
invoke('v2_dlna_start_discovery'),
]);
} catch { /* ignore */ }
await new Promise((resolve) => setTimeout(resolve, durationMs));
let chromecast: { name?: string }[] = [];
let dlna: { name?: string }[] = [];
try {
const [cc, dl] = await Promise.allSettled([
invoke<{ name?: string }[]>('v2_cast_get_devices'),
invoke<{ name?: string }[]>('v2_dlna_get_devices'),
]);
if (cc.status === 'fulfilled') chromecast = cc.value ?? [];
if (dl.status === 'fulfilled') dlna = dl.value ?? [];
} catch { /* ignore */ }
try {
await Promise.allSettled([
invoke('v2_cast_stop_discovery'),
invoke('v2_dlna_stop_discovery'),
]);
} catch { /* ignore */ }
return {
chromecastCount: chromecast.length,
dlnaCount: dlna.length,
chromecastDevices: chromecast.map((d) => d.name ?? '<unnamed>'),
dlnaDevices: dlna.map((d) => d.name ?? '<unnamed>'),
durationMs: Date.now() - started,
};
}
export interface CollectOptions {
includeCastScan?: boolean;
castScanMs?: number;
}
/**
* Collect all diagnostic data and return it as a pretty-printed JSON block
* suitable for prepending to a paste upload.
*/
export async function collectDiagnosticsText(opts: CollectOptions = {}): Promise<string> {
const castPromise = opts.includeCastScan
? scanCastDevices(opts.castScanMs ?? 10000)
: Promise.resolve(null);
const [runtime, system, qconnect, cast] = await Promise.all([
invoke<unknown>('v2_get_runtime_diagnostics').catch((e) => ({ error: String(e) })),
invoke<unknown>('v2_get_system_info').catch((e) => ({ error: String(e) })),
snapshotQconnect(),
castPromise,
]);
const payload = {
exportedAt: new Date().toISOString(),
runtime,
system,
playback: snapshotPlayback(),
qconnect,
castScan: cast,
};
return JSON.stringify(payload, null, 2);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* settingsIntentStore
*
* Cross-component cue for navigating to a specific Settings section and
* optionally triggering an action on arrival (e.g. open the Logs modal
* from the "Report Issue" pop-up).
*
* The intent is consumed (cleared) as soon as SettingsView applies it,
* so subsequent visits to Settings use their normal default section.
*/
export interface SettingsIntent {
section?: string;
openLogs?: boolean;
}
let pending: SettingsIntent | null = null;
const listeners = new Set<() => void>();
export function setSettingsIntent(intent: SettingsIntent): void {
pending = intent;
for (const listener of listeners) listener();
}
export function consumeSettingsIntent(): SettingsIntent | null {
const intent = pending;
pending = null;
return intent;
}
export function peekSettingsIntent(): SettingsIntent | null {
return pending;
}
export function subscribeSettingsIntent(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Window Title Store
*
* Manages the opt-in "show track in OS window title" preference.
*
* Settings:
* - enabled: toggle that controls whether the OS window title should reflect
* the currently playing track (default: false)
* - template: format string with {artist}, {track}, {album} placeholders
* (default: "{artist} — {track}")
*
* Persisted to localStorage — pure UX preference, no backend state.
*/
const STORAGE_KEY_ENABLED = 'qbz-window-title-enabled';
const STORAGE_KEY_TEMPLATE = 'qbz-window-title-template';
export const DEFAULT_WINDOW_TITLE_TEMPLATE = '{artist} — {track}';
// State
let enabled = false;
let template = DEFAULT_WINDOW_TITLE_TEMPLATE;
// Listeners for cross-component reactivity
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
/**
* Initialize the store from localStorage.
* Safe to call multiple times; subsequent calls are no-ops beyond re-reading
* the latest values.
*/
export function initWindowTitleStore(): void {
try {
const savedEnabled = localStorage.getItem(STORAGE_KEY_ENABLED);
if (savedEnabled !== null) {
enabled = savedEnabled === 'true';
}
const savedTemplate = localStorage.getItem(STORAGE_KEY_TEMPLATE);
if (savedTemplate !== null && savedTemplate.length > 0) {
template = savedTemplate;
}
} catch (e) {
console.error('[WindowTitleStore] Failed to initialize:', e);
}
}
/**
* Subscribe to window-title preference changes.
* Listener is invoked immediately with current state, and on every change.
* Returns an unsubscribe function.
*/
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
listener();
return () => {
listeners.delete(listener);
};
}
export function getWindowTitleEnabled(): boolean {
return enabled;
}
export function getWindowTitleTemplate(): string {
return template;
}
export function setWindowTitleEnabled(value: boolean): void {
enabled = value;
try {
localStorage.setItem(STORAGE_KEY_ENABLED, String(value));
} catch (e) {
console.error('[WindowTitleStore] Failed to save enabled setting:', e);
}
notifyListeners();
}
export function setWindowTitleTemplate(value: string): void {
template = value;
try {
localStorage.setItem(STORAGE_KEY_TEMPLATE, value);
} catch (e) {
console.error('[WindowTitleStore] Failed to save template setting:', e);
}
notifyListeners();
}
/**
* Apply the template substitutions with the given track metadata.
* Returns the rendered title, or an empty string if the template is empty
* after trimming (caller should fall back to the app name in that case).
*/
export function renderWindowTitle(
tpl: string,
track: { artist?: string; title?: string; album?: string } | null
): string {
if (!track) return '';
const artist = track.artist ?? '';
const title = track.title ?? '';
const album = track.album ?? '';
const rendered = tpl
.replaceAll('{artist}', artist)
.replaceAll('{track}', title)
.replaceAll('{album}', album);
return rendered.trim();
}
+52
View File
@@ -162,6 +162,13 @@
type UserInfo
} from '$lib/stores/authStore';
import { setStorageUserId, migrateLocalStorage, migrateLocalStorageV2, getUserItem, setUserItem } from '$lib/utils/userStorage';
import {
initWindowTitleStore,
getWindowTitleEnabled,
getWindowTitleTemplate,
renderWindowTitle,
subscribe as subscribeWindowTitle,
} from '$lib/stores/windowTitleStore';
// Favorites state management
import { loadFavorites } from '$lib/stores/favoritesStore';
@@ -963,6 +970,10 @@
// TitleBar reference for focusing search
let titlebarRef = $state<{ focusSearch: () => void } | undefined>(undefined);
// Window-title (OS title bar) preference — bumped on store changes so the
// effect below recomputes immediately when the user toggles the setting.
let windowTitlePrefVersion = $state(0);
// Playback State (from playerStore subscription)
let currentTrack = $state<PlayingTrack | null>(null);
let isPlaying = $state(false);
@@ -3937,6 +3948,39 @@
};
});
// OS window title: opt-in to reflect currently playing track.
// Depends on the store preference version + currentTrack so it reacts to
// both setting changes and track changes immediately.
$effect(() => {
void windowTitlePrefVersion;
const enabled = getWindowTitleEnabled();
const template = getWindowTitleTemplate();
const track = currentTrack;
const trackTitle = track?.title;
const trackArtist = track?.artist;
const trackAlbum = track?.album;
let nextTitle = 'QBZ';
if (enabled && track) {
const rendered = renderWindowTitle(template, {
artist: trackArtist,
title: trackTitle,
album: trackAlbum,
});
if (rendered.length > 0) {
nextTitle = rendered;
}
}
try {
getCurrentWindow().setTitle(nextTitle).catch((err) => {
console.warn('[WindowTitle] setTitle failed:', err);
});
} catch (err) {
console.warn('[WindowTitle] setTitle threw:', err);
}
});
// Debounced full session save (coalesces rapid state changes into a single save)
let sessionSaveDebounce: ReturnType<typeof setTimeout> | null = null;
function debouncedFullSessionSave() {
@@ -4050,6 +4094,13 @@
// Bootstrap app (theme, mouse nav, Last.fm restore)
const { cleanup: cleanupBootstrap } = bootstrapApp();
// Window-title preference: load from localStorage and subscribe so that
// toggling the setting updates the OS title bar immediately.
initWindowTitleStore();
const unsubscribeWindowTitle = subscribeWindowTitle(() => {
windowTitlePrefVersion = windowTitlePrefVersion + 1;
});
// Quality fallback modal listener
function handleQualityFallbackPrompt(e: Event) {
const detail = (e as CustomEvent).detail;
@@ -4920,6 +4971,7 @@
unlistenQconnectDiagnostic?.();
unlistenQconnectRendererReportDebug?.();
unlistenAudioDeviceMissing?.();
unsubscribeWindowTitle();
// Save session before cleanup
saveSessionBeforeClose();
cleanupBootstrap();