diff --git a/package-lock.json b/package-lock.json index 8d89dc08..9c1083d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.30.0", + "version": "1.34.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.30.0", + "version": "1.34.2", "dependencies": { "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2", @@ -36,7 +36,8 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.2", "typescript": "^5.5.3", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.3" } }, "node_modules/@babel/code-frame": { @@ -1227,6 +1228,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", @@ -1613,6 +1621,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1686,6 +1712,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1784,6 +1933,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -1894,6 +2053,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1973,6 +2139,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2258,6 +2444,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2332,6 +2528,24 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2545,6 +2759,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2555,6 +2776,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2572,6 +2824,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2708,6 +2970,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -2717,6 +3069,23 @@ "node": ">=0.10.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 539d3e11..043ed236 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "preview": "vite preview", "tauri": "tauri", "tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "test": "vitest run" }, "dependencies": { "@tanstack/react-virtual": "^3.13.23", @@ -39,6 +40,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.2", "typescript": "^5.5.3", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vitest": "^4.1.3" } } diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 0d90510a..5af6a571 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -7,6 +7,7 @@ import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; import { useCachedUrl } from './CachedImage'; import { getCachedUrl } from '../utils/imageCache'; +import { extractCoverColors } from '../utils/dynamicColors'; import { useTranslation } from 'react-i18next'; import { useLyrics } from '../hooks/useLyrics'; import { useAuthStore } from '../store/authStore'; @@ -284,6 +285,25 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { // `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl). const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false); + // Dynamic accent color extracted from the current album cover. + // Applied as --dynamic-fs-accent on the root element so it inherits to all + // children; CSS rules use var(--dynamic-fs-accent, var(--accent)) as fallback. + // Reset to null on track change so the previous color doesn't linger while + // the new one is being extracted. + const [dynamicAccent, setDynamicAccent] = useState(null); + useEffect(() => { + setDynamicAccent(null); + if (!artUrl || !artKey) return; + let cancelled = false; + getCachedUrl(artUrl, artKey).then(blobUrl => { + if (cancelled || !blobUrl) return; + extractCoverColors(blobUrl).then(colors => { + if (!cancelled && colors.accent) setDynamicAccent(colors.accent); + }); + }); + return () => { cancelled = true; }; + }, [artKey]); // artKey is stable per track — artUrl would also work + // Artist image → portrait on right. Falls back to cover art. const [artistBgUrl, setArtistBgUrl] = useState(''); useEffect(() => { @@ -362,6 +382,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { aria-label={t('player.fullscreen')} data-idle={isIdle} onMouseMove={handleMouseMove} + style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined} > {/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */} @@ -445,7 +466,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { onClick={() => useAuthStore.getState().setShowFullscreenLyrics(!showFullscreenLyrics)} aria-label={t('player.fsLyricsToggle')} data-tooltip={t('player.fsLyricsToggle')} - style={{ color: showFullscreenLyrics ? 'var(--accent)' : 'rgba(255,255,255,0.35)' }} + style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }} > diff --git a/src/styles/components.css b/src/styles/components.css index 26680320..4ae32a2d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2917,10 +2917,13 @@ height: 130%; left: -35%; bottom: -35%; - background: radial-gradient(ellipse, var(--accent-glow, rgba(120, 80, 255, 0.14)) 0%, transparent 65%); + background: radial-gradient(ellipse, + color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 86%) 0%, + transparent 65%); filter: blur(55px); animation: mesh-aura-a 26s ease-in-out infinite; animation-delay: 350ms; + transition: background 400ms ease-in-out; } .fs-mesh-blob-b { @@ -2928,10 +2931,13 @@ height: 110%; right: -25%; top: -25%; - background: radial-gradient(ellipse, var(--accent-dim, rgba(120, 80, 255, 0.08)) 0%, transparent 65%); + background: radial-gradient(ellipse, + color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 92%) 0%, + transparent 65%); filter: blur(65px); animation: mesh-aura-b 20s ease-in-out infinite; animation-delay: 350ms; + transition: background 400ms ease-in-out; } /* ── Artist portrait — right half, object-fit: contain ── */ @@ -3043,7 +3049,8 @@ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.07), - 0 0 28px var(--accent-glow, var(--accent)); + 0 0 28px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 30%); + transition: box-shadow 200ms ease-in-out; } /* Each layer is absolutely stacked — layers crossfade via opacity */ @@ -3071,13 +3078,14 @@ font-family: var(--font-display); font-size: clamp(28px, 4.5vw, 68px); font-weight: 900; - color: var(--accent); + color: var(--dynamic-fs-accent, var(--accent)); margin: 0; line-height: 1.05; - text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--accent-glow, var(--accent)); + text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--dynamic-fs-accent, var(--accent-glow, var(--accent))); white-space: normal; word-wrap: break-word; overflow-wrap: break-word; + transition: color 200ms ease-in-out, text-shadow 200ms ease-in-out; } /* Artist name — secondary, below track title */ @@ -3125,7 +3133,7 @@ border-radius: 50%; color: rgba(255, 255, 255, 0.65); cursor: pointer; - transition: all var(--transition-fast); + transition: all var(--transition-fast), color 200ms ease-in-out, background-color 200ms ease-in-out; background: transparent; } @@ -3136,7 +3144,7 @@ } .fs-btn.active { - color: var(--accent); + color: var(--dynamic-fs-accent, var(--accent)); } .fs-btn-sm { @@ -3147,22 +3155,22 @@ .fs-btn-play { width: 52px; height: 52px; - background: var(--accent); + background: var(--dynamic-fs-accent, var(--accent)); color: var(--ctp-crust); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow, var(--accent)); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--dynamic-fs-accent, var(--accent-glow, var(--accent))); } .fs-btn-play:hover { - background: var(--accent); + background: var(--dynamic-fs-accent, var(--accent)); color: var(--ctp-crust); transform: scale(1.1); filter: brightness(1.12); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--accent-glow, var(--accent)); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--dynamic-fs-accent, var(--accent-glow, var(--accent))); } .fs-btn-heart:hover, .fs-btn-heart.active { - color: var(--color-star-active, var(--accent)); + color: var(--dynamic-fs-accent, var(--color-star-active, var(--accent))); background: rgba(255, 255, 255, 0.08); } @@ -3217,9 +3225,10 @@ top: 0; left: 0; bottom: 0; - background: var(--accent); - box-shadow: 0 0 15px var(--accent-glow, var(--accent)); + background: var(--dynamic-fs-accent, var(--accent)); + box-shadow: 0 0 15px var(--dynamic-fs-accent, var(--accent-glow, var(--accent))); pointer-events: none; + transition: background-color 200ms ease-in-out, box-shadow 200ms ease-in-out; } .fs-seekbar input[type="range"] { @@ -5478,6 +5487,20 @@ color: var(--ctp-crust); } +/* Radio card: buttons row always pinned to bottom */ +.radio-card { + display: flex; + flex-direction: column; +} +.radio-card .album-card-info { + flex: 1; + display: flex; + flex-direction: column; +} +.radio-card .album-card-artist { + margin-top: auto; +} + /* ─ Alphabet Filter Bar ─ */ .alphabet-filter-bar { display: flex; diff --git a/src/utils/dynamicColors.test.ts b/src/utils/dynamicColors.test.ts new file mode 100644 index 00000000..95f9b510 --- /dev/null +++ b/src/utils/dynamicColors.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { + srgbToLinear, + relativeLuminance, + contrastRatio, + rgbToHsl, + hslToRgb, + ensureContrast, +} from './dynamicColors'; + +// ─── srgbToLinear ───────────────────────────────────────────────────────────── + +describe('srgbToLinear', () => { + it('maps 0 to 0', () => expect(srgbToLinear(0)).toBe(0)); + it('maps 1 to 1', () => expect(srgbToLinear(1)).toBeCloseTo(1)); + it('uses the low-end linear formula below 0.04045', () => { + expect(srgbToLinear(0.04)).toBeCloseTo(0.04 / 12.92, 6); + }); + it('uses the gamma formula above 0.04045', () => { + expect(srgbToLinear(0.5)).toBeCloseTo(Math.pow((0.5 + 0.055) / 1.055, 2.4), 6); + }); +}); + +// ─── relativeLuminance ──────────────────────────────────────────────────────── + +describe('relativeLuminance', () => { + it('returns 0 for black (0,0,0)', () => expect(relativeLuminance(0, 0, 0)).toBe(0)); + it('returns 1 for white (1,1,1)', () => expect(relativeLuminance(1, 1, 1)).toBeCloseTo(1, 5)); + it('weights green highest (0.7152)', () => { + const greenOnly = relativeLuminance(0, 1, 0); + const redOnly = relativeLuminance(1, 0, 0); + const blueOnly = relativeLuminance(0, 0, 1); + expect(greenOnly).toBeGreaterThan(redOnly); + expect(redOnly).toBeGreaterThan(blueOnly); + }); +}); + +// ─── contrastRatio ──────────────────────────────────────────────────────────── + +describe('contrastRatio', () => { + it('returns 21 for pure black on white', () => { + expect(contrastRatio(0, 1)).toBeCloseTo(21, 1); + }); + it('returns 1 when both luminances are equal', () => { + expect(contrastRatio(0.2, 0.2)).toBeCloseTo(1, 5); + }); + it('is symmetric', () => { + expect(contrastRatio(0.1, 0.5)).toBeCloseTo(contrastRatio(0.5, 0.1), 10); + }); + it('returns ≥ 4.5 for a light accent on near-black (FS bg)', () => { + const accent = relativeLuminance(200 / 255, 160 / 255, 60 / 255); + expect(contrastRatio(accent, 0.01)).toBeGreaterThanOrEqual(4.5); + }); +}); + +// ─── rgbToHsl / hslToRgb round-trip ────────────────────────────────────────── + +describe('rgbToHsl / hslToRgb', () => { + const cases: Array<[number, number, number]> = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [128, 128, 128], + [255, 255, 255], + [0, 0, 0], + [200, 100, 50], + ]; + it.each(cases)('round-trips rgb(%i,%i,%i)', (r, g, b) => { + const [h, s, l] = rgbToHsl(r, g, b); + const [rr, gg, bb] = hslToRgb(h, s, l); + expect(rr).toBeCloseTo(r, -1); // within ±1 due to rounding + expect(gg).toBeCloseTo(g, -1); + expect(bb).toBeCloseTo(b, -1); + }); + + it('pure grey has saturation 0', () => { + const [, s] = rgbToHsl(128, 128, 128); + expect(s).toBe(0); + }); + it('pure red has hue 0°', () => { + const [h] = rgbToHsl(255, 0, 0); + expect(h).toBeCloseTo(0, 0); + }); + it('pure green has hue 120°', () => { + const [h] = rgbToHsl(0, 255, 0); + expect(h).toBeCloseTo(120, 0); + }); + it('pure blue has hue 240°', () => { + const [h] = rgbToHsl(0, 0, 255); + expect(h).toBeCloseTo(240, 0); + }); +}); + +// ─── ensureContrast ─────────────────────────────────────────────────────────── + +describe('ensureContrast', () => { + const BG_LUMINANCE = 0.010; // near-black FS player background + const MIN_RATIO = 4.5; + + function meetsContrast(rgb: [number, number, number]): boolean { + const l = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255); + return contrastRatio(l, BG_LUMINANCE) >= MIN_RATIO; + } + + it('returns input unchanged if already sufficient', () => { + // White is always sufficient + const result = ensureContrast([255, 255, 255], BG_LUMINANCE, MIN_RATIO); + expect(result).toEqual([255, 255, 255]); + }); + + it('lightens a very dark color until contrast is met', () => { + const result = ensureContrast([10, 10, 10], BG_LUMINANCE, MIN_RATIO); + expect(meetsContrast(result)).toBe(true); + }); + + it('lightens a dark saturated color until contrast is met', () => { + // Dark blue — typical for dark album covers + const result = ensureContrast([20, 20, 80], BG_LUMINANCE, MIN_RATIO); + expect(meetsContrast(result)).toBe(true); + }); + + it('preserves a mid-tone color that already meets the ratio', () => { + // Yellow-ish, high luminance — should already pass + const input: [number, number, number] = [255, 230, 50]; + const result = ensureContrast(input, BG_LUMINANCE, MIN_RATIO); + expect(meetsContrast(result)).toBe(true); + }); + + it('always produces a result with contrast ≥ MIN_RATIO', () => { + // Stress-test with a range of dark, saturated colors + const darkColors: Array<[number, number, number]> = [ + [5, 30, 60], + [60, 5, 30], + [30, 60, 5], + [80, 0, 120], + [0, 0, 0], + ]; + for (const c of darkColors) { + const result = ensureContrast(c, BG_LUMINANCE, MIN_RATIO); + expect(meetsContrast(result)).toBe(true); + } + }); + + it('handles a very demanding minRatio gracefully (returns white)', () => { + // 21:1 is the theoretical maximum — only black-on-white achieves it. + // Any input should at least not throw and produce some output. + const result = ensureContrast([10, 10, 10], BG_LUMINANCE, 21); + expect(result.length).toBe(3); + result.forEach(c => { expect(c).toBeGreaterThanOrEqual(0); expect(c).toBeLessThanOrEqual(255); }); + }); +}); diff --git a/src/utils/dynamicColors.ts b/src/utils/dynamicColors.ts new file mode 100644 index 00000000..2a98f890 --- /dev/null +++ b/src/utils/dynamicColors.ts @@ -0,0 +1,163 @@ +// ─── Dynamic Cover Colors ───────────────────────────────────────────────────── +// +// Extracts a vibrant accent color from an album cover URL using the Canvas API. +// Designed exclusively for the Fullscreen Player — applied as a CSS custom +// property on the .fs-player root, inheriting down to all children. +// +// Guarantees WCAG 4.5:1 contrast against the FS player's near-black background +// by progressively lightening the extracted color in HSL space until the ratio +// is met. Falls back to an empty string (→ CSS falls back to var(--accent)). + +export interface CoverColors { + /** CSS color string, e.g. "rgb(200,120,60)". Empty string = use fallback. */ + accent: string; +} + +// ─── WCAG math (pure — unit-testable) ───────────────────────────────────────── + +/** Convert a sRGB channel [0, 1] to linear light. */ +export function srgbToLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +/** WCAG relative luminance for linear-light RGB channels [0, 1]. */ +export function relativeLuminance(r: number, g: number, b: number): number { + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); +} + +/** WCAG contrast ratio given two relative luminances. Always ≥ 1. */ +export function contrastRatio(l1: number, l2: number): number { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +// ─── HSL helpers (pure) ─────────────────────────────────────────────────────── + +/** RGB [0–255] → HSL [0–360, 0–1, 0–1]. */ +export function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l < 0.5 ? d / (max + min) : d / (2 - max - min); + let h: number; + switch (max) { + case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break; + case gn: h = ((bn - rn) / d + 2) / 6; break; + default: h = ((rn - gn) / d + 4) / 6; + } + return [h * 360, s, l]; +} + +function hue2rgb(p: number, q: number, t: number): number { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; +} + +/** HSL [0–360, 0–1, 0–1] → RGB [0–255]. */ +export function hslToRgb(h: number, s: number, l: number): [number, number, number] { + h = h / 360; + if (s === 0) { + const v = Math.round(l * 255); + return [v, v, v]; + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return [ + Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + Math.round(hue2rgb(p, q, h) * 255), + Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + ]; +} + +// ─── Contrast enforcement (pure — unit-testable) ────────────────────────────── + +/** + * Given an RGB color [0–255] and the luminance of the background it will sit + * on, progressively increases HSL lightness in 0.04 steps until the contrast + * ratio reaches `minRatio`. Returns white [255,255,255] as the ultimate + * fallback if even L=1 doesn't suffice (can only happen at extreme minRatio + * values, e.g. 21:1). + */ +export function ensureContrast( + rgb: [number, number, number], + bgLuminance: number, + minRatio: number, +): [number, number, number] { + const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]); + + // Already meeting the requirement? + const initialLum = relativeLuminance(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255); + if (contrastRatio(initialLum, bgLuminance) >= minRatio) return [...rgb]; + + // Lighten in steps of 4 % (25 iterations max → terminates) + for (let step = 1; step <= 25; step++) { + const newL = Math.min(1, l + step * 0.04); + const newRgb = hslToRgb(h, s, newL); + const newLum = relativeLuminance(newRgb[0] / 255, newRgb[1] / 255, newRgb[2] / 255); + if (contrastRatio(newLum, bgLuminance) >= minRatio) return newRgb; + } + return [255, 255, 255]; +} + +// ─── Canvas extraction (requires DOM) ───────────────────────────────────────── + +/** + * The FS player mesh background is a very dark near-black. + * luminance ≈ 0.010 is a conservative upper bound — the color contrast will be + * at least this good. + */ +const FS_BG_LUMINANCE = 0.010; +const MIN_CONTRAST = 4.5; + +/** + * Loads `imageUrl` into an 8×8 canvas and finds the most vibrant pixel + * (highest HSL saturation). Applies `ensureContrast` to guarantee + * WCAG AA readability against the FS player background. + * + * Resolves with `{ accent: '' }` on any error — the caller's CSS + * `var(--dynamic-fs-accent, var(--accent))` then falls back to the theme accent. + */ +export function extractCoverColors(imageUrl: string): Promise { + if (!imageUrl) return Promise.resolve({ accent: '' }); + + return new Promise(resolve => { + const img = new Image(); + // Blob URLs are same-origin in Tauri WebKit — no crossOrigin needed. + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext('2d'); + if (!ctx) { resolve({ accent: '' }); return; } + + ctx.drawImage(img, 0, 0, 8, 8); + const { data } = ctx.getImageData(0, 0, 8, 8); + + // Pick pixel with highest HSL saturation (most vibrant). + let bestSat = -1; + let bestR = 180, bestG = 100, bestB = 50; // warm orange fallback + for (let i = 0; i < data.length; i += 4) { + const r = data[i], g = data[i + 1], b = data[i + 2]; + const [, s] = rgbToHsl(r, g, b); + if (s > bestSat) { bestSat = s; bestR = r; bestG = g; bestB = b; } + } + + const [fr, fg, fb] = ensureContrast([bestR, bestG, bestB], FS_BG_LUMINANCE, MIN_CONTRAST); + resolve({ accent: `rgb(${fr},${fg},${fb})` }); + } catch { + resolve({ accent: '' }); + } + }; + img.onerror = () => resolve({ accent: '' }); + img.src = imageUrl; + }); +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..7eeb3f85 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});