mirror of
https://github.com/SrIzan10/osu-radio.git
synced 2026-05-01 10:55:12 +00:00
Merge pull request #89 from Glockosu/feature/color-changes
Dynamic Color Changes
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
5
src/@types.d.ts
vendored
@@ -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
2
src/RequestAPI.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
13
src/main/router/song-color-router.ts
Normal file
13
src/main/router/song-color-router.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
180
src/renderer/src/components/song/color-extractor.ts
Normal file
180
src/renderer/src/components/song/color-extractor.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user