feat(album-detail): album header gradient backdrop from artwork palette

Extracts the dominant color from album artwork (via node-vibrant) and
paints it as a soft top-down gradient behind the album header, with an
appearance toggle to opt out. Adds the .has-art-bg state class plus
contrast-lifted overrides for back button, info text, and artist link.

cc
This commit is contained in:
vicrodh committed 2026-05-06 20:40:18 -06:00
1 parent 19f33cb157
commit 0d784545eb
11 files changed
+1024 -1

No files matched your search

+745
View File
@@ -19,6 +19,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"lucide-svelte": "^1.0.1",
"node-vibrant": "^4.0.4",
"svelte-i18n": "^4.0.0"
},
"devDependencies": {
@@ -804,6 +805,134 @@
"svelte": "^5.0.0"
}
},
"node_modules/@jimp/bmp": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.22.12.tgz",
"integrity": "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12",
"bmp-js": "^0.1.0"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/core": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.22.12.tgz",
"integrity": "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12",
"any-base": "^1.1.0",
"buffer": "^5.2.0",
"exif-parser": "^0.1.12",
"file-type": "^16.5.4",
"isomorphic-fetch": "^3.0.0",
"pixelmatch": "^4.0.2",
"tinycolor2": "^1.6.0"
}
},
"node_modules/@jimp/custom": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "^0.22.12"
}
},
"node_modules/@jimp/gif": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.22.12.tgz",
"integrity": "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12",
"gifwrap": "^0.10.1",
"omggif": "^1.0.9"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/jpeg": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.22.12.tgz",
"integrity": "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12",
"jpeg-js": "^0.4.4"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz",
"integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/png": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.22.12.tgz",
"integrity": "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==",
"license": "MIT",
"dependencies": {
"@jimp/utils": "^0.22.12",
"pngjs": "^6.0.0"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/tiff": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.22.12.tgz",
"integrity": "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==",
"license": "MIT",
"dependencies": {
"utif2": "^4.0.1"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/types": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.22.12.tgz",
"integrity": "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==",
"license": "MIT",
"dependencies": {
"@jimp/bmp": "^0.22.12",
"@jimp/gif": "^0.22.12",
"@jimp/jpeg": "^0.22.12",
"@jimp/png": "^0.22.12",
"@jimp/tiff": "^0.22.12",
"timm": "^1.6.1"
},
"peerDependencies": {
"@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/utils": {
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.22.12.tgz",
"integrity": "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.13.3"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1562,6 +1691,12 @@
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1628,6 +1763,156 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vibrant/color": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/color/-/color-4.0.4.tgz",
"integrity": "sha512-Fq2tAszz4QOPWfHZ+KuEAchXUD8i594BM2fOJt8dI/fvYbiVoBycBF/BlNH6F4IWBubxXoPqD4JmmAHvFYbNew==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/core": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/core/-/core-4.0.4.tgz",
"integrity": "sha512-yZ0XSpW2biKyaJPpBC31AVYgn7NseKSO2q3KNMmDrkL2qC6TEWsBMnSQ28n0m///chZELXpQLx1CCOsWg5pj8w==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4",
"@vibrant/generator": "^4.0.4",
"@vibrant/image": "^4.0.4",
"@vibrant/quantizer": "^4.0.4",
"@vibrant/worker": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/generator": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/generator/-/generator-4.0.4.tgz",
"integrity": "sha512-rwq8PnlpKdch4YqaA1FAwdm71gKE2cMrUsbu72TqRFGa8rpP1roaZlQCVXIIwElXVc3r9axZyAcqyTLaMjhrTg==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4",
"@vibrant/types": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/generator-default": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/generator-default/-/generator-default-4.0.4.tgz",
"integrity": "sha512-QeVDeH2dz9lityvJCb84Ml4hlBTElwCpU7SVpiDFBh6gPoCLnzcb1H9G4NgG3hOlAPyrBM+Ivq1Pg+1lZj5Ywg==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4",
"@vibrant/generator": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/image": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/image/-/image-4.0.4.tgz",
"integrity": "sha512-NBIJj7umfDRVpFjJHQo1AFSCWCzQyjfil+Yxu7W62PEL72GPCif0CDiglPkvVF8QhDLmnx/x1k3LIBb9jWF2sw==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/image-browser": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/image-browser/-/image-browser-4.0.4.tgz",
"integrity": "sha512-7qVyAm+z9t98iwMDzUgGCwgRg0KBB5RXQFgiO2Um5Izd1wO7BKC0SHVEz2k7sRx3XNfBf+JExp8quPrvSz17gg==",
"license": "MIT",
"dependencies": {
"@vibrant/image": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/image-node": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/image-node/-/image-node-4.0.4.tgz",
"integrity": "sha512-aG8Ukt9oTa6FWaAV5oBKsBetkKASWH31hZiFJ2R1291f3TZlphUyQTJz5TubucIRsCEl4dgG1xyxFPgse2IABA==",
"license": "MIT",
"dependencies": {
"@jimp/custom": "^0.22.12",
"@jimp/plugin-resize": "^0.22.12",
"@jimp/types": "^0.22.12",
"@vibrant/image": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/quantizer": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/quantizer/-/quantizer-4.0.4.tgz",
"integrity": "sha512-722CooC2W4mlBiv+zyAsIrIvARnMCN/P2Muo8bnWd0SQlVWFtQnFxJWGOUPOPS4DGe3pGoqmNfvS0let4dICZQ==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4",
"@vibrant/image": "^4.0.4",
"@vibrant/types": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/quantizer-mmcq": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/quantizer-mmcq/-/quantizer-mmcq-4.0.4.tgz",
"integrity": "sha512-/1CNnM96J8K+OBCWNUzywo6VdnmdFJyiKO+ty/nkfe8H0NseOEHIL7PrVtWGgtsb0rh2uTAq2rjXv65TfgPy8g==",
"license": "MIT",
"dependencies": {
"@vibrant/color": "^4.0.4",
"@vibrant/image": "^4.0.4",
"@vibrant/quantizer": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/types": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/types/-/types-4.0.4.tgz",
"integrity": "sha512-Qq3mVTJamn7yD4OBgBEUKaxfDlm3sxBK55N7dH3XzI9Ey7KR00R06uwtqOcEJMsziWTEXdYN3VUlYaj2Tkt7hw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vibrant/worker": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@vibrant/worker/-/worker-4.0.4.tgz",
"integrity": "sha512-Q/R6PYhSMWCXEk/IcXbWIzIu7Z4b58ABkGvcdF8Y+q/7g+KnpxKW5x/jfQ/6ciyYSby13wZZoEdNr3QQVgsdBQ==",
"license": "MIT",
"dependencies": {
"@vibrant/types": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@@ -1751,6 +2036,18 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1786,6 +2083,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -1815,6 +2118,26 @@
"node": ">= 0.4"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1825,6 +2148,36 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2140,6 +2493,29 @@
"es5-ext": "~0.10.14"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -2176,6 +2552,23 @@
}
}
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"license": "MIT",
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2190,6 +2583,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/globalyzer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
@@ -2215,6 +2618,41 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/image-q/node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/intl-messageformat": {
"version": "10.7.18",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
@@ -2249,6 +2687,22 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2676,6 +3130,81 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-vibrant": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/node-vibrant/-/node-vibrant-4.0.4.tgz",
"integrity": "sha512-hA/pUXBE9TJ41G9FlTkzeqD5JdxgvvPGYZb/HNpdkaxxXUEnP36imSolZ644JuPun+lTd+FpWWtBpTYdp2noQA==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.15.3",
"@vibrant/core": "^4.0.4",
"@vibrant/generator-default": "^4.0.4",
"@vibrant/image-browser": "^4.0.4",
"@vibrant/image-node": "^4.0.4",
"@vibrant/quantizer-mmcq": "^4.0.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/crutchcorn"
}
},
"node_modules/node-vibrant/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/node-vibrant/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -2686,6 +3215,18 @@
],
"license": "MIT"
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -2706,6 +3247,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2724,6 +3278,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
"integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==",
"license": "ISC",
"dependencies": {
"pngjs": "^3.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
"integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@@ -2767,6 +3351,15 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2784,6 +3377,62 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readable-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2798,6 +3447,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2853,6 +3508,26 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -2916,6 +3591,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/svelte": {
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
@@ -3020,6 +3721,12 @@
"node": ">=0.12"
}
},
"node_modules/timm": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
"integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==",
"license": "MIT"
},
"node_modules/tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -3037,6 +3744,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -3093,6 +3806,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3164,6 +3894,15 @@
"node": ">=20.18.1"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
@@ -3373,6 +4112,12 @@
"node": ">=20"
}
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+1
View File
@@ -36,6 +36,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"lucide-svelte": "^1.0.1",
"node-vibrant": "^4.0.4",
"svelte-i18n": "^4.0.0"
},
"devDependencies": {
@@ -34,6 +34,11 @@
import BookletViewer from '../BookletViewer.svelte';
import type { QobuzGoody } from '$lib/types';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
import { extractPalette, pickHeaderColor, type ArtworkPalette } from '$lib/utils/artworkPalette';
import {
subscribe as subscribeAppearance,
isAlbumHeaderGradientEnabled,
} from '$lib/stores/appearancePreferencesStore';
interface Track {
id: number;
@@ -202,6 +207,25 @@
let coverOverride = $state<string | null>(null);
let scrollContainer: HTMLDivElement | null = $state(null);
// Header gradient driven by extracted artwork palette.
let gradientEnabled = $state(isAlbumHeaderGradientEnabled());
let artworkPalette = $state<ArtworkPalette | null>(null);
$effect(() => {
const url = coverOverride ?? album.artwork ?? null;
artworkPalette = null;
if (!url || !gradientEnabled) return;
extractPalette(url).then((p) => {
const current = coverOverride ?? album.artwork ?? null;
if (current === url) artworkPalette = p;
});
});
const headerColor = $derived(gradientEnabled ? pickHeaderColor(artworkPalette) : null);
const headerStyle = $derived.by(() => {
if (!headerColor) return '';
const needsScrim = headerColor.luminance > 0.6;
return `--art-bg: ${headerColor.hex}; --art-scrim: ${needsScrim ? '0.35' : '0'};`;
});
// Multi-select
let multiSelectMode = $state(false);
let multiSelectedIds = $state(new Set<number>());
@@ -492,8 +516,13 @@
}
});
const unsubscribeAppearance = subscribeAppearance(() => {
gradientEnabled = isAlbumHeaderGradientEnabled();
});
return () => {
unsubscribe?.();
unsubscribeAppearance();
};
});
@@ -550,7 +579,7 @@
</script>
<ViewTransition duration={200} distance={12} direction="up">
<div class="album-detail" bind:this={scrollContainer} onscroll={(e) => saveScrollPosition('album', (e.target as HTMLElement).scrollTop, album.id)}>
<div class="album-detail" class:has-art-bg={!!headerColor} style={headerStyle} bind:this={scrollContainer} onscroll={(e) => saveScrollPosition('album', (e.target as HTMLElement).scrollTop, album.id)}>
<!-- Back Navigation -->
<button class="back-btn" onclick={onBack}>
<ArrowLeft size={16} />
@@ -965,6 +994,47 @@
height: 100%;
padding: 8px 8px 100px 18px;
overflow-y: auto;
position: relative;
}
.album-detail.has-art-bg::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 320px;
background:
linear-gradient(180deg, rgba(0, 0, 0, var(--art-scrim, 0)) 0%, rgba(0, 0, 0, 0) 100%),
linear-gradient(180deg, var(--art-bg, transparent) 0%, var(--art-bg, transparent) 30%, transparent 100%);
z-index: 0;
pointer-events: none;
transition: background 320ms ease;
}
.album-detail > * {
position: relative;
z-index: 1;
}
/* Lift secondary text contrast over the colored backdrop. */
.album-detail.has-art-bg .back-btn,
.album-detail.has-art-bg .album-info,
.album-detail.has-art-bg .album-quality,
.album-detail.has-art-bg .album-stats {
color: rgba(255, 255, 255, 0.78);
}
.album-detail.has-art-bg .back-btn:hover {
color: #fff;
}
.album-detail.has-art-bg .artist-link {
color: #fff;
}
.album-detail.has-art-bg .artist-link:hover {
text-decoration: underline;
}
/* Custom scrollbar */
@@ -84,6 +84,10 @@
isDegraded
} from '$lib/stores/degradedStore';
import { showToast } from '$lib/stores/toastStore';
import {
isAlbumHeaderGradientEnabled,
setAlbumHeaderGradient,
} from '$lib/stores/appearancePreferencesStore';
import {
enableVerboseCapture,
disableVerboseCapture,
@@ -996,6 +1000,12 @@
// Appearance settings
let theme = $state('Dark');
let albumHeaderGradient = $state(isAlbumHeaderGradientEnabled());
function handleAlbumHeaderGradientToggle(enabled: boolean) {
setAlbumHeaderGradient(enabled);
albumHeaderGradient = enabled;
}
let toastsEnabled = $state(true);
let systemNotificationsEnabled = $state(true);
let language = $state('Auto');
@@ -4502,6 +4512,14 @@
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.albumHeaderGradient')}</span>
<small class="setting-note">{$t('settings.appearance.albumHeaderGradientDesc')}</small>
</div>
<Toggle enabled={albumHeaderGradient} onchange={handleAlbumHeaderGradientToggle} />
</div>
<!-- Auto-Theme generating overlay -->
{#if autoThemeGenerating}
<div class="auto-theme-overlay">
+2
View File
@@ -2132,6 +2132,8 @@
"appearance": {
"title": "Darstellung",
"theme": "Thema",
"albumHeaderGradient": "Farbverlauf im Album-Header",
"albumHeaderGradientDesc": "Verwende die dominante Farbe des Albumcovers als Hintergrund in der Albumdetailansicht.",
"themes": {
"dark": "Dunkel",
"light": "Hell",
+2
View File
@@ -2132,6 +2132,8 @@
"appearance": {
"title": "Appearance",
"theme": "Theme",
"albumHeaderGradient": "Album header gradient",
"albumHeaderGradientDesc": "Use the album cover's dominant color as a backdrop in album detail views.",
"themes": {
"dark": "Dark",
"light": "Light",
+2
View File
@@ -2132,6 +2132,8 @@
"appearance": {
"title": "Apariencia",
"theme": "Tema",
"albumHeaderGradient": "Degradado en el encabezado del álbum",
"albumHeaderGradientDesc": "Usa el color dominante de la portada como fondo en la vista de detalle del álbum.",
"themes": {
"dark": "Oscuro",
"light": "Claro",
+2
View File
@@ -2132,6 +2132,8 @@
"appearance": {
"title": "Apparence",
"theme": "Thème",
"albumHeaderGradient": "Dégradé dans l'en-tête de l'album",
"albumHeaderGradientDesc": "Utilise la couleur dominante de la pochette comme arrière-plan dans la vue détaillée de l'album.",
"themes": {
"dark": "Sombre",
"light": "Clair",
+2
View File
@@ -2132,6 +2132,8 @@
"appearance": {
"title": "Aparência",
"theme": "Tema",
"albumHeaderGradient": "Gradiente no cabeçalho do álbum",
"albumHeaderGradientDesc": "Usa a cor dominante da capa como fundo na visualização de detalhes do álbum.",
"themes": {
"dark": "Escuro",
"light": "Claro",
@@ -0,0 +1,65 @@
/**
* Appearance Preferences Store
*
* Per-user toggles for visual treatments that have a clear opt-in/opt-out
* value (gradients, blurs, motion). Persisted in localStorage.
*/
export interface AppearancePreferences {
/** Render the artwork-derived gradient on album detail headers. */
albumHeaderGradient: boolean;
}
const STORAGE_KEY = 'qbz-appearance-preferences';
const DEFAULT_SETTINGS: AppearancePreferences = {
albumHeaderGradient: true,
};
let settings: AppearancePreferences = loadSettings();
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) listener();
}
function loadSettings(): AppearancePreferences {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as Partial<AppearancePreferences>;
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (err) {
console.error('Failed to load appearance preferences:', err);
}
return { ...DEFAULT_SETTINGS };
}
function saveSettings(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (err) {
console.error('Failed to save appearance preferences:', err);
}
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getSettings(): AppearancePreferences {
return settings;
}
export function isAlbumHeaderGradientEnabled(): boolean {
return settings.albumHeaderGradient;
}
export function setAlbumHeaderGradient(enabled: boolean): void {
if (settings.albumHeaderGradient === enabled) return;
settings = { ...settings, albumHeaderGradient: enabled };
saveSettings();
notifyListeners();
}
+114
View File
@@ -0,0 +1,114 @@
import { Vibrant } from 'node-vibrant/browser';
export interface ArtworkSwatch {
hex: string;
rgb: [number, number, number];
population: number;
/** Perceived luminance in 0..1 (Rec. 709). */
luminance: number;
}
export interface ArtworkPalette {
/** Most populous swatch — closest to a "background" pick. */
dominant: ArtworkSwatch | null;
vibrant: ArtworkSwatch | null;
muted: ArtworkSwatch | null;
lightVibrant: ArtworkSwatch | null;
darkVibrant: ArtworkSwatch | null;
lightMuted: ArtworkSwatch | null;
darkMuted: ArtworkSwatch | null;
}
const cache = new Map<string, Promise<ArtworkPalette>>();
function relativeLuminance(r: number, g: number, b: number): number {
const channel = (c: number) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function toSwatch(s: { rgb: [number, number, number]; population: number; hex: string } | null | undefined): ArtworkSwatch | null {
if (!s) return null;
const [r, g, b] = s.rgb;
return {
hex: s.hex,
rgb: [r, g, b],
population: s.population,
luminance: relativeLuminance(r, g, b),
};
}
export async function extractPalette(url: string | null | undefined): Promise<ArtworkPalette> {
if (!url) return emptyPalette();
const cached = cache.get(url);
if (cached) return cached;
const promise = (async () => {
try {
const palette = await Vibrant.from(url).getPalette();
const all = [
palette.Vibrant,
palette.Muted,
palette.LightVibrant,
palette.DarkVibrant,
palette.LightMuted,
palette.DarkMuted,
].filter((s): s is NonNullable<typeof s> => !!s);
const dominantRaw = all.length > 0
? all.reduce((a, b) => (a.population >= b.population ? a : b))
: null;
return {
dominant: toSwatch(dominantRaw),
vibrant: toSwatch(palette.Vibrant),
muted: toSwatch(palette.Muted),
lightVibrant: toSwatch(palette.LightVibrant),
darkVibrant: toSwatch(palette.DarkVibrant),
lightMuted: toSwatch(palette.LightMuted),
darkMuted: toSwatch(palette.DarkMuted),
} satisfies ArtworkPalette;
} catch (err) {
console.warn('[artworkPalette] extract failed for', url, err);
return emptyPalette();
}
})();
cache.set(url, promise);
return promise;
}
/**
* Pick the swatch most appropriate for an album header backdrop:
* darkVibrant when it carries enough weight (≥ 10% of total swatch population),
* else darkMuted, else dominant. Returns null when the palette is empty.
*/
export function pickHeaderColor(palette: ArtworkPalette | null): ArtworkSwatch | null {
if (!palette) return null;
const swatches = [
palette.vibrant,
palette.muted,
palette.lightVibrant,
palette.darkVibrant,
palette.lightMuted,
palette.darkMuted,
];
const totalPop = swatches.reduce((sum, s) => sum + (s?.population ?? 0), 0);
if (palette.darkVibrant && totalPop > 0 && palette.darkVibrant.population / totalPop >= 0.1) {
return palette.darkVibrant;
}
if (palette.darkMuted) return palette.darkMuted;
return palette.dominant;
}
function emptyPalette(): ArtworkPalette {
return {
dominant: null,
vibrant: null,
muted: null,
lightVibrant: null,
darkVibrant: null,
lightMuted: null,
darkMuted: null,
};
}