Merge pull request #89 from Glockosu/feature/color-changes

Dynamic Color Changes
This commit is contained in:
duduBTW
2024-10-24 20:55:37 -03:00
committed by GitHub
15 changed files with 363 additions and 48 deletions

34
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

5
src/@types.d.ts vendored
View File

@@ -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;

2
src/RequestAPI.d.ts vendored
View File

@@ -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;
};

View File

@@ -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";

View File

@@ -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);
});

View File

@@ -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<JSX.IntrinsicElements["span"]> = (props) => {
const state = useSlider();
return (
<span
{...props}
style={{
...state.transitionStyleValue(),
// Memoize the style calculations
const computedStyle = createMemo(() => {
return sn(
state.transitionStyleValue(),
{
width: `${state.percentage()}%`,
"pointer-events": "none",
"transition-property": "width",
}}
/>
);
},
props.style,
);
});
return <span {...props} style={computedStyle()} />;
};
export default SliderRange;

View File

@@ -11,6 +11,7 @@ type SongImageProps = {
src: string | undefined | Accessor<string | undefined>;
group?: string;
instantLoad?: boolean;
onImageLoaded?: (src: string) => void;
} & JSX.IntrinsicElements["div"];
const SongImage: Component<SongImageProps> = (props) => {
@@ -23,6 +24,7 @@ const SongImage: Component<SongImageProps> = (props) => {
}
setSrc(evt.detail);
props.onImageLoaded?.(evt.detail);
delete image?.dataset.eventHandler;
};

View File

@@ -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<string | undefined>;
secondaryColor: Accessor<string | undefined>;
processImage(src: string): void;
};
export function useColorExtractor() {
const extractColorFromImage = (song: Song): UseColorExtractorResult => {
const [primaryColor, setPrimartColor] = createSignal<string | undefined>(song.primaryColor);
const [secondaryColor, setSecondaryColor] = createSignal<string | undefined>(
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<ExtractColorsFromImageResult> {
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;
}

View File

@@ -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<SongControlsProps> = (props) => {
const [disable, setDisable] = createSignal(isSongUndefined(song()));
const [playHint, setPlayHint] = createSignal("");
@@ -43,7 +48,7 @@ const SongControls: Component = () => {
createEffect(() => setDisable(isSongUndefined(song())));
return (
<div class="flex w-full items-center gap-4">
<div class="flex w-full items-center gap-4" style={{ "--dynamic-color": props.averageColor }}>
<LeftPart />
<div class="flex flex-1 items-center justify-center gap-6">
<Button
@@ -68,13 +73,16 @@ const SongControls: Component = () => {
</Button>
<button
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-2xl text-thick-material"
class="flex h-12 w-12 items-center justify-center rounded-full border border-solid border-stroke bg-surface text-2xl text-thick-material text-white"
onClick={() => togglePlay()}
disabled={disable()}
title={playHint()}
style={{
"background-color": props.averageColor, // Use the average color as background
}}
>
<Show when={!isPlaying()} fallback={<PauseIcon fill="currentColor" size={20} />}>
<PlayIcon fill="currentColor" size={20} />
<Show when={!isPlaying()} fallback={<PauseIcon fill="white" size={20} />}>
<PlayIcon fill="white" size={20} />
</Show>
</button>
@@ -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);

View File

@@ -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 (
<div class="flex h-full w-full max-w-[800px] flex-col p-8">
<div class="mb-8 grid flex-grow place-items-center">
<SongImage
src={song().bg}
instantLoad={true}
onImageLoaded={colorData().processImage}
class="size-80 rounded-lg bg-cover bg-center object-cover shadow-lg"
/>
</div>
@@ -29,14 +35,15 @@ const SongDetail: Component = () => {
<span class="text-lg">{song().artist}</span>
</div>
<ProgressBar />
<SongControls />
<ProgressBar averageColor={colorData().primaryColor()} />
<SongControls averageColor={colorData().primaryColor()} />
</div>
</div>
);
};
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
>
<Slider.Track class="flex h-7 items-center rounded-xl bg-thick-material p-1">
<Slider.Range class="block h-5 rounded-l-lg bg-surface" />
<Slider.Track class="flex h-7 items-center rounded-xl border border-stroke bg-thick-material p-1">
<Slider.Range
class="block h-5 rounded-l-lg border border-stroke bg-surface"
style={{
"background-color": props.averageColor,
}}
/>
</Slider.Track>
<Slider.Thumb class="-mt-0.5 block h-8 w-1.5 rounded-lg bg-white" />
<Slider.Time class="z-10 block px-3 pt-1.5 text-end text-[13px] font-bold">

View File

@@ -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<SongItemProps> = (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<SongItemProps> = (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<SongItemProps> = (props) => {
</Popover.Content>
</Portal>
<div
class="group relative isolate z-20 select-none rounded-md"
class="min-h-[72px] rounded-lg py-0.5 pl-1.5 pr-0.5 transition-colors"
classList={{
"outline outline-2 outline-accent": isActive(),
"shadow-glow-blue": isSelected(),
}}
style={{
background: borderColor(),
}}
data-active={isActive()}
ref={item}
data-url={props.song.bg}
onContextMenu={(e) => {
e.preventDefault();
setMousePos([e.clientX, e.clientY]);
setLocalShow(true);
}}
>
<SongImage
class={twMerge(
"absolute inset-0 z-[-1] h-full w-full rounded-md bg-cover bg-center bg-no-repeat opacity-30 group-hover:opacity-90",
isActive() && "opacity-90",
)}
src={props.song.bg}
group={props.group}
/>
<div class="flex flex-row items-center justify-between rounded-md bg-black/50">
<div class="z-20 flex min-h-[72px] flex-col justify-center overflow-hidden rounded-md p-3">
<h3 class="text-shadow text-[22px] font-extrabold leading-7 shadow-black/60">
{props.song.title}
</h3>
<div
class="group relative isolate select-none rounded-lg"
ref={item}
data-url={props.song.bg}
onContextMenu={(evt) => setCoords([evt.clientX, evt.clientY])}
>
<SongImage
class={`absolute inset-0 z-[-1] h-full w-full rounded-md bg-cover bg-center bg-no-repeat`}
src={props.song.bg}
group={props.group}
onImageLoaded={processImage}
/>
<div
class="flex flex-col justify-center overflow-hidden rounded-md p-3"
style={{
background: backgrund(),
}}
>
<h3 class="text-shadow text-[22px] font-extrabold leading-7">{props.song.title}</h3>
<p class="text-base text-subtext">{props.song.artist}</p>
</div>
<div class="mr-2 grid aspect-square size-9 place-items-center rounded border-solid border-stroke bg-transparent p-1 text-text hover:bg-surface">
<div class="absolute right-2 top-1/2 -translate-y-1/2 grid aspect-square size-9 place-items-center rounded border-solid border-stroke bg-transparent p-1 text-text hover:bg-surface">
<Popover.Trigger
class={twMerge(
"opacity-0 transition-opacity group-hover:opacity-100",

View File

@@ -1,6 +1,21 @@
import { clsx, type ClassValue } from "clsx";
import { JSX } from "solid-js/jsx-runtime";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type Style = string | JSX.CSSProperties | undefined;
export function sn(...styles: Style[]): JSX.CSSProperties {
return styles.reduce<JSX.CSSProperties>((acc, style) => {
if (typeof style !== "object") {
return acc;
}
return {
...acc,
...style,
};
}, {});
}

View File

@@ -35,13 +35,13 @@ const MainScene: Component = () => {
<Nav />
<main class="relative flex h-[calc(100vh-52px)]">
<TabContent />
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-1 items-center justify-center song-detail-gradient">
<SongDetail />
</div>
<QueueModal />
</main>
<div class="pointer-events-none absolute inset-0 z-[-1] opacity-[0.072]">
<div class="pointer-events-none absolute inset-0 z-[-1] opacity-[0.12]">
<SongImage
src={song().bg}
instantLoad={true}

View File

@@ -26,6 +26,9 @@ export default {
red: "rgba(var(--color-red))",
green: "rgba(var(--color-green))",
},
boxShadow: {
"glow-blue": "0px 0px 10px #4EBFFF, 0px 0px 28px rgba(78, 191, 255, 0.72)",
},
},
},
variants: {