Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,13 +63,35 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro
const isFullscreen = !!useFullscreenElement(containerRef.current?.ownerDocument);

const [state, setState] = useState<VisualizerState>({ state: "loading" });
const [trackData, setTrackData] = useState<{ audioAnalysis?: SpotifyAudioAnalysis; themeColor: Spicetify.Color }>({
themeColor: Spicetify.Color.fromHex("#535353")
const [settings, setSettings] = useState<VisualizerSettings>(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;
Expand Down Expand Up @@ -111,8 +135,10 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro
Spicetify.CosmosAsync.get(analysisRequestUrl).catch(e => console.error("[Visualizer]", e)) as Promise<unknown>,
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 ||
Expand Down Expand Up @@ -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;

Expand All @@ -186,7 +221,7 @@ export default function App(props: { isSecondaryWindow?: boolean; onWindowDestro
<Renderer
isEnabled={state.state === "running"}
audioAnalysis={trackData.audioAnalysis}
themeColor={trackData.themeColor}
themeColor={visualizerColor}
/>
)}
</ErrorHandlerContext.Provider>
Expand All @@ -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)}
/>
</>
)}
Expand Down
155 changes: 131 additions & 24 deletions src/menu.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Spicetify.ReactComponent.IconComponent
Expand All @@ -9,6 +11,40 @@ const SpotifyIcon = React.memo((props: { name: Spicetify.Icon | "empty"; size: n
/>
));

const ColorInputModal = ({ initialColor, onConfirm }: { initialColor: string; onConfirm: (color: string) => void }) => {
const [value, setValue] = useState(initialColor);

return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px", padding: "16px" }}>
<Spicetify.ReactComponent.TextComponent variant="viola">Enter hex color code:</Spicetify.ReactComponent.TextComponent>
<input
type="text"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
style={{
backgroundColor: "var(--spice-main-elevated)",
color: "var(--spice-text)",
border: "none",
padding: "8px",
borderRadius: "4px"
}}
/>
<Spicetify.ReactComponent.ButtonPrimary
onClick={() => {
if (isValidHexColor(value)) {
onConfirm(value);
Spicetify.PopupModal.hide();
} else {
Spicetify.showNotification("Invalid hex color code!", true);
}
}}
>
Save
</Spicetify.ReactComponent.ButtonPrimary>
</div>
);
};

type MainMenuProps = {
renderers: RendererDefinition[];
currentRendererId: string;
Expand All @@ -20,32 +56,103 @@ type MainMenuProps = {
onOpenWindow: () => void;
};

const MainMenu = React.memo((props: MainMenuProps) => (
<Spicetify.ReactComponent.Menu>
<Spicetify.ReactComponent.MenuSubMenuItem displayText="Renderer">
{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(
<ColorInputModal
initialColor={settings.customColor}
onConfirm={color => {
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 (
<Spicetify.ReactComponent.Menu>
<Spicetify.ReactComponent.MenuSubMenuItem displayText="Renderer">
{props.renderers.map(v => (
<Spicetify.ReactComponent.MenuItem
onClick={() => props.onSelectRenderer(v.id)}
leadingIcon={<SpotifyIcon name={v.id === props.currentRendererId ? "check" : "empty"} size={16} />}
>
{v.name}
</Spicetify.ReactComponent.MenuItem>
))}
</Spicetify.ReactComponent.MenuSubMenuItem>

<Spicetify.ReactComponent.MenuSubMenuItem displayText="Color Source">
<Spicetify.ReactComponent.MenuItem
onClick={() => props.onSelectRenderer(v.id)}
leadingIcon={<SpotifyIcon name={v.id === props.currentRendererId ? "check" : "empty"} size={16} />}
onClick={() => handleSelectColorSource(ColorSource.EXTRACTED)}
leadingIcon={
<SpotifyIcon name={settings.colorSource === ColorSource.EXTRACTED ? "check" : "empty"} size={16} />
}
>
{v.name}
Song Color
</Spicetify.ReactComponent.MenuItem>
))}
</Spicetify.ReactComponent.MenuSubMenuItem>
<Spicetify.ReactComponent.MenuItem
onClick={() => (props.isFullscreen ? props.onExitFullscreen() : props.onEnterFullscreen())}
trailingIcon={<SpotifyIcon name={props.isFullscreen ? "minimize" : "fullscreen"} size={16} />}
>
{props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
</Spicetify.ReactComponent.MenuItem>
<Spicetify.ReactComponent.MenuItem
onClick={() => props.onOpenWindow()}
trailingIcon={<SpotifyIcon name="external-link" size={16} />}
>
Open Window
</Spicetify.ReactComponent.MenuItem>
</Spicetify.ReactComponent.Menu>
));
<Spicetify.ReactComponent.MenuItem
onClick={() => handleSelectColorSource(ColorSource.THEME)}
leadingIcon={<SpotifyIcon name={settings.colorSource === ColorSource.THEME ? "check" : "empty"} size={16} />}
>
Theme Color
</Spicetify.ReactComponent.MenuItem>
<Spicetify.ReactComponent.MenuItem
onClick={() => handleSelectColorSource(ColorSource.CUSTOM)}
leadingIcon={<SpotifyIcon name={settings.colorSource === ColorSource.CUSTOM ? "check" : "empty"} size={16} />}
>
Custom Color...
</Spicetify.ReactComponent.MenuItem>
</Spicetify.ReactComponent.MenuSubMenuItem>

<Spicetify.ReactComponent.MenuItem
onClick={() => (props.isFullscreen ? props.onExitFullscreen() : props.onEnterFullscreen())}
trailingIcon={<SpotifyIcon name={props.isFullscreen ? "minimize" : "fullscreen"} size={16} />}
>
{props.isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
</Spicetify.ReactComponent.MenuItem>
<Spicetify.ReactComponent.MenuItem
onClick={() => props.onOpenWindow()}
trailingIcon={<SpotifyIcon name="external-link" size={16} />}
>
Open Window
</Spicetify.ReactComponent.MenuItem>
</Spicetify.ReactComponent.Menu>
);
});

export const MainMenuButton = React.memo((props: MainMenuProps & { className: string; renderInline?: boolean }) => {
return (
Expand Down
15 changes: 9 additions & 6 deletions src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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({
Expand All @@ -41,7 +44,7 @@ export class MetadataService {
}
]
}),
(response: any) => {
(response: EsperantoResponse) => {
if (response.pendingResponse) return;
cancel.cancel();

Expand Down
6 changes: 4 additions & 2 deletions src/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ export class SpotifyModules {
private static loadedModules: Record<string, ModuleState> = {};

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
Expand Down
35 changes: 35 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -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<VisualizerSettings>) {
const currentSettings = getSettings();
const newSettings = { ...currentSettings, ...settings };
Spicetify.LocalStorage.set(SETTINGS_KEY, JSON.stringify(newSettings));
window.dispatchEvent(new Event("visualizer-settings-changed"));
}
4 changes: 4 additions & 0 deletions src/types/spicetify.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading