Skip to content

Commit

Permalink
feat: Album-Art Based Color Shifting (#394)
Browse files Browse the repository at this point in the history
Co-authored-by: Nam Anh <[email protected]>
Co-authored-by: Isaac Maier <[email protected]>
  • Loading branch information
3 people authored Dec 4, 2022
1 parent 9ae1c09 commit 67b3ca3
Show file tree
Hide file tree
Showing 11 changed files with 911 additions and 17 deletions.
3 changes: 3 additions & 0 deletions packages/marketplace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,21 @@
"node": ">=16"
},
"devDependencies": {
"@types/chroma-js": "^2.1.4",
"@types/filesystem": "^0.0.32",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@types/semver": "^7.3.13",
"@types/wicg-file-system-access": "^2020.9.5",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"chroma-js": "^2.4.2",
"copyfiles": "^2.4.1",
"eslint": "^8.28.0",
"eslint-plugin-react": "^7.31.11",
"i18next": "^22.0.6",
"i18next-browser-languagedetector": "^7.0.1",
"node-vibrant": "3.1.4",
"prismjs": "^1.29.0",
"react-dropdown": "^1.11.0",
"react-i18next": "^12.0.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/marketplace/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ class App extends React.Component<{
hideInstalled: JSON.parse(getLocalStorageDataFromKey("marketplace:hideInstalled", false)),
colorShift: JSON.parse(getLocalStorageDataFromKey("marketplace:colorShift", false)),
themeDevTools: JSON.parse(getLocalStorageDataFromKey("marketplace:themeDevTools", false)),
albumArtBasedColors: JSON.parse(getLocalStorageDataFromKey("marketplace:albumArtBasedColors", false)),
albumArtBasedColorsMode: getLocalStorageDataFromKey("marketplace:albumArtBasedColorsMode") || "monochrome-light",
// Legacy from reddit app
type: JSON.parse(getLocalStorageDataFromKey("marketplace:type", false)),
// I was considering adding watchers as "followers" but it looks like the value is a duplicate
Expand Down
35 changes: 32 additions & 3 deletions packages/marketplace/src/components/Modals/Settings/ConfigRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import React from "react";
import { Config } from "../../../types/marketplace-types";

import Toggle from "../../Toggle";

import SortBox from "../../Sortbox";
const ConfigRow = (props: {
name: string;
storageKey: string;
modalConfig: Config;
clickable?: boolean;
updateConfig: (CONFIG: Config) => void;
type?: string;
options?: string[];
}) => {
const toggleId = `toggle:${props.storageKey}`;
const type = props.type;
const componentId = (type === "dropdown")
? "dropdown:" + props.storageKey
: "toggle:" + props.storageKey;
const enabled = !!props.modalConfig.visual[props.storageKey];

const settingsToggleChange = (e) => {
Expand All @@ -24,10 +29,34 @@ const ConfigRow = (props: {
props.updateConfig(props.modalConfig);
// gridUpdatePostsVisual && gridUpdatePostsVisual();
};
const settingsDropdownChange = (value) => {
const state = value;
const storageKey = props.storageKey;
props.modalConfig.visual[storageKey] = state;
localStorage.setItem(`marketplace:${storageKey}`, String(state));
props.updateConfig(props.modalConfig);
};

if (type === "dropdown" && props.options) {
return (
<SortBox
sortBoxOptions={props.options.map((option) => {
return {
key: option,
value: option,
};
})}
onChange={(value) => settingsDropdownChange(value)}
sortBySelectedFn={(item) => {
return item.key == props.modalConfig.visual[props.storageKey];
}}
/>

);
}
return (
<div className='setting-row'>
<label htmlFor={toggleId} className='col description'>{props.name}</label>
<label htmlFor={componentId} className='col description'>{props.name}</label>
<div className='col action'>
<Toggle name={props.name} storageKey={props.storageKey} enabled={enabled} onChange={settingsToggleChange} />
</div>
Expand Down
14 changes: 9 additions & 5 deletions packages/marketplace/src/components/Modals/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ const SettingsModal = ({ CONFIG, updateAppConfig } : Props) => {
return (
<div id="marketplace-config-container">
<h2>{t("settings.optionsHeading")}</h2>
<ConfigRow name={t("settings.starCountLabel")} storageKey='stars' modalConfig={modalConfig} updateConfig={updateConfig} />
<ConfigRow name={t("settings.tagsLabel")} storageKey='tags' modalConfig={modalConfig} updateConfig={updateConfig} />
<ConfigRow name={t("settings.devToolsLabel")} storageKey='themeDevTools' modalConfig={modalConfig} updateConfig={updateConfig} />
<ConfigRow name={t("settings.hideInstalledLabel")} storageKey='hideInstalled' modalConfig={modalConfig} updateConfig={updateConfig} />
<ConfigRow name={t("settings.colourShiftLabel")} storageKey='colorShift' modalConfig={modalConfig} updateConfig={updateConfig} />
<ConfigRow name={t("settings.starCountLabel")} storageKey='stars' modalConfig={modalConfig} updateConfig={updateConfig}/>
<ConfigRow name={t("settings.tagsLabel")} storageKey='tags' modalConfig={modalConfig} updateConfig={updateConfig}/>
<ConfigRow name={t("settings.devToolsLabel")} storageKey='themeDevTools' modalConfig={modalConfig} updateConfig={updateConfig}/>
<ConfigRow name={t("settings.hideInstalledLabel")} storageKey='hideInstalled' modalConfig={modalConfig} updateConfig={updateConfig}/>
<ConfigRow name={t("settings.colourShiftLabel")} storageKey='colorShift' modalConfig={modalConfig} updateConfig={updateConfig}/>
<ConfigRow name={t("settings.albumArtBasedColors")} storageKey='albumArtBasedColors' modalConfig={modalConfig} updateConfig={updateConfig}/>
{/* Make options monochrome-dark ,monochrome-light ,analogic complement ,analogic-complement ,triad ,quad*/}
<ConfigRow name={t("settings.albumArtBasedColorsMode")} storageKey='albumArtBasedColorsMode' modalConfig=
{modalConfig} updateConfig={updateConfig} type="dropdown" options={["monochromeDark", "monochromeLight", "analogicComplement", "analogic", "triad", "quad"]} />
<h2>{t("settings.tabsHeading")}</h2>
<div className="tabs-container">
{modalConfig.tabs.map(({ name }, index) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/marketplace/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const LOCALSTORAGE_KEYS = {
sortBy: "marketplace:sort-by",
// Theme installed store the localsorage key of the theme (e.g. marketplace:installed:NYRI4/Comfy-spicetify/user.css)
themeInstalled: "marketplace:theme-installed",
albumArtBasedColor: "marketplace:albumArtBasedColors",
albumArtBasedColorMode: "marketplace:albumArtBasedColorsMode",
colorShift: "marketplace:colorShift",
};

Expand Down
6 changes: 4 additions & 2 deletions packages/marketplace/src/extensions/extension.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
addToSessionStorage,
sleep,
addExtensionToSpicetifyConfig,
initAlbumArtBasedColor,
} from "../logic/Utils";
import {
getBlacklist,
Expand Down Expand Up @@ -91,8 +92,9 @@ import {

// Add to Spicetify.Config
Spicetify.Config.color_scheme = themeManifest.activeScheme;

if (localStorage.getItem(LOCALSTORAGE_KEYS.colorShift) === "true") {
if (localStorage.getItem(LOCALSTORAGE_KEYS.albumArtBasedColor) === "true") {
initAlbumArtBasedColor(activeScheme);
} else if (localStorage.getItem(LOCALSTORAGE_KEYS.colorShift) === "true") {
initColorShiftLoop(themeManifest.schemes);
}
} else {
Expand Down
111 changes: 108 additions & 3 deletions packages/marketplace/src/logic/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CardProps } from "../components/Card/Card";
import { Author, CardItem, ColourScheme, SchemeIni, Snippet, SortBoxOption } from "../types/marketplace-types";

import Vibrant from "node-vibrant";
import Chroma from "chroma-js";
import { LOCALSTORAGE_KEYS } from "../constants";
/**
* Get localStorage data (or fallback value), given a key
* @param key The localStorage key
Expand All @@ -9,7 +11,6 @@ import { Author, CardItem, ColourScheme, SchemeIni, Snippet, SortBoxOption } fro
*/
export const getLocalStorageDataFromKey = (key: string, fallback?: unknown) => {
const data = localStorage.getItem(key);

if (data) {
try {
// If it's json parse it
Expand Down Expand Up @@ -317,6 +318,109 @@ export const initColorShiftLoop = (schemes: SchemeIni) => {
}, 60 * 1000);
};

export const getColorFromImage = async (image: HTMLImageElement, numColors: number) => {
const swatches = await Vibrant.from(image).maxColorCount(numColors).getPalette((err, palette) => {
if (err) {
console.error(err);
return;
}
return palette;
});

if (swatches.Vibrant) {
// remove the # from the hex
return swatches.Vibrant.hex.substring(1);
}

return "null";
};

export const generateColorPalette = async (mainColor: string, numColors: number) => {
// Generate a palette from https://www.thecolorapi.com/id?hex=0047AB&rgb=0,71,171&hsl=215,100%,34%&cmyk=100,58,0,33&format=html
const mode = getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.albumArtBasedColorMode);
// Add a hyphen before any uppercase characters
const modeStr = mode.replace(/([A-Z])/g, "-$1").toLowerCase();
//fetch `https://www.thecolorapi.com/scheme?hex=${mainColor}&mode=${modeStr}&count=${numColors}`
const palette = await fetch(`https://www.thecolorapi.com/scheme?hex=${mainColor}&mode=${modeStr}&count=${numColors}`)
.then((response) => response.json());
// create an array of the hex values for the colors while also removing the #
const colorArray = palette.colors.map((color) => color.hex.value.substring(1));
return colorArray;
};

async function waitForAlbumArt(): Promise<HTMLImageElement | null> {
// Only return when the album art is loaded
return new Promise((resolve) => {
setInterval(() => {
const albumArt: HTMLImageElement | null = document.querySelector(".main-image-image.cover-art-image");
if (albumArt) {
resolve(albumArt);
}
}, 50);
});
}

export const initAlbumArtBasedColor = (scheme: ColourScheme) => {
const style = document.createElement("style");
style.className = "colorShift-style";
style.innerHTML = `
* {
transition-duration: 400ms;
}
.main-type-bass {
transition-duration: unset !important;
}`;
// Add a listener for the album art changing
// and update the color scheme accordingly
document.body.appendChild(style);
Spicetify.Player.addEventListener("songchange", async () => {
await sleep(1000);
let albumArt: HTMLImageElement | null = document.querySelector(".main-image-image.cover-art-image");

// If it doesn't exist, wait for it to load
if (albumArt == null || !albumArt.complete) {
albumArt = await waitForAlbumArt();
}

if (albumArt) {
const numColors = new Set(Object.values(scheme)).size;
const mainColor = await getColorFromImage(albumArt, numColors);
const newColors = await generateColorPalette(mainColor, numColors);
/* Find which keys share the same value in the current scheme, create a new scheme that has the value as the key and all the keys in the old scheme as the value
i.e.
{ "color1": "#000000", "color2": "#000000", "color3": "#FFFFFF" } ->
{ "#000000": ["color1", "color2"], "#FFFFFF": ["color3"]}
*/
let colorMap = new Map();
for (const [key, value] of Object.entries(scheme)) {
if (colorMap.has(value)) {
colorMap.get(value).push(key);
} else {
colorMap.set(value, [key]);
}
}
// Order the color map by how similar the colors are to eachother
const orderedColorMap = new Map([...colorMap.entries()].sort((a, b) => {
const aColor = Chroma(a[0]);
const bColor = Chroma(b[0]);
return aColor.get("lab.l") - bColor.get("lab.l");
}));
colorMap = orderedColorMap;
// replace the keys in the color map with the new colors
const newScheme = {};
for (const [, value] of colorMap.entries()) {
const newColor = newColors.shift();
if (newColor) {
for (const key of value) {
newScheme[key] = newColor;
}
}
}
injectColourScheme(newScheme);
}
});
};

export const parseCSS = async (themeData: CardItem) => {
if (!themeData.cssURL) throw new Error("No CSS URL provided");

Expand Down Expand Up @@ -378,7 +482,7 @@ export const getParamsFromGithubRaw = (url: string) => {

export function addToSessionStorage(items, key?) {
if (!items) return;
items.forEach(item => {
items.forEach((item) => {
if (!key) key = `${items.user}-${items.repo}`;
// If the key already exists, it will append to it instead of overwriting it
const existing = window.sessionStorage.getItem(key);
Expand Down Expand Up @@ -478,3 +582,4 @@ export const addExtensionToSpicetifyConfig = (main?: string) => {
Spicetify.Config.extensions.push(name);
}
};

4 changes: 3 additions & 1 deletion packages/marketplace/src/resources/locales/en-US.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"translation": {
"settings": {
"colourShiftLabel": "Shift colors every minute"
"colourShiftLabel": "Shift colors every minute",
"albumArtBasedColors": "Change colors based on album art",
"albumArtBasedColorsMode": "The mode that the colorapi uses to generate color schemes"
},
"devTools": {
"colorIniEditorPlaceholder": "[your-color-scheme-name]"
Expand Down
2 changes: 2 additions & 0 deletions packages/marketplace/src/resources/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"devToolsLabel": "Theme developer tools",
"hideInstalledLabel": "Hide installed when browsing",
"colourShiftLabel": "Shift colours every minute",
"albumArtBasedColors": "Change colours based on album art",
"albumArtBasedColorsMode": "The mode that the colorapi uses to generate colour schemes",
"tabsHeading": "Tabs",
"resetHeading": "Reset",
"resetBtn": "$t(settings.resetHeading)",
Expand Down
3 changes: 3 additions & 0 deletions packages/marketplace/src/types/marketplace-types.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

declare global {
interface Window {
Marketplace: Record<string, unknown>;
Expand Down Expand Up @@ -116,6 +117,8 @@ export type VisualConfig = {
hideInstalled: boolean;
colorShift: boolean;
themeDevTools: boolean;
albumArtBasedColors: boolean;
albumArtBasedColorsMode: "monochromeLight" | "monochromeDark" | "quad" | "triad" | "analogic" | "analogicComplement";
// Legacy from reddit app
type: boolean;
// I was considering adding watchers as "followers" but it looks like the value is a duplicate
Expand Down
Loading

0 comments on commit 67b3ca3

Please sign in to comment.