diff --git a/src/app.tsx b/src/app.tsx index 59a2653..416363a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -11,6 +11,8 @@ import SpectrumVisualizer from "./components/renderer/SpectrumVisualizer"; import { MainMenuButton } from "./menu"; import { createVisualizerWindow } from "./window"; import { useFullscreenElement } from "./hooks"; +import { ColorSource, getSettings, VisualizerSettings } from "./settings"; +import { resolveCSSVariable } from "./utils"; export type RendererProps = { isEnabled: boolean; @@ -61,13 +63,35 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro const isFullscreen = !!useFullscreenElement(containerRef.current?.ownerDocument); const [state, setState] = useState({ state: "loading" }); - const [trackData, setTrackData] = useState<{ audioAnalysis?: SpotifyAudioAnalysis; themeColor: Spicetify.Color }>({ - themeColor: Spicetify.Color.fromHex("#535353") + const [settings, setSettings] = useState(getSettings()); + const [trackData, setTrackData] = useState<{ audioAnalysis?: SpotifyAudioAnalysis; extractedColor: Spicetify.Color }>({ + extractedColor: Spicetify.Color.fromHex("#535353") }); + const visualizerColor = useMemo(() => { + switch (settings.colorSource) { + case ColorSource.THEME: + try { + const hex = resolveCSSVariable("--spice-accent"); + return Spicetify.Color.fromHex(hex); + } catch { + return Spicetify.Color.fromHex("#1db954"); + } + case ColorSource.CUSTOM: + try { + return Spicetify.Color.fromHex(settings.customColor); + } catch { + return Spicetify.Color.fromHex("#FFFFFF"); + } + case ColorSource.EXTRACTED: + default: + return trackData.extractedColor; + } + }, [settings, trackData.extractedColor]); + const updateState = useCallback( (newState: VisualizerState) => - setState(oldState => { + setState((oldState: VisualizerState) => { if (oldState.state === "error" && oldState.errorData.recovery === ErrorRecovery.NONE) return oldState; return newState; @@ -111,8 +135,10 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro Spicetify.CosmosAsync.get(analysisRequestUrl).catch(e => console.error("[Visualizer]", e)) as Promise, metadataService .fetch(ExtensionKind.EXTRACTED_COLOR, item.metadata.image_url) - .catch(s => console.error(`[Visualizer] Could not load extracted color metadata. Status: ${CacheStatus[s]}`)) - .then(colors => { + .catch((s: CacheStatus) => + console.error(`[Visualizer] Could not load extracted color metadata. Status: ${CacheStatus[s]}`) + ) + .then((colors: { value: Uint8Array; typeUrl: string } | null | void) => { if ( !colors || colors.value.length === 0 || @@ -158,12 +184,21 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro } } - setTrackData({ audioAnalysis: audioAnalysis as SpotifyAudioAnalysis, themeColor: vibrantColor }); + setTrackData({ audioAnalysis: audioAnalysis as SpotifyAudioAnalysis, extractedColor: vibrantColor }); updateState({ state: "running" }); }, [metadataService] ); + useEffect(() => { + const handleSettingsChange = () => { + setSettings(getSettings()); + }; + + window.addEventListener("visualizer-settings-changed", handleSettingsChange); + return () => window.removeEventListener("visualizer-settings-changed", handleSettingsChange); + }, []); + useEffect(() => { if (isUnrecoverableError) return; @@ -186,7 +221,7 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro )} @@ -203,7 +238,7 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro containerRef.current?.ownerDocument.exitFullscreen(); }} onOpenWindow={() => createVisualizerWindow(rendererId)} - onSelectRenderer={id => setRendererId(id)} + onSelectRenderer={(id: string) => setRendererId(id)} /> )} diff --git a/src/menu.tsx b/src/menu.tsx index 40bf2b5..d80d9f6 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -1,5 +1,7 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { RendererDefinition } from "./app"; +import { ColorSource, getSettings, saveSettings } from "./settings"; +import { isValidHexColor } from "./utils"; const SpotifyIcon = React.memo((props: { name: Spicetify.Icon | "empty"; size: number }) => ( )); +const ColorInputModal = ({ initialColor, onConfirm }: { initialColor: string; onConfirm: (color: string) => void }) => { + const [value, setValue] = useState(initialColor); + + return ( +
+ Enter hex color code: + ) => setValue(e.target.value)} + style={{ + backgroundColor: "var(--spice-main-elevated)", + color: "var(--spice-text)", + border: "none", + padding: "8px", + borderRadius: "4px" + }} + /> + { + if (isValidHexColor(value)) { + onConfirm(value); + Spicetify.PopupModal.hide(); + } else { + Spicetify.showNotification("Invalid hex color code!", true); + } + }} + > + Save + +
+ ); +}; + type MainMenuProps = { renderers: RendererDefinition[]; currentRendererId: string; @@ -20,32 +56,103 @@ type MainMenuProps = { onOpenWindow: () => void; }; -const MainMenu = React.memo((props: MainMenuProps) => ( - - - {props.renderers.map(v => ( +const MainMenu = React.memo((props: MainMenuProps) => { + const [settings, setSettings] = useState(getSettings()); + + useEffect(() => { + const handler = () => setSettings(getSettings()); + window.addEventListener("visualizer-settings-changed", handler); + return () => window.removeEventListener("visualizer-settings-changed", handler); + }, []); + + const handleSelectColorSource = (source: ColorSource) => { + if (source === ColorSource.CUSTOM) { + const promptColor = () => { + const container = document.createElement("div"); + const handleModalClose = () => { + Spicetify.ReactDOM.unmountComponentAtNode(container); + container.remove(); + }; + + Spicetify.ReactDOM.render( + { + saveSettings({ colorSource: ColorSource.CUSTOM, customColor: color }); + handleModalClose(); + }} + />, + container + ); + Spicetify.PopupModal.display({ + title: "Custom Color", + content: container + }); + + const originalHide = Spicetify.PopupModal.hide; + Spicetify.PopupModal.hide = () => { + handleModalClose(); + originalHide(); + Spicetify.PopupModal.hide = originalHide; + }; + }; + promptColor(); + } else { + saveSettings({ colorSource: source }); + } + }; + + return ( + + + {props.renderers.map(v => ( + props.onSelectRenderer(v.id)} + leadingIcon={} + > + {v.name} + + ))} + + + props.onSelectRenderer(v.id)} - leadingIcon={} + onClick={() => handleSelectColorSource(ColorSource.EXTRACTED)} + leadingIcon={ + + } > - {v.name} + Song Color - ))} - - (props.isFullscreen ? props.onExitFullscreen() : props.onEnterFullscreen())} - trailingIcon={} - > - {props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} - - props.onOpenWindow()} - trailingIcon={} - > - Open Window - - -)); + handleSelectColorSource(ColorSource.THEME)} + leadingIcon={} + > + Theme Color + + handleSelectColorSource(ColorSource.CUSTOM)} + leadingIcon={} + > + Custom Color... + + + + (props.isFullscreen ? props.onExitFullscreen() : props.onEnterFullscreen())} + trailingIcon={} + > + {props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} + + props.onOpenWindow()} + trailingIcon={} + > + Open Window + + + ); +}); export const MainMenuButton = React.memo((props: MainMenuProps & { className: string; renderInline?: boolean }) => { return ( diff --git a/src/metadata.ts b/src/metadata.ts index de26447..fe9a403 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -14,8 +14,8 @@ export enum CacheStatus { } export class MetadataService { - service: any; - serviceDescriptor: any; + service: EsperantoService | null = null; + serviceDescriptor: any | null = null; public constructor() { const metadataService = SpotifyModules.getMetadataService(); @@ -24,13 +24,16 @@ export class MetadataService { if (!metadataService) return; if (!createTransport) return; - this.serviceDescriptor = metadataService as any; - this.service = new this.serviceDescriptor((createTransport as any)()); + this.serviceDescriptor = metadataService; + this.service = new (this.serviceDescriptor as any)((createTransport as () => unknown)()); } public fetch(kind: ExtensionKind, entityUri: string): Promise<{ typeUrl: string; value: Uint8Array }> { return new Promise((resolve, reject) => { - if (!this.service || !this.serviceDescriptor) reject(CacheStatus.UNKNOWN); + if (!this.service || !this.serviceDescriptor) { + reject(CacheStatus.UNKNOWN); + return; + } const cancel = this.service.observe( this.serviceDescriptor.METHODS.observe.requestType.fromPartial({ @@ -41,7 +44,7 @@ export class MetadataService { } ] }), - (response: any) => { + (response: EsperantoResponse) => { if (response.pendingResponse) return; cancel.cancel(); diff --git a/src/modules.ts b/src/modules.ts index fcbc39d..2c330cb 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -5,8 +5,10 @@ export class SpotifyModules { private static loadedModules: Record = {}; private static init() { - const webpack = (window as any).webpackChunkclient_web ?? (window as any).webpackChunkopen; - const require = webpack.push([[Symbol()], {}, (re: any) => re]); + const webpack = + (window as { webpackChunkclient_web?: any; webpackChunkopen?: any }).webpackChunkclient_web ?? + (window as any).webpackChunkopen; + const require = webpack.push([[Symbol("visualizer")], {}, (re: (id: string) => unknown) => re]); const cache = Object.keys(require.m).map(id => require(id)); this.modules = cache diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..743b070 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,35 @@ +export enum ColorSource { + EXTRACTED = "extracted", + THEME = "theme", + CUSTOM = "custom" +} + +export type VisualizerSettings = { + colorSource: ColorSource; + customColor: string; +}; + +const SETTINGS_KEY = "visualizer:settings"; + +const DEFAULT_SETTINGS: VisualizerSettings = { + colorSource: ColorSource.EXTRACTED, + customColor: "#FFFFFF" +}; + +export function getSettings(): VisualizerSettings { + const settings = Spicetify.LocalStorage.get(SETTINGS_KEY); + if (!settings) return DEFAULT_SETTINGS; + + try { + return { ...DEFAULT_SETTINGS, ...JSON.parse(settings) }; + } catch { + return DEFAULT_SETTINGS; + } +} + +export function saveSettings(settings: Partial) { + const currentSettings = getSettings(); + const newSettings = { ...currentSettings, ...settings }; + Spicetify.LocalStorage.set(SETTINGS_KEY, JSON.stringify(newSettings)); + window.dispatchEvent(new Event("visualizer-settings-changed")); +} diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index f40fb74..95bec32 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -1819,6 +1819,10 @@ declare namespace Spicetify { * @see Spicetify.ReactComponent.MenuItemProps */ const MenuItem: any; + /** + * Component to construct menu sub-item + */ + const MenuSubMenuItem: any; /** * Tailored ReactComponent.Menu for specific type of object * diff --git a/src/types/types.d.ts b/src/types/types.d.ts index a416b42..95660d3 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -1,5 +1,32 @@ type PlayerEventListener = Parameters[1]; +type ModuleState = { state: "failed" } | { state: "succeeded"; value: unknown }; + +interface EsperantoResponse { + pendingResponse: boolean; + extensionResult: { + status: number; + details: { + cacheStatus: number; + }; + extensionData: { + typeUrl: string; + value: Uint8Array; + }; + }[]; +} + +interface EsperantoService { + observe(request: unknown, callback: (response: EsperantoResponse) => void): { cancel(): void }; + METHODS: { + observe: { + requestType: { + fromPartial(data: unknown): unknown; + }; + }; + }; +} + namespace SpotifyAudioAnalysis { interface Meta { analyzer_version: string; diff --git a/src/utils.ts b/src/utils.ts index 7f5bb14..05a9de6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -137,3 +137,12 @@ export function sampleAccumulatedIntegral(amplitudeCurve: CurveEntry[], position return (p1.accumulatedIntegral ?? 0) + integrateLinearSegment(p1, mid); } + +export function resolveCSSVariable(variableName: string): string { + if (typeof window === "undefined") return "#FFFFFF"; + return window.getComputedStyle(document.documentElement).getPropertyValue(variableName).trim() || "#FFFFFF"; +} + +export function isValidHexColor(color: string): boolean { + return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color); +}