diff --git a/package-lock.json b/package-lock.json index 8b53c4d..ecf8ff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,13 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "electron-updater": "^6.3.9", + "extract-colors": "^4.1.0", "fastest-levenshtein": "^1.0.16", "get-audio-duration": "^4.0.1", "graceful-fs": "^4.2.11", "lucide-solid": "^0.452.0", "node-addon-api": "^8.2.1", + "polished": "^4.3.1", "sharp": "^0.33.5", "solid-focus-trap": "^0.1.7", "tailwind-merge": "^2.5.3" @@ -487,6 +489,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", @@ -5562,6 +5575,11 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extract-colors": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/extract-colors/-/extract-colors-4.1.0.tgz", + "integrity": "sha512-BWZxUwpYra1G91rnq/xxuhVNkkbixdi74xdlebo6744lXYx8SUsOMdFU9FQGoVJZpEmcXC9dXS3lc0/8WyNVkw==" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -8076,6 +8094,17 @@ "node": ">=10.4.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -8589,6 +8618,11 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 53da3ec..a1edf50 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,13 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "electron-updater": "^6.3.9", + "extract-colors": "^4.1.0", "fastest-levenshtein": "^1.0.16", "get-audio-duration": "^4.0.1", "graceful-fs": "^4.2.11", "lucide-solid": "^0.452.0", "node-addon-api": "^8.2.1", + "polished": "^4.3.1", "sharp": "^0.33.5", "solid-focus-trap": "^0.1.7", "tailwind-merge": "^2.5.3" diff --git a/src/@types.d.ts b/src/@types.d.ts index 1248add..d2367d8 100644 --- a/src/@types.d.ts +++ b/src/@types.d.ts @@ -77,6 +77,10 @@ export type Song = { tags?: string[]; diffs: string[]; + + // Colors + primaryColor?: string; + secondaryColor?: string; } & Resource; // Serialization is in JSON that's why properties are only single letter @@ -121,6 +125,7 @@ export type TableMap = { songs: { [key: ResourceID]: Song }; audio: { [key: ResourceID]: AudioSource }; images: { [key: ResourceID]: ImageSource }; + colors: { [key: ResourceID]: ColorsSource }; playlists: { [key: string]: ResourceID[] }; settings: Settings; system: System; diff --git a/src/RequestAPI.d.ts b/src/RequestAPI.d.ts index 51798a8..3784e3c 100644 --- a/src/RequestAPI.d.ts +++ b/src/RequestAPI.d.ts @@ -69,5 +69,7 @@ export type RequestAPI = { "save::localVolume": (volume: number, song: ResourceID) => void; + "save::songColors": (primaryColor: string, secondaryColor: string, song: ResourceID) => void; + "dev::storeLocation": () => string; }; diff --git a/src/main/router/import.ts b/src/main/router/import.ts index 3adf755..f98f5ff 100644 --- a/src/main/router/import.ts +++ b/src/main/router/import.ts @@ -9,5 +9,6 @@ import "./parser-router"; import "./queue-router"; import "./resource-router"; import "./settings-router"; +import "./song-color-router"; import "./songs-pool-router"; import "./window-router"; diff --git a/src/main/router/song-color-router.ts b/src/main/router/song-color-router.ts new file mode 100644 index 0000000..af948fa --- /dev/null +++ b/src/main/router/song-color-router.ts @@ -0,0 +1,13 @@ +import { Router } from "../lib/route-pass/Router"; +import { Storage } from "../lib/storage/Storage"; + +Router.respond("save::songColors", (_evt, primaryColor, secondaryColor, songID) => { + const song = Storage.getTable("songs").get(songID); + if (song.isNone) { + return; + } + + song.value.primaryColor = primaryColor; + song.value.secondaryColor = secondaryColor; + Storage.getTable("songs").write(songID, song.value); +}); diff --git a/src/renderer/src/components/slider/SliderRange.tsx b/src/renderer/src/components/slider/SliderRange.tsx index f2c99b3..c6c97fa 100644 --- a/src/renderer/src/components/slider/SliderRange.tsx +++ b/src/renderer/src/components/slider/SliderRange.tsx @@ -1,20 +1,24 @@ import { useSlider } from "./Slider"; -import { Component, JSX } from "solid-js"; +import { sn } from "@renderer/lib/css.utils"; +import { Component, JSX, createMemo } from "solid-js"; const SliderRange: Component = (props) => { const state = useSlider(); - return ( - { + return sn( + state.transitionStyleValue(), + { width: `${state.percentage()}%`, "pointer-events": "none", "transition-property": "width", - }} - /> - ); + }, + props.style, + ); + }); + + return ; }; export default SliderRange; diff --git a/src/renderer/src/components/song/SongImage.tsx b/src/renderer/src/components/song/SongImage.tsx index 4eb4bed..79e323d 100644 --- a/src/renderer/src/components/song/SongImage.tsx +++ b/src/renderer/src/components/song/SongImage.tsx @@ -11,6 +11,7 @@ type SongImageProps = { src: string | undefined | Accessor; group?: string; instantLoad?: boolean; + onImageLoaded?: (src: string) => void; } & JSX.IntrinsicElements["div"]; const SongImage: Component = (props) => { @@ -23,6 +24,7 @@ const SongImage: Component = (props) => { } setSrc(evt.detail); + props.onImageLoaded?.(evt.detail); delete image?.dataset.eventHandler; }; diff --git a/src/renderer/src/components/song/color-extractor.ts b/src/renderer/src/components/song/color-extractor.ts new file mode 100644 index 0000000..f363be8 --- /dev/null +++ b/src/renderer/src/components/song/color-extractor.ts @@ -0,0 +1,180 @@ +import { extractColors } from "extract-colors"; +import { lighten, darken, getContrast, parseToHsl, hslToColorString } from "polished"; +import { Accessor, createSignal } from "solid-js"; +import { Song } from "src/@types"; + +const MIN_CONTRAST_RATIO = 4.5; +const MIN_VIBRANCY_THRESHOLD = 0.3; +const VIBRANCY_WEIGHT = 0.7; +const AREA_WEIGHT = 0.3; + +type UseColorExtractorResult = { + primaryColor: Accessor; + secondaryColor: Accessor; + processImage(src: string): void; +}; + +export function useColorExtractor() { + const extractColorFromImage = (song: Song): UseColorExtractorResult => { + const [primaryColor, setPrimartColor] = createSignal(song.primaryColor); + const [secondaryColor, setSecondaryColor] = createSignal( + song.secondaryColor, + ); + + const processImage = async (src: string) => { + if (primaryColor() || secondaryColor()) { + return; + } + + try { + const colors = await extractColorsFromImage(src); + console.log("colors", colors); + if (!colors.primaryColor || !colors.secondaryColor) { + return; + } + + setPrimartColor(colors.primaryColor); + setSecondaryColor(colors.secondaryColor); + + await window.api.request( + "save::songColors", + colors.primaryColor, + colors.secondaryColor, + song.audio, + ); + } catch (err) { + console.error("Error extracting color:", err); + } + }; + + return { primaryColor, secondaryColor, processImage }; + }; + + return { extractColorFromImage }; +} + +type ExtractColorsFromImageResult = { primaryColor: string; secondaryColor: string }; +function extractColorsFromImage(src: string): Promise { + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.src = src; + + return new Promise((resolve, reject) => { + img.onload = async () => { + try { + const colors = await extractColors(img.src); + + const validColors = colors.filter((color) => { + const hsl = parseToHsl(color.hex); + return hsl.lightness > 0.1 && hsl.lightness < 0.9; + }); + + const sortedColors = validColors.sort((a, b) => { + const vibrancyA = getVibrancy(parseToHsl(a.hex)); + const vibrancyB = getVibrancy(parseToHsl(b.hex)); + const scoreA = vibrancyA * VIBRANCY_WEIGHT + a.area * AREA_WEIGHT; + const scoreB = vibrancyB * VIBRANCY_WEIGHT + b.area * AREA_WEIGHT; + return scoreB - scoreA; + }); + + let primaryColor = sortedColors[0]?.hex || "gray"; + + // Find the first valid color that meets contrast and vibrancy thresholds + for (const color of sortedColors) { + const contrast = getContrast(color.hex, "#FFFFFF"); + if ( + contrast >= MIN_CONTRAST_RATIO && + getVibrancy(parseToHsl(color.hex)) >= MIN_VIBRANCY_THRESHOLD + ) { + primaryColor = color.hex; + break; + } + } + + primaryColor = alterUnappealingColor(primaryColor); + + // Improve contrast if necessary + if (getContrast(primaryColor, "#FFFFFF") < MIN_CONTRAST_RATIO) { + primaryColor = improveContrast(primaryColor); + } + + const { lightness } = parseToHsl(primaryColor); + const secondaryColor = + lightness < 0.2 ? lighten(0.1, primaryColor) : darken(0.1, primaryColor); + + document.documentElement.style.setProperty("--extracted-color-rgb", hexToRgb(primaryColor)); + resolve({ primaryColor, secondaryColor }); + } catch (err) { + reject(err); + } + }; + }); +} + +// Helper function to improve contrast by darkening the color +function improveContrast(color: string): string { + let darkenedColor = color; + let contrast = getContrast(darkenedColor, "#FFFFFF"); + for (let i = 0; i < 7 && contrast < MIN_CONTRAST_RATIO; i++) { + darkenedColor = darken(0.1, darkenedColor); // Darken the color by 10% + contrast = getContrast(darkenedColor, "#FFFFFF"); + } + return darkenedColor; +} + +// Helper function to convert hex to RGB +function hexToRgb(hex: string): string { + const bigint = parseInt(hex.slice(1), 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return `${r}, ${g}, ${b}`; +} + +// Helper function to calculate vibrancy based on saturation and lightness +function getVibrancy(hsl: { hue: number; saturation: number; lightness: number }): number { + return hsl.saturation * (1 - Math.abs(hsl.lightness - 0.5) * 2); +} + +// Helper function to alter unappealing colors +function alterUnappealingColor(color: string): string { + const { hue, saturation, lightness } = parseToHsl(color); + // Alter brownish hues to red + if (hue >= 20 && hue <= 45 && saturation < 0.6 && lightness > 0.3 && lightness < 0.6) { + return hslToColorString({ hue: randomizeHue(0, 10), saturation: saturation + 0.3, lightness }); + } + // Alter pickle green/yellowish greens to more vibrant yellow or green + if (hue >= 70 && hue <= 100 && saturation > 0.2 && saturation < 0.4 && lightness > 0.3) { + const newHue = Math.random() > 0.5 ? randomizeHue(50, 65) : randomizeHue(100, 120); + return hslToColorString({ hue: newHue, saturation: saturation + 0.3, lightness }); + } + // Alter tan to a more appealing golden color + if (hue >= 35 && hue <= 50 && lightness > 0.7 && saturation < 0.5) { + return hslToColorString({ + hue: randomizeHue(100, 190), + saturation: saturation + 0.5, + lightness: lightness - 0.2, + }); + } + // Alter grayish brown + if ( + hue >= 20 && + hue <= 30 && + saturation >= 0.3 && + saturation <= 0.5 && + lightness >= 0.2 && + lightness <= 0.5 + ) { + return hslToColorString({ hue: 75, saturation: saturation + 0.3, lightness: lightness + 0.3 }); + } + // Alter muted purple + if (hue >= 240 && hue <= 280 && saturation <= 0.5 && lightness >= 0.5 && lightness <= 0.7) { + return hslToColorString({ hue: 260, saturation: saturation + 0.4, lightness: lightness + 0.1 }); + } + return color; +} + +// Helper function to add slight randomization to hue values +function randomizeHue(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/src/renderer/src/components/song/song-detail/SongControls.tsx b/src/renderer/src/components/song/song-detail/SongControls.tsx index 88206ea..2075ab0 100644 --- a/src/renderer/src/components/song/song-detail/SongControls.tsx +++ b/src/renderer/src/components/song/song-detail/SongControls.tsx @@ -25,7 +25,12 @@ import { } from "lucide-solid"; import { Component, createEffect, createSignal, Match, Show, Switch } from "solid-js"; -const SongControls: Component = () => { +// Add a prop to accept the averageColor +type SongControlsProps = { + averageColor?: string; +}; + +const SongControls: Component = (props) => { const [disable, setDisable] = createSignal(isSongUndefined(song())); const [playHint, setPlayHint] = createSignal(""); @@ -43,7 +48,7 @@ const SongControls: Component = () => { createEffect(() => setDisable(isSongUndefined(song()))); return ( -
+
@@ -106,6 +114,7 @@ const SongControls: Component = () => { ); }; +// LeftPart component updated to include averageColor prop for styling const LeftPart = () => { const [isHoveringVolume, setIsHoveringVolume] = createSignal(false); let isHoverintTimeoutId: NodeJS.Timeout; @@ -119,7 +128,6 @@ const LeftPart = () => { setIsHoveringVolume(true); }} onMouseLeave={() => { - // Add a timeout so the volume slider doesn't disappear instantly when the mouse leaves it isHoverintTimeoutId = setTimeout(() => { setIsHoveringVolume(false); }, 320); diff --git a/src/renderer/src/components/song/song-detail/SongDetail.tsx b/src/renderer/src/components/song/song-detail/SongDetail.tsx index 97d7c03..faeae23 100644 --- a/src/renderer/src/components/song/song-detail/SongDetail.tsx +++ b/src/renderer/src/components/song/song-detail/SongDetail.tsx @@ -1,5 +1,6 @@ import formatTime from "../../../lib/time-formatter"; import SongImage from "../SongImage"; +import { useColorExtractor } from "../color-extractor"; import SongControls from "./SongControls"; import Slider from "@renderer/components/slider/Slider"; import { @@ -10,15 +11,20 @@ import { handleSeekStart, handleSeekEnd, } from "@renderer/components/song/song.utils"; -import { Component, createMemo, Show } from "solid-js"; +import { Component, createMemo } from "solid-js"; +import { Show } from "solid-js"; const SongDetail: Component = () => { + const { extractColorFromImage } = useColorExtractor(); + const colorData = createMemo(() => extractColorFromImage(song())); + return (
@@ -29,14 +35,15 @@ const SongDetail: Component = () => { {song().artist}
- - + +
); }; -const ProgressBar = () => { +type ProgressBarProps = { averageColor: string | undefined }; +const ProgressBar = (props: ProgressBarProps) => { const currentValue = createMemo(() => { return timestamp() / (duration() !== 0 ? duration() : 1); }); @@ -52,8 +59,13 @@ const ProgressBar = () => { onValueCommit={handleSeekEnd} animate > - - + + diff --git a/src/renderer/src/components/song/song-item/SongItem.tsx b/src/renderer/src/components/song/song-item/SongItem.tsx index 344517d..403998d 100644 --- a/src/renderer/src/components/song/song-item/SongItem.tsx +++ b/src/renderer/src/components/song/song-item/SongItem.tsx @@ -2,8 +2,10 @@ import { ResourceID, Song } from "../../../../../@types"; import draggable from "../../../lib/draggable/draggable"; import SongHint from "../SongHint"; import SongImage from "../SongImage"; +import { useColorExtractor } from "../color-extractor"; import { ignoreClickInContextMenu } from "../context-menu/SongContextMenu"; import { song as selectedSong } from "../song.utils"; +import { transparentize } from "polished"; import Popover from "@renderer/components/popover/Popover"; import { EllipsisVerticalIcon } from "lucide-solid"; import { Component, createSignal, JSXElement, onMount, createMemo } from "solid-js"; @@ -22,14 +24,17 @@ type SongItemProps = { const SongItem: Component = (props) => { let item: HTMLDivElement | undefined; + const [, setCoords] = createSignal<[number, number]>([0, 0], { equals: false }); + + const { extractColorFromImage } = useColorExtractor(); + const { primaryColor, secondaryColor, processImage } = extractColorFromImage(props.song); const [localShow, setLocalShow] = createSignal(false); const [mousePos, setMousePos] = createSignal<[number, number]>([0, 0]); onMount(() => { - if (!item) { - return; - } + if (!item) return; + // Initialize draggable functionality draggable(item, { onClick: ignoreClickInContextMenu(() => props.onSelect(props.song.path)), onDrop: props.onDrop ?? (() => {}), @@ -42,8 +47,31 @@ const SongItem: Component = (props) => { } }); - const isActive = createMemo(() => { - return selectedSong().path === props.song.path; + const isSelected = createMemo(() => { + return selectedSong().audio === props.song.audio; + }); + + const borderColor = createMemo(() => { + const color = secondaryColor(); + if (isSelected()) { + return "#ffffff"; + } + + if (typeof color === "undefined") { + return "rgba(var(--color-thick-material))"; + } + + return color; + }); + + const backgrund = createMemo(() => { + const color = primaryColor(); + if (!color) { + return "rgba(0, 0, 0, 0.72)"; + } + + const lowerAlpha = transparentize(0.9); + return `linear-gradient(to right, ${color}, ${lowerAlpha(color)})`; }); return ( @@ -68,36 +96,42 @@ const SongItem: Component = (props) => {
{ + e.preventDefault(); setMousePos([e.clientX, e.clientY]); setLocalShow(true); }} > - - -
-
-

- {props.song.title} -

+
setCoords([evt.clientX, evt.clientY])} + > + +
+

{props.song.title}

{props.song.artist}

-
+
((acc, style) => { + if (typeof style !== "object") { + return acc; + } + + return { + ...acc, + ...style, + }; + }, {}); +} diff --git a/src/renderer/src/scenes/main-scene/MainScene.tsx b/src/renderer/src/scenes/main-scene/MainScene.tsx index 8cea517..01f3aca 100644 --- a/src/renderer/src/scenes/main-scene/MainScene.tsx +++ b/src/renderer/src/scenes/main-scene/MainScene.tsx @@ -35,13 +35,13 @@ const MainScene: Component = () => {