From a70a55c5ea8357e5c8181a008c7d69f9e9327a61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:37:03 +0000 Subject: [PATCH 01/26] chore(deps-dev): bump vitest from 4.1.2 to 4.1.4 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 94 +++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10a7985ae..bddf6c0ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "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" }, @@ -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 }, diff --git a/package.json b/package.json index 59d805613..3bf6933fa 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "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", From 15194eaf2d48705153e0e023ab2cdd530feed38c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:37:19 +0000 Subject: [PATCH 02/26] chore(deps-dev): bump svelte from 5.55.2 to 5.55.4 Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.2 to 5.55.4. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-version: 5.55.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10a7985ae..58038629c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@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", @@ -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", diff --git a/package.json b/package.json index 59d805613..2fa5c864a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@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", From 22005c3789d32aaae4682b919f8aadb4bb72e8f8 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:51:15 -0600 Subject: [PATCH 03/26] fix(carousel): don't steal clicks via implicit pointer capture (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HorizontalScrollRow.setPointerCapture() was called on every pointerdown inside the scroll container. Per the Pointer Events spec, once a pointer is captured, subsequent events — including the click that follows pointerup — are retargeted to the capture element. For cards rendered inside the carousel that meant clicks never reached the card button, so nothing happened when users tried to open an album, artist, or playlist from any row on Home / Discover / Editor's Picks / For You. Don't capture the pointer up front. Only call setPointerCapture once the move threshold (5px) is crossed, i.e. when we know this is a real drag and not a click. Plain clicks take their normal path to the card and navigation works; drags still get capture so the scroll keeps tracking the cursor if it leaves the container. Also wrap both capture/release in try/catch (some WebKit versions throw when the pointer is no longer valid) and reset dragDistance in onClickCapture so a swallowed click doesn't poison the next one. cc --- src/lib/components/HorizontalScrollRow.svelte | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/lib/components/HorizontalScrollRow.svelte b/src/lib/components/HorizontalScrollRow.svelte index f0940aa71..777a47409 100644 --- a/src/lib/components/HorizontalScrollRow.svelte +++ b/src/lib/components/HorizontalScrollRow.svelte @@ -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); From ace9e93b846076bbddbf61cb88a56661b8ad3857 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:58:03 -0600 Subject: [PATCH 04/26] feat(window): optional track metadata in window title (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in setting under Appearance that lets the OS window title reflect the currently playing track. Template supports {artist}, {track}, {album} placeholders; defaults to "{artist} — {track}". Falls back to "QBZ" when disabled or with no track. Persisted to localStorage (pure UX preference — no backend state). cc --- src/lib/components/views/SettingsView.svelte | 43 +++++++ src/lib/i18n/locales/de.json | 3 + src/lib/i18n/locales/en.json | 3 + src/lib/i18n/locales/es.json | 3 + src/lib/i18n/locales/fr.json | 3 + src/lib/i18n/locales/pt.json | 3 + src/lib/stores/windowTitleStore.ts | 112 +++++++++++++++++++ src/routes/+page.svelte | 50 +++++++++ 8 files changed, 220 insertions(+) create mode 100644 src/lib/stores/windowTitleStore.ts diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 184040f57..3166906a1 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -41,6 +41,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 +995,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()); @@ -1550,6 +1562,11 @@ loadSystemNotificationsPreference(); systemNotificationsEnabled = getSystemNotificationsEnabled(); + // Load window title preference + initWindowTitleStore(); + windowTitleEnabled = getWindowTitleEnabled(); + windowTitleTemplate = getWindowTitleTemplate(); + // Load playback preferences loadPlaybackPreferences(); @@ -4474,6 +4491,32 @@ {$t('settings.appearance.systemNotifications')} { systemNotificationsEnabled = v; setSystemNotificationsEnabled(v); }} /> +
+ {$t('settings.appearance.windowTitleShow')} + { windowTitleEnabled = v; setWindowTitleEnabled(v); }} + /> +
+ {#if windowTitleEnabled} +
+
+ {$t('settings.appearance.windowTitleTemplate')} + {$t('settings.appearance.windowTitleTemplateHelp')} +
+ { + const next = e.currentTarget.value; + windowTitleTemplate = next; + setWindowTitleTemplate(next); + }} + /> +
+ {/if} {#if platform !== 'macos'}
diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index ebe060d49..93a2eac10 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -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", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index a3524f3cd..22895f48c 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -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", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index f4dc1355e..94db28f55 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -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", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index ae3aa2d49..df4fe529a 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -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", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index aa64cff7f..fd55b87cf 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -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", diff --git a/src/lib/stores/windowTitleStore.ts b/src/lib/stores/windowTitleStore.ts new file mode 100644 index 000000000..325bd2eea --- /dev/null +++ b/src/lib/stores/windowTitleStore.ts @@ -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(); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4babd6b59..53174ffa5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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(null); let isPlaying = $state(false); @@ -3937,6 +3948,37 @@ }; }); + // 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(() => { + // Track dependencies explicitly so the effect re-runs on preference change. + void windowTitlePrefVersion; + const enabled = getWindowTitleEnabled(); + const template = getWindowTitleTemplate(); + const track = currentTrack; + + let nextTitle = 'QBZ'; + if (enabled && track) { + const rendered = renderWindowTitle(template, { + artist: track.artist, + title: track.title, + album: track.album, + }); + 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 | null = null; function debouncedFullSessionSave() { @@ -4050,6 +4092,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 +4969,7 @@ unlistenQconnectDiagnostic?.(); unlistenQconnectRendererReportDebug?.(); unlistenAudioDeviceMissing?.(); + unsubscribeWindowTitle(); // Save session before cleanup saveSessionBeforeClose(); cleanupBootstrap(); From fa7758f5bb486eb572a3e1cf1d2bf77e6f62c3cd Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:48:43 -0600 Subject: [PATCH 05/26] fix(window,login): grant set-title capability + shrink login logo 20% - Add core:window:allow-set-title to capabilities/default.json. Tauri 2 rejects setTitle() from the frontend unless the capability is explicitly granted; without this the window-title feature silently fell back to "QBZ" in every case. - Login logo: 175px -> 140px (-20%) per feedback. - Drop the debug console logging on the title effect now that the wiring is confirmed working; keep the warn-on-failure. cc --- src-tauri/capabilities/default.json | 1 + src/lib/components/views/LoginView.svelte | 4 ++-- src/routes/+page.svelte | 10 ++++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 30f7e27b4..49c74a902 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src/lib/components/views/LoginView.svelte b/src/lib/components/views/LoginView.svelte index 953b4088a..f8ddf3bcf 100644 --- a/src/lib/components/views/LoginView.svelte +++ b/src/lib/components/views/LoginView.svelte @@ -485,8 +485,8 @@ } .logo-img { - width: 175px; - height: 175px; + width: 140px; + height: 140px; object-fit: contain; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 53174ffa5..6ff2291a1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3952,18 +3952,20 @@ // Depends on the store preference version + currentTrack so it reacts to // both setting changes and track changes immediately. $effect(() => { - // Track dependencies explicitly so the effect re-runs on preference change. 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: track.artist, - title: track.title, - album: track.album, + artist: trackArtist, + title: trackTitle, + album: trackAlbum, }); if (rendered.length > 0) { nextTitle = rendered; From bbf368440e1538faa90d618239a6896a96dd4848 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:02:07 -0600 Subject: [PATCH 06/26] fix(cast): keep scanning spinner visible for the full discovery window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multicast discovery (DLNA / Chromecast) typically needs 5-15s before devices announce themselves. The previous code set loading=false after the first pollDevices() call, so users saw an empty "No devices found" state just a second or two after opening the picker — even though discovery was still in progress. Add a separate `scanning` flag that stays true for 15 seconds after startDiscovery (cleared explicitly on stopDiscovery and on error). The empty state now shows the spinner + "Loading devices" while (loading || scanning), so users get visual feedback during the full multicast window instead of a misleading "no devices found". cc --- src/lib/components/CastPicker.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/components/CastPicker.svelte b/src/lib/components/CastPicker.svelte index a8967fcb3..943f01890 100644 --- a/src/lib/components/CastPicker.svelte +++ b/src/lib/components/CastPicker.svelte @@ -78,6 +78,13 @@ let error = $state(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 | 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 @@
{/if} - {:else if loading && devices().length === 0} + {:else if devices().length === 0 && (loading || scanning)}

{$t('toast.loadingDevices')}

From c3a270fd18735dfa78de0bd25651403e9180f60f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:16:38 -0600 Subject: [PATCH 07/26] chore(deps): bump rand 0.10.0 -> 0.10.1 to fix GHSA-cq8v-f236-94qc Advisory: rand is unsound with a custom logger using rand::rng(). Affects 0.10.x only; 0.10.1 fixes the issue. The older rand versions still present in the lockfiles (0.7.3, 0.8.5, 0.9.2) are transitive dependencies that do not use the rand::rng() API surface introduced in 0.10 and are not exploitable via this advisory. The lru 0.12.5 advisory (GHSA-rhfx-m35p-ff5j) is a transitive dep of ratatui 0.29.0 (used by qbzd). Can't be bumped without upstream ratatui dropping the old lru; tracked separately. cc --- crates/Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index e5656d34e..c1cac05e8 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -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", From e9040b4b6dcf24876f9ce24cec918f07ce92bbcf Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:19:39 -0600 Subject: [PATCH 08/26] fix(sidebar): backfill missing favorites tabs (e.g. 'labels') Users whose favorites tab_order was persisted before 'labels' became a favorites tab see the sidebar render without the Labels entry, while the TitleBar nav (which uses a fresh default) shows it fine. When loading the persisted tab_order, diff against the canonical default and splice back any tab the user is missing at its default position. Preserves any reordering the user applied. cc --- src/lib/components/Sidebar.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index b1b7672a3..44bab1eca 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -973,7 +973,21 @@ async function loadFavoritesPreferences() { try { const prefs = await invoke('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); } From 0d852410f2c0d1fa27641acebd1b22e63261d2ac Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:42:43 -0600 Subject: [PATCH 09/26] fix(carousel): disable text selection in horizontal rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous CSS only disabled user-select while .dragging was active, but the drag class only gets applied after crossing the 5px threshold in onPointerMove. During those first few pixels the browser's default selection behaviour would highlight card titles and artist names, which the user then has to click away to clear. Disable user-select on the scroll-container permanently. Text is still selectable in the album/artist/playlist detail views — only the carousel rows themselves are locked. cc --- src/lib/components/HorizontalScrollRow.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/components/HorizontalScrollRow.svelte b/src/lib/components/HorizontalScrollRow.svelte index 777a47409..2bfbc6acd 100644 --- a/src/lib/components/HorizontalScrollRow.svelte +++ b/src/lib/components/HorizontalScrollRow.svelte @@ -189,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 { From d673dc206ac13e1fd96e6e504eb2c088bd73d24a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:46:53 -0600 Subject: [PATCH 10/26] fix(ui): thicken action-btn-circle stroke from 1px to 2px (3px primary) The 1px inset stroke on .action-btn-circle read as jagged at standard resolution (sub-pixel antialiasing artifacts) and effectively invisible on HiDPI displays. Bump the resting stroke to 2px (secondary) and 3px (primary play button) so it survives both; hover states scale with it to keep the visual hierarchy. The extra pixels eat slightly into the concentric fill, per the requested trade-off. Applies to all reuse sites: Album/Artist/Playlist/Label/FavQ detail headers, DynamicSuggest, ForYouTab, LocalLibrary, etc. cc --- src/app.css | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app.css b/src/app.css index 4e66d5b2e..5d15b9f6d 100644 --- a/src/app.css +++ b/src/app.css @@ -1568,14 +1568,16 @@ select { background: transparent; color: var(--text-muted); cursor: pointer; - box-shadow: inset 0 0 0 1px var(--border-strong); + /* 2px inset stroke (was 1px) — 1px disappears on HiDPI and looks jagged + from sub-pixel antialiasing at the default resolution. */ + box-shadow: inset 0 0 0 2px var(--border-strong); transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; } .action-btn-circle:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); - box-shadow: inset 0 0 0 1px var(--text-primary); + box-shadow: inset 0 0 0 2px var(--text-primary); } .action-btn-circle:disabled { @@ -1589,18 +1591,20 @@ select { background: var(--accent-primary); border: none; color: var(--text-primary); - box-shadow: inset 0 0 0 2px var(--border-subtle); + /* 3px inset stroke (was 2px) keeps the visual hierarchy vs the smaller + secondary buttons now that both got bumped. */ + box-shadow: inset 0 0 0 3px var(--border-subtle); transition: background 150ms ease, box-shadow 200ms ease; } .action-btn-circle.primary:hover { background: var(--accent-hover); - box-shadow: inset 0 0 0 2px var(--text-primary); + box-shadow: inset 0 0 0 3px var(--text-primary); } .action-btn-circle.is-active { background: rgba(var(--accent-primary-rgb, 66, 133, 244), 0.15); - box-shadow: inset 0 0 0 1px var(--accent-primary); + box-shadow: inset 0 0 0 2px var(--accent-primary); color: var(--accent-primary); } From a9eaf64612009ef3c5a811c5115ea5bc0709816d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:37 -0600 Subject: [PATCH 11/26] fix(ui): thicken only the resting stroke of the primary action button Previous commit bumped every stroke state to the same thickness. The hover and secondary-button states were fine; only the primary's resting stroke (using --border-subtle, a low-contrast colour) read as jagged from sub-pixel AA and disappeared on HiDPI. Revert secondary buttons, is-active, and primary-hover to their original 1-2px. Keep the primary resting state at 3px so it survives AA at the default resolution and stays visible on HiDPI. cc --- src/app.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.css b/src/app.css index 5d15b9f6d..ad3b27875 100644 --- a/src/app.css +++ b/src/app.css @@ -1568,16 +1568,14 @@ select { background: transparent; color: var(--text-muted); cursor: pointer; - /* 2px inset stroke (was 1px) — 1px disappears on HiDPI and looks jagged - from sub-pixel antialiasing at the default resolution. */ - box-shadow: inset 0 0 0 2px var(--border-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; } .action-btn-circle:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); - box-shadow: inset 0 0 0 2px var(--text-primary); + box-shadow: inset 0 0 0 1px var(--text-primary); } .action-btn-circle:disabled { @@ -1591,20 +1589,22 @@ select { background: var(--accent-primary); border: none; color: var(--text-primary); - /* 3px inset stroke (was 2px) keeps the visual hierarchy vs the smaller - secondary buttons now that both got bumped. */ + /* 3px inset stroke in the resting state: --border-subtle is a low-contrast + colour, and a 1-2px ring around it reads jagged from sub-pixel AA and + disappears on HiDPI. Hover swaps to the full-contrast --text-primary so + a thinner 2px stroke looks crisp there. */ box-shadow: inset 0 0 0 3px var(--border-subtle); transition: background 150ms ease, box-shadow 200ms ease; } .action-btn-circle.primary:hover { background: var(--accent-hover); - box-shadow: inset 0 0 0 3px var(--text-primary); + box-shadow: inset 0 0 0 2px var(--text-primary); } .action-btn-circle.is-active { background: rgba(var(--accent-primary-rgb, 66, 133, 244), 0.15); - box-shadow: inset 0 0 0 2px var(--accent-primary); + box-shadow: inset 0 0 0 1px var(--accent-primary); color: var(--accent-primary); } From eb9f82ea96d3977b55e3a0032109b315ae771d10 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:57 -0600 Subject: [PATCH 12/26] fix(ui): bump primary resting stroke to 5px for visibility 3px was still not enough against a low-contrast --border-subtle ring. Push it to 5px so the stroke is unmistakable at default resolution and survives HiDPI scaling. cc --- src/app.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app.css b/src/app.css index ad3b27875..007a1350d 100644 --- a/src/app.css +++ b/src/app.css @@ -1589,11 +1589,11 @@ select { background: var(--accent-primary); border: none; color: var(--text-primary); - /* 3px inset stroke in the resting state: --border-subtle is a low-contrast - colour, and a 1-2px ring around it reads jagged from sub-pixel AA and - disappears on HiDPI. Hover swaps to the full-contrast --text-primary so - a thinner 2px stroke looks crisp there. */ - box-shadow: inset 0 0 0 3px var(--border-subtle); + /* 5px inset stroke in the resting state: --border-subtle is low-contrast + against --accent-primary, so thinner rings read as jagged/fuzzy from + sub-pixel AA and vanish on HiDPI. Eat more of the concentric fill so + the ring is unmistakable; hover keeps its crisp 2px text-primary ring. */ + box-shadow: inset 0 0 0 5px var(--border-subtle); transition: background 150ms ease, box-shadow 200ms ease; } From d9b1efc091fc0cb0970974a6962dcf1c2ff30cf5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:05:07 -0600 Subject: [PATCH 13/26] feat(diagnostics): add Playback, Qobuz Connect, and Cast Discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the System Diagnostics panel with three new sections aimed at what debug tickets usually ask for, and include them in the clipboard export: - Playback: is-playing, volume, position/duration, current track (title, artist, album, source, local flag, quality/format/bit-depth/ sample-rate). No track IDs or UUIDs — defensive regex redacts any hex/UUID-shaped strings before export so paste sites and CodeQL don't flag them as secrets. - Qobuz Connect: running/transport_connected, role (controller / local-renderer / observer / none), active renderer friendly name/brand/model (no UUIDs or IPs), visible renderer count, last error (id-redacted). - Cast Discovery: on-demand 10-second multicast scan for Chromecast and DLNA; reports per-protocol count and the friendly names that were discovered. No hosts, no ports, no UUIDs. Playback rows stay live via a player-store subscription while the panel is open. cc --- src/lib/components/DiagnosticsPanel.svelte | 367 ++++++++++++++++++++- src/lib/i18n/locales/de.json | 5 + src/lib/i18n/locales/en.json | 5 + src/lib/i18n/locales/es.json | 5 + src/lib/i18n/locales/fr.json | 5 + src/lib/i18n/locales/pt.json | 5 + 6 files changed, 384 insertions(+), 8 deletions(-) diff --git a/src/lib/components/DiagnosticsPanel.svelte b/src/lib/components/DiagnosticsPanel.svelte index 07d13ec6d..8d841e314 100644 --- a/src/lib/components/DiagnosticsPanel.svelte +++ b/src/lib/components/DiagnosticsPanel.svelte @@ -1,9 +1,18 @@
@@ -199,6 +458,98 @@ {#if diagnostics}
QBZ v{diagnostics.appVersion}
+ + {#if playback} + + {#if playbackOpen} + + + + + + + + + + {#each playbackRows(playback) as row (row.label)} + + + + + + {/each} + +
{$t('settings.developer.diagnostics.colSetting')}{$t('settings.developer.diagnostics.colRuntime')}
{row.label}{row.runtime}·
+ {/if} + {/if} + + + {#if qconnect} + + {#if qconnectOpen} + + + + + + + + + + {#each qconnectRows(qconnect) as row (row.label)} + + + + + + {/each} + +
{$t('settings.developer.diagnostics.colSetting')}{$t('settings.developer.diagnostics.colRuntime')}
{row.label}{row.runtime}·
+ {/if} + {/if} + + + + {#if castOpen} +
+ +
+ {#if castScan} + + + + + + + + + + {#each castRows(castScan) as row (row.label + row.runtime)} + + + + + + {/each} + +
{$t('settings.developer.diagnostics.colSetting')}{$t('settings.developer.diagnostics.colRuntime')}
{row.label}{row.runtime}{row.status === 'mismatch' ? '✗' : '·'}
+ {/if} + {/if} + + +
+
+ {/snippet} + + + diff --git a/src/lib/components/UserCard.svelte b/src/lib/components/UserCard.svelte index 48168f7ca..def5cec08 100644 --- a/src/lib/components/UserCard.svelte +++ b/src/lib/components/UserCard.svelte @@ -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 @@ + showReportModal = false} + onGoToLogs={handleReportGoToLogs} + onCreateIssue={handleReportCreateIssue} +/> +