397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
import type {
|
|
ButterChurnPresets,
|
|
ButterChurnWebampPreset,
|
|
SkinData,
|
|
WebampApiResponse,
|
|
WebampCI,
|
|
} from "components/apps/Webamp/types";
|
|
import { centerPosition } from "components/system/Window/functions";
|
|
import type { Position } from "react-rnd";
|
|
import { HOME, MP3_MIME_TYPE, PACKAGE_DATA } from "utils/constants";
|
|
import { bufferToBlob, cleanUpBufferUrl, loadFiles } from "utils/functions";
|
|
import type { Track, URLTrack } from "webamp";
|
|
|
|
const BROKEN_PRESETS = new Set([
|
|
"Flexi - alien fish pond",
|
|
"Geiss - Spiral Artifact",
|
|
]);
|
|
|
|
const WEBAMP_SKINS_PATH = `${HOME}/Documents/Winamp Skins`;
|
|
|
|
const ALLOWS_CORS_IN_WINAMP_SKIN_MUSEUM =
|
|
typeof window !== "undefined" &&
|
|
["localhost", PACKAGE_DATA.author.url.replace("https://", "")].includes(
|
|
window.location.hostname
|
|
);
|
|
|
|
const createWebampSkinMuseumQuery = (offset: number): string => `
|
|
query {
|
|
skins(filter: APPROVED, first: 1, offset: ${offset}) {
|
|
nodes {
|
|
download_url
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
export const BASE_WEBAMP_OPTIONS = {
|
|
availableSkins: [
|
|
{
|
|
name: "Aqua X",
|
|
url: `${WEBAMP_SKINS_PATH}/Aqua_X.wsz`,
|
|
},
|
|
{
|
|
name: "Nucleo NLog v2G",
|
|
url: `${WEBAMP_SKINS_PATH}/Nucleo_NLog_v102.wsz`,
|
|
},
|
|
...(ALLOWS_CORS_IN_WINAMP_SKIN_MUSEUM
|
|
? [
|
|
{
|
|
defaultName: "Random (Winamp Skin Museum)",
|
|
loading: false,
|
|
get name(): string {
|
|
if (this.loading) return this.defaultName;
|
|
|
|
this.loading = true;
|
|
|
|
fetch("https://api.webamp.org/graphql", {
|
|
body: JSON.stringify({
|
|
query: createWebampSkinMuseumQuery(
|
|
Math.floor(Math.random() * 1000)
|
|
),
|
|
}),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
method: "POST",
|
|
}).then(async (response) => {
|
|
const { data } = ((await response.json()) ||
|
|
{}) as WebampApiResponse;
|
|
|
|
this.skinUrl = data?.skins?.nodes?.[0]?.download_url as string;
|
|
this.loading = false;
|
|
});
|
|
|
|
return this.defaultName;
|
|
},
|
|
skinUrl: "",
|
|
get url(): string {
|
|
return this.skinUrl || `${WEBAMP_SKINS_PATH}/base-2.91.wsz`;
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
name: "SpyAMP Professional Edition v5",
|
|
url: `${WEBAMP_SKINS_PATH}/SpyAMP_Professional_Edition_v5.wsz`,
|
|
},
|
|
],
|
|
};
|
|
|
|
const BASE_WINDOW_SIZE = {
|
|
height: 116,
|
|
width: 275,
|
|
};
|
|
|
|
const CONTAINER_WINDOW = "#webamp";
|
|
|
|
export const MAIN_WINDOW = "#main-window";
|
|
|
|
export const PLAYLIST_WINDOW = "#playlist-window";
|
|
|
|
export const cleanBufferOnSkinLoad = (
|
|
webamp: WebampCI,
|
|
url = ""
|
|
): Promise<void> =>
|
|
webamp.skinIsLoaded().then(() => {
|
|
if (url) cleanUpBufferUrl(url);
|
|
});
|
|
|
|
export const closeEqualizer = (webamp: WebampCI): void =>
|
|
webamp.store.dispatch({
|
|
type: "CLOSE_WINDOW",
|
|
windowId: "equalizer",
|
|
});
|
|
|
|
export const enabledMilkdrop = (webamp: WebampCI): void =>
|
|
webamp.store.dispatch({
|
|
open: false,
|
|
type: "ENABLE_MILKDROP",
|
|
});
|
|
|
|
export const setSkinData = (webamp: WebampCI, data: SkinData): void =>
|
|
webamp.store.dispatch({
|
|
data,
|
|
type: "SET_SKIN_DATA",
|
|
});
|
|
|
|
const loadButterchurn = (webamp: WebampCI, butterchurn: unknown): void =>
|
|
webamp.store.dispatch({
|
|
butterchurn,
|
|
type: "GOT_BUTTERCHURN",
|
|
});
|
|
|
|
const loadButterchurnPresets = (
|
|
webamp: WebampCI,
|
|
presets: ButterChurnWebampPreset[]
|
|
): void =>
|
|
webamp.store.dispatch({
|
|
presets,
|
|
type: "GOT_BUTTERCHURN_PRESETS",
|
|
});
|
|
|
|
const getButterchurnPresetIndex = (webamp: WebampCI): number => {
|
|
const { presetHistory = [], presets = [] } =
|
|
webamp.store.getState()?.milkdrop || {};
|
|
const index = Math.floor(Math.random() * presets.length);
|
|
const preset = presets[index];
|
|
|
|
return preset.name &&
|
|
!BROKEN_PRESETS.has(preset.name) &&
|
|
!presetHistory.slice(-5).includes(index)
|
|
? index
|
|
: getButterchurnPresetIndex(webamp);
|
|
};
|
|
|
|
export const loadButterchurnPreset = (webamp: WebampCI): void => {
|
|
const index = getButterchurnPresetIndex(webamp);
|
|
|
|
webamp.store.dispatch({
|
|
addToHistory: true,
|
|
index,
|
|
type: "PRESET_REQUESTED",
|
|
});
|
|
webamp.store.dispatch({
|
|
index,
|
|
type: "SELECT_PRESET_AT_INDEX",
|
|
});
|
|
};
|
|
|
|
let cycleTimerId = 0;
|
|
|
|
const cycleButterchurnPresets = (webamp: WebampCI): void => {
|
|
window.clearInterval(cycleTimerId);
|
|
cycleTimerId = window.setInterval(() => {
|
|
if (!webamp) window.clearInterval(cycleTimerId);
|
|
|
|
loadButterchurnPreset(webamp);
|
|
}, 20000);
|
|
};
|
|
|
|
export const loadMilkdropWhenNeeded = (webamp: WebampCI): void => {
|
|
const unsubscribe = webamp.store.subscribe(() => {
|
|
const { milkdrop, windows } = webamp.store.getState();
|
|
|
|
if (windows?.genWindows?.milkdrop?.open && !milkdrop?.butterchurn) {
|
|
loadFiles(["/Program Files/Webamp/butterchurn.min.js"]).then(() => {
|
|
if (!window.butterchurn?.default) return;
|
|
|
|
loadButterchurn(webamp, window.butterchurn.default);
|
|
|
|
const { playlist, main: mainWindow } = windows.genWindows || {};
|
|
const { x = 0, y = 0 } =
|
|
(playlist?.open ? playlist?.position : mainWindow?.position) || {};
|
|
|
|
webamp.store.dispatch({
|
|
positions: {
|
|
milkdrop: {
|
|
x,
|
|
y: y + BASE_WINDOW_SIZE.height,
|
|
},
|
|
},
|
|
type: "UPDATE_WINDOW_POSITIONS",
|
|
});
|
|
|
|
unsubscribe();
|
|
|
|
webamp.store.subscribe(() => {
|
|
const webampDesktop = [...document.body.children].find((node) =>
|
|
node.classList?.contains("webamp-desktop")
|
|
);
|
|
|
|
if (webampDesktop) {
|
|
const main = document.querySelector("main");
|
|
|
|
if (main) {
|
|
[...main.children].forEach((node) => {
|
|
if (node.classList?.contains("webamp-desktop")) {
|
|
node.remove();
|
|
}
|
|
});
|
|
main.append(webampDesktop);
|
|
}
|
|
}
|
|
});
|
|
|
|
import("butterchurn-presets").then(({ default: presets }) => {
|
|
const resolvedPresets: ButterChurnWebampPreset[] = Object.entries(
|
|
presets as ButterChurnPresets
|
|
).map(([name, preset]) => ({
|
|
name,
|
|
preset,
|
|
}));
|
|
|
|
loadButterchurnPresets(webamp, resolvedPresets);
|
|
loadButterchurnPreset(webamp);
|
|
cycleButterchurnPresets(webamp);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
export const getWebampElement = (): HTMLDivElement | null =>
|
|
document.querySelector<HTMLDivElement>(CONTAINER_WINDOW);
|
|
|
|
export const updateWebampPosition = (
|
|
webamp: WebampCI,
|
|
position?: Position
|
|
): void => {
|
|
const { height, width } = BASE_WINDOW_SIZE;
|
|
const { x, y } = position || centerPosition({ height: height * 2, width });
|
|
|
|
webamp.store.dispatch({
|
|
positions: {
|
|
main: { x, y },
|
|
milkdrop: { x: 0 - width, y: 0 - height },
|
|
playlist: { x, y: height + y },
|
|
},
|
|
type: "UPDATE_WINDOW_POSITIONS",
|
|
});
|
|
};
|
|
|
|
export const focusWindow = (webamp: WebampCI, window: string): void =>
|
|
webamp.store.dispatch({
|
|
type: "SET_FOCUSED_WINDOW",
|
|
window,
|
|
});
|
|
|
|
export const unFocus = (webamp: WebampCI): void =>
|
|
webamp.store.dispatch({
|
|
type: "SET_FOCUSED_WINDOW",
|
|
window: "",
|
|
});
|
|
|
|
export const parseTrack = async (
|
|
file: Buffer,
|
|
fileName: string
|
|
): Promise<Track> => {
|
|
const { parseBuffer } = await import("music-metadata-browser");
|
|
const {
|
|
common: { album = "", artist = "", title = fileName },
|
|
format: { duration = 0 },
|
|
} = await parseBuffer(
|
|
file,
|
|
{
|
|
mimeType: MP3_MIME_TYPE,
|
|
size: file.length,
|
|
},
|
|
{ duration: true, skipCovers: true, skipPostHeaders: true }
|
|
);
|
|
|
|
return {
|
|
blob: bufferToBlob(file, "audio/mpeg"),
|
|
duration: Math.floor(duration),
|
|
metaData: { album, artist, title },
|
|
};
|
|
};
|
|
|
|
export const createM3uPlaylist = (tracks: URLTrack[]): string => {
|
|
const m3uPlaylist = tracks.map((track): string => {
|
|
const trackUrl = track.url ? `\n${track.url.toString()}` : "";
|
|
let title = track.defaultName;
|
|
|
|
if (track.metaData?.artist) {
|
|
if (track.metaData?.title) {
|
|
title = `${track.metaData.artist} - ${track.metaData.title}`;
|
|
} else if (title) {
|
|
title = `${track.metaData.artist} - ${title}`;
|
|
}
|
|
} else if (track.metaData?.title) {
|
|
title = track.metaData.title;
|
|
}
|
|
|
|
return trackUrl
|
|
? `#EXTINF:${track.duration ?? -1},${title || ""}${trackUrl}`
|
|
: "";
|
|
});
|
|
|
|
return `${["#EXTM3U", ...m3uPlaylist.filter(Boolean)].join("\n")}\n`;
|
|
};
|
|
|
|
const MAX_PLAYLIST_ITEMS = 1000;
|
|
|
|
export const tracksFromPlaylist = async (
|
|
data: string,
|
|
extension: string,
|
|
defaultName?: string
|
|
): Promise<Track[]> => {
|
|
const { ASX, M3U, PLS } = await import("playlist-parser");
|
|
const parser: Record<string, typeof ASX | typeof M3U | typeof PLS> = {
|
|
".asx": ASX,
|
|
".m3u": M3U,
|
|
".pls": PLS,
|
|
};
|
|
const tracks =
|
|
parser[extension]
|
|
?.parse(data)
|
|
.filter(Boolean)
|
|
.slice(0, MAX_PLAYLIST_ITEMS) ?? [];
|
|
|
|
return tracks.map(({ artist = "", file = "", length = 0, title = "" }) => {
|
|
const [parsedArtist, parsedTitle] = [artist.trim(), title.trim()];
|
|
|
|
return {
|
|
duration: length > 0 ? length : 0,
|
|
metaData: {
|
|
album: parsedTitle || defaultName,
|
|
artist: parsedArtist,
|
|
title: parsedTitle,
|
|
},
|
|
url: file,
|
|
};
|
|
});
|
|
};
|
|
|
|
type MetadataGetter = () => Promise<Track["metaData"]>;
|
|
|
|
type MetadataProvider = (url: string) => MetadataGetter;
|
|
|
|
const removeCData = (string = ""): string =>
|
|
string.replace(/<!\[CDATA\[|]]>/g, "");
|
|
|
|
const streamingMetadataProviders: Record<string, MetadataProvider> = {
|
|
"somafm.com": (url: string) => async () => {
|
|
const { pathname } = new URL(url);
|
|
const [channel] = pathname.replace("/", "").split("-");
|
|
const xmlPlaylist = await (
|
|
await fetch(`https://somafm.com/songs/${channel}.xml`, {
|
|
cache: "no-cache",
|
|
credentials: "omit",
|
|
keepalive: false,
|
|
mode: "cors",
|
|
referrerPolicy: "no-referrer",
|
|
// eslint-disable-next-line unicorn/no-null
|
|
window: null,
|
|
})
|
|
).text();
|
|
const songNode = new DOMParser()
|
|
.parseFromString(xmlPlaylist, "application/xml")
|
|
.querySelector("song");
|
|
const artist = songNode?.querySelector("artist")?.innerHTML;
|
|
const title = songNode?.querySelector("title")?.innerHTML;
|
|
|
|
return {
|
|
artist: removeCData(artist),
|
|
title: removeCData(title),
|
|
};
|
|
},
|
|
};
|
|
|
|
export const getMetadataProvider = (url: string): MetadataGetter | void => {
|
|
const { host } = new URL(url);
|
|
const [, domain, tld] = host.split(".");
|
|
|
|
return streamingMetadataProviders[`${domain}.${tld}`]?.(url);
|
|
};
|