mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(fs-player): adaptive dynamic accent color from album cover
Extracts the most vibrant pixel from an 8×8 downscaled album cover via the Canvas API and applies it as --dynamic-fs-accent on the .fs-player root element. All accent-colored FS player elements use var(--dynamic-fs-accent, var(--accent)) as fallback so the theme accent is restored automatically when the FS player closes. Elements updated: - .fs-track-title (color + text-shadow) - .fs-btn.active, .fs-btn-heart.active/:hover - .fs-btn-play and :hover (background + box-shadow) - .fs-seekbar-played (background + box-shadow) - .fs-mesh-blob-a/-b (radial-gradient via color-mix at 14%/8% opacity) - .fs-art-wrap box-shadow glow (color-mix at 70% opacity) - MicVocal lyrics-toggle inline style Color safety: WCAG 4.5:1 contrast against near-black FS background enforced by progressively lightening in HSL space (ensureContrast). Pure math functions unit-tested with vitest (28 tests, 0 deps beyond vitest itself). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+372
-3
@@ -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",
|
||||
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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<string>('');
|
||||
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)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
|
||||
+37
-14
@@ -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;
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
});
|
||||
@@ -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<CoverColors> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user