SongSpotlight: Show off songs on your profile (#853)
* feat(SongSpotlight): port from vencord PR * feat(SongSpotlight): some slight code improvements * fix(components): match Link's props with vencord * fix(SongSpotlight): ditch React.memo & csp
This commit is contained in:
parent
33fcf774cc
commit
df890ac455
24 changed files with 2462 additions and 2 deletions
|
|
@ -42,6 +42,7 @@
|
|||
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@intrnl/xxhash64": "^0.1.2",
|
||||
"@song-spotlight/api": "^2.0.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@types/less": "^3.0.8",
|
||||
"@types/stylus": "^0.48.42",
|
||||
|
|
|
|||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -25,6 +25,9 @@ importers:
|
|||
'@intrnl/xxhash64':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@song-spotlight/api':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(typescript@5.9.3)
|
||||
'@streamparser/json':
|
||||
specifier: ^0.0.22
|
||||
version: 0.0.22
|
||||
|
|
@ -718,6 +721,11 @@ packages:
|
|||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
'@song-spotlight/api@2.0.0':
|
||||
resolution: {integrity: sha512-a3gyrSojd3BzwloxZHyDXA7HhjmCNQZOYATh7AZJyCXIqilBMwFPgQ2FVkUpzf6MzZhN9emeN6OGZOJb/rkECw==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
|
||||
'@streamparser/json@0.0.22':
|
||||
resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==}
|
||||
|
||||
|
|
@ -2650,6 +2658,9 @@ packages:
|
|||
zod@3.24.2:
|
||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.24.7':
|
||||
|
|
@ -2995,6 +3006,12 @@ snapshots:
|
|||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@song-spotlight/api@2.0.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@streamparser/json@0.0.22': {}
|
||||
|
||||
'@stylistic/eslint-plugin@5.6.0(eslint@9.39.1)':
|
||||
|
|
@ -5286,3 +5303,6 @@ snapshots:
|
|||
q: 1.5.1
|
||||
|
||||
zod@3.24.2: {}
|
||||
|
||||
zod@4.3.6:
|
||||
optional: true
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
import "./Link.css";
|
||||
|
||||
import { classes } from "@utils/misc";
|
||||
import type { AnchorHTMLAttributes, PropsWithChildren } from "react";
|
||||
import type { AnchorHTMLAttributes, DetailedHTMLProps, PropsWithChildren } from "react";
|
||||
|
||||
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
export interface LinkProps extends DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement> {
|
||||
disabled?: boolean;
|
||||
useDefaultUnderlineStyles?: boolean;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export function Link({
|
|||
children,
|
||||
...restProps
|
||||
}: PropsWithChildren<LinkProps>) {
|
||||
|
||||
const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href);
|
||||
const needsSafeAttrs = href && !isInternal;
|
||||
|
||||
|
|
|
|||
91
src/equicordplugins/songSpotlight.desktop/index.tsx
Normal file
91
src/equicordplugins/songSpotlight.desktop/index.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
import { useAuthorizationStore } from "./lib/stores/AuthorizationStore";
|
||||
import { useSongStore } from "./lib/stores/SongStore";
|
||||
import { clearCache } from "./service";
|
||||
import Settings from "./ui/settings";
|
||||
import ProfileSongs from "./ui/songs/ProfileSongs";
|
||||
import WidgetSongs from "./ui/songs/WidgetSongs";
|
||||
|
||||
export default definePlugin({
|
||||
name: "SongSpotlight",
|
||||
description: "Show off songs on your profile",
|
||||
authors: [EquicordDevs.nexpid],
|
||||
settings: definePluginSettings({
|
||||
manager: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <Settings />,
|
||||
},
|
||||
}),
|
||||
patches: [
|
||||
// Personal profile popout
|
||||
{
|
||||
find: ".WIDGETS_USER_PROFILE_ACCOUNT_POPOUT_NEW_BADGE]",
|
||||
replacement: {
|
||||
match: /user:(\i),bio:.{0,60}}\)/,
|
||||
replace: "$&,$self.renderProfileSongs({userId:$1.id})",
|
||||
},
|
||||
},
|
||||
// Message user popout
|
||||
{
|
||||
find: ".isProvisional?(",
|
||||
replacement: {
|
||||
match: /user:(\i),bio:.{0,60}}\)/,
|
||||
replace: "$&,$self.renderProfileSongs({userId:$1.id})",
|
||||
},
|
||||
},
|
||||
// DM sidebar profile
|
||||
{
|
||||
find: ".SIDEBAR}),nicknameIcons:",
|
||||
replacement: {
|
||||
match: /{userId:(\i)\.id}\)}\)]}\)/,
|
||||
replace: "$&,$self.renderProfileSongs({userId:$1.id})",
|
||||
},
|
||||
},
|
||||
// Full profile modal sections (lazy loaded)
|
||||
{
|
||||
find: ".MUTUAL_GUILDS})),",
|
||||
replacement: {
|
||||
match: /(\i).push\({text.{0,50}}\);/,
|
||||
replace: "$&$1.push({text:\"Song Spotlight\",section:\"SONG_SPOTLIGHT\"});",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: ".hasUnsavedChanges()&&",
|
||||
replacement: {
|
||||
match: /({user:(\i),.{0,80}return (\i===))/,
|
||||
replace: "$1\"SONG_SPOTLIGHT\"?$self.renderWidgetSongs({user:$2}):$3",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
flux: {
|
||||
CONNECTION_OPEN: () => {
|
||||
useSongStore.getState().$refresh();
|
||||
},
|
||||
},
|
||||
start() {
|
||||
// the cache lives in native.ts so it persists between reloads and
|
||||
// only gets cleared on full restart. we don't want that since
|
||||
// audio preview URLs expire very fast, so we just clear it on
|
||||
// plugin restart instead
|
||||
clearCache();
|
||||
|
||||
useSongStore.getState().$refresh();
|
||||
useAuthorizationStore.persist.rehydrate();
|
||||
},
|
||||
|
||||
renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }),
|
||||
renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }),
|
||||
});
|
||||
136
src/equicordplugins/songSpotlight.desktop/lib/api.ts
Normal file
136
src/equicordplugins/songSpotlight.desktop/lib/api.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { UserData } from "@song-spotlight/api/structs";
|
||||
import { showToast, Toasts, UserStore } from "@webpack/common";
|
||||
|
||||
import { useAuthorizationStore } from "./stores/AuthorizationStore";
|
||||
import { useSongStore } from "./stores/SongStore";
|
||||
import { logger } from "./utils";
|
||||
|
||||
export const apiConstants = {
|
||||
api: "https://dc.songspotlight.nexpid.xyz/",
|
||||
oauth2: {
|
||||
clientId: "1157745434140344321",
|
||||
redirectURL: "https://dc.songspotlight.nexpid.xyz/api/auth/authorize",
|
||||
},
|
||||
songLimit: 6,
|
||||
};
|
||||
|
||||
export async function authFetch(_url: string | URL, options?: RequestInit) {
|
||||
const url = new URL(_url);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Authorization: useAuthorizationStore.getState().getToken()!,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) return res;
|
||||
|
||||
// not modified
|
||||
if (res.status === 304) return null;
|
||||
|
||||
// unauthorized
|
||||
if (res.status === 401) {
|
||||
useAuthorizationStore.getState().deleteToken();
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
showToast(
|
||||
!text.includes("<body>") && res.status >= 400 && res.status <= 599
|
||||
? `Song Spotlight: ${text}`
|
||||
: `Song Spotlight fetch error at ${url.pathname}`,
|
||||
Toasts.Type.FAILURE,
|
||||
);
|
||||
|
||||
logger.error(
|
||||
"Got an authFetch response error",
|
||||
options?.method ?? "GET",
|
||||
url.toString(),
|
||||
res.status,
|
||||
text,
|
||||
);
|
||||
throw new Error(text);
|
||||
} catch (error) {
|
||||
showToast(`Song Spotlight: ${error}`, Toasts.Type.FAILURE);
|
||||
|
||||
logger.error(
|
||||
"Got an authFetch request error",
|
||||
options?.method ?? "GET",
|
||||
url.toString(),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getData(): Promise<UserData | undefined> {
|
||||
return await authFetch(new URL("api/data", apiConstants.api), {
|
||||
headers: {
|
||||
"If-Modified-Since": useSongStore.getState().self?.at,
|
||||
} as HeadersInit,
|
||||
}).then(async res => {
|
||||
if (!res) return useSongStore.getState().self?.data;
|
||||
|
||||
const data = await res.json();
|
||||
useSongStore.getState().update({
|
||||
data,
|
||||
at: res.headers.get("Last-Modified") || undefined,
|
||||
});
|
||||
return data;
|
||||
});
|
||||
}
|
||||
export async function listData(userId: string): Promise<UserData | undefined> {
|
||||
if (userId === UserStore.getCurrentUser()?.id) return await getData();
|
||||
|
||||
return await authFetch(new URL(`api/data/${userId}`, apiConstants.api), {
|
||||
headers: {
|
||||
"If-Modified-Since": useSongStore.getState().users[userId]?.at,
|
||||
} as HeadersInit,
|
||||
}).then(async res => {
|
||||
if (!res) return useSongStore.getState().users[userId]?.data;
|
||||
|
||||
const data = await res.json();
|
||||
useSongStore.getState().update({
|
||||
userId,
|
||||
data,
|
||||
at: res.headers.get("Last-Modified") || undefined,
|
||||
});
|
||||
return data;
|
||||
});
|
||||
}
|
||||
export async function saveData(data: UserData): Promise<true> {
|
||||
return await authFetch(new URL("api/data", apiConstants.api), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(res => res?.json())
|
||||
.then(json => {
|
||||
useSongStore
|
||||
.getState().update({
|
||||
data,
|
||||
at: new Date().toUTCString(),
|
||||
});
|
||||
return json;
|
||||
});
|
||||
}
|
||||
export async function deleteData(): Promise<true> {
|
||||
return await authFetch(new URL("api/data", apiConstants.api), {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(res => res?.json())
|
||||
.then(json => {
|
||||
useSongStore.getState().delete();
|
||||
return json;
|
||||
});
|
||||
}
|
||||
46
src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx
Normal file
46
src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { openModal } from "@utils/modal";
|
||||
import { ApplicationIntegrationType } from "@vencord/discord-types/enums";
|
||||
import { OAuth2AuthorizeModal, showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import { apiConstants, authFetch, getData } from "./api";
|
||||
import { useAuthorizationStore } from "./stores/AuthorizationStore";
|
||||
import { logger } from "./utils";
|
||||
|
||||
export function presentOAuth2Modal() {
|
||||
openModal(props => (
|
||||
<OAuth2AuthorizeModal
|
||||
{...props}
|
||||
clientId={apiConstants.oauth2.clientId}
|
||||
scopes={["applications.commands", "identify"]}
|
||||
integrationType={ApplicationIntegrationType.USER_INSTALL}
|
||||
permissions={0n}
|
||||
responseType="code"
|
||||
redirectUri={apiConstants.oauth2.redirectURL}
|
||||
cancelCompletesFlow={false}
|
||||
callback={async ({ location }) => {
|
||||
if (!location) return;
|
||||
|
||||
try {
|
||||
const url = new URL(location);
|
||||
url.searchParams.append("whois", "equicord");
|
||||
|
||||
const token = await (await authFetch(url))?.text();
|
||||
if (!token) return;
|
||||
|
||||
useAuthorizationStore.getState().setToken(token);
|
||||
getData();
|
||||
|
||||
showToast("Successfully authorized!", Toasts.Type.SUCCESS);
|
||||
} catch (error) {
|
||||
logger.error("Got an error during OAuth2", error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { DataStore } from "@api/index";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { UserStore, zustandCreate, zustandPersist } from "@webpack/common";
|
||||
|
||||
import { PersistedZustandStore, ZustandDefinition } from "./zustand";
|
||||
|
||||
interface AuthorizationState {
|
||||
tokens: Record<string, string>;
|
||||
getToken(): string | undefined;
|
||||
setToken(token: string): void;
|
||||
deleteToken(): void;
|
||||
isAuthorized(): boolean;
|
||||
}
|
||||
|
||||
export const useAuthorizationStore: PersistedZustandStore<AuthorizationState> = proxyLazy(() =>
|
||||
zustandCreate(
|
||||
zustandPersist(
|
||||
((set, get) => ({
|
||||
tokens: {},
|
||||
getToken() {
|
||||
return get().tokens[UserStore.getCurrentUser()?.id];
|
||||
},
|
||||
setToken(token) {
|
||||
const userId = UserStore.getCurrentUser()?.id;
|
||||
if (userId) {
|
||||
set({
|
||||
tokens: Object.assign(get().tokens, {
|
||||
[userId]: token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
deleteToken() {
|
||||
set({ tokens: {} });
|
||||
},
|
||||
isAuthorized() {
|
||||
return !!get().getToken();
|
||||
},
|
||||
})) as ZustandDefinition<AuthorizationState>,
|
||||
{
|
||||
name: "songspotlight-auth",
|
||||
storage: {
|
||||
async getItem(name: string) {
|
||||
return (await DataStore.get(name)) ?? null;
|
||||
},
|
||||
async setItem(name: string, value: string) {
|
||||
return await DataStore.set(name, value);
|
||||
},
|
||||
async removeItem(name: string) {
|
||||
return await DataStore.del(name);
|
||||
},
|
||||
},
|
||||
partialize: ({ tokens }) => ({ tokens }),
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { UserData } from "@song-spotlight/api/structs";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { UserStore, zustandCreate } from "@webpack/common";
|
||||
|
||||
import { ZustandDefinition, ZustandStore } from "./zustand";
|
||||
|
||||
interface Data {
|
||||
data: UserData;
|
||||
at?: string;
|
||||
}
|
||||
|
||||
interface SongState {
|
||||
users: Record<string, Data>;
|
||||
self?: Data;
|
||||
update(props: {
|
||||
userId?: string;
|
||||
data: UserData;
|
||||
at?: string;
|
||||
}): void;
|
||||
delete(userId?: string): void;
|
||||
$refresh(): void;
|
||||
}
|
||||
|
||||
export const useSongStore: ZustandStore<SongState> = proxyLazy(() =>
|
||||
zustandCreate(
|
||||
((set, get) => ({
|
||||
users: {},
|
||||
update({ userId, data, at }) {
|
||||
userId ??= UserStore.getCurrentUser()?.id;
|
||||
if (userId) {
|
||||
set({
|
||||
users: {
|
||||
...get().users,
|
||||
[userId]: { data, at },
|
||||
},
|
||||
});
|
||||
}
|
||||
get().$refresh();
|
||||
},
|
||||
delete(userId) {
|
||||
userId ??= UserStore.getCurrentUser()?.id;
|
||||
if (userId) {
|
||||
const { [userId]: _, ...users } = get().users;
|
||||
set({ users });
|
||||
}
|
||||
get().$refresh();
|
||||
},
|
||||
$refresh() {
|
||||
set({
|
||||
self: get().users[UserStore.getCurrentUser()?.id],
|
||||
});
|
||||
},
|
||||
})) as ZustandDefinition<SongState>,
|
||||
)
|
||||
);
|
||||
18
src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts
vendored
Normal file
18
src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type ZustandDefinition<T> = (set: (state: Partial<T>) => void, get: () => T) => T;
|
||||
|
||||
export interface ZustandStore<T> {
|
||||
(): T;
|
||||
getState(): T;
|
||||
}
|
||||
|
||||
export interface PersistedZustandStore<T> extends ZustandStore<T> {
|
||||
persist: {
|
||||
rehydrate(): Promise<void>;
|
||||
};
|
||||
}
|
||||
29
src/equicordplugins/songSpotlight.desktop/lib/utils.ts
Normal file
29
src/equicordplugins/songSpotlight.desktop/lib/utils.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { RenderSongInfo } from "@song-spotlight/api/handlers";
|
||||
import type { Song } from "@song-spotlight/api/structs";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
export const logger = new Logger("SongSpotlight");
|
||||
|
||||
export const cl = classNameFactory("vc-songspotlight-");
|
||||
|
||||
export function formatDurationMs(duration: number) {
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatCoverTooltip(song: Song, render: RenderSongInfo) {
|
||||
if (["user", "artist"].includes(song.type)) {
|
||||
// "Jane Remover's Top tracks"
|
||||
return `${render.label}'s ${render.sublabel}`;
|
||||
} else {
|
||||
// "Star people by Jane Remover"
|
||||
return `${render.label} by ${render.sublabel}`;
|
||||
}
|
||||
}
|
||||
26
src/equicordplugins/songSpotlight.desktop/native.ts
Normal file
26
src/equicordplugins/songSpotlight.desktop/native.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import * as handlers from "@song-spotlight/api/handlers";
|
||||
import type { Song } from "@song-spotlight/api/structs";
|
||||
import { setFetchHandler } from "@song-spotlight/api/util";
|
||||
import { type IpcMainInvokeEvent, net } from "electron";
|
||||
|
||||
setFetchHandler(net.fetch as unknown as typeof fetch);
|
||||
|
||||
export async function parseLink(_: IpcMainInvokeEvent, link: string) {
|
||||
return handlers.parseLink(link);
|
||||
}
|
||||
export async function renderSong(_: IpcMainInvokeEvent, song: Song) {
|
||||
return handlers.renderSong(song);
|
||||
}
|
||||
export async function validateSong(_: IpcMainInvokeEvent, song: Song) {
|
||||
return handlers.validateSong(song);
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
return handlers.clearCache();
|
||||
}
|
||||
29
src/equicordplugins/songSpotlight.desktop/service.ts
Normal file
29
src/equicordplugins/songSpotlight.desktop/service.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { RenderSongInfo } from "@song-spotlight/api/handlers";
|
||||
import { Song } from "@song-spotlight/api/structs";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
import { PluginNative } from "@utils/types";
|
||||
import { useEffect, useState } from "@webpack/common";
|
||||
|
||||
export function useRender(song: Song) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [render, setRender] = useState<RenderSongInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
setRender(null);
|
||||
renderSong(song)
|
||||
.catch(() => null)
|
||||
.then(info => info ? setRender(info) : setFailed(true));
|
||||
}, [sid(song)]);
|
||||
|
||||
return { failed, render };
|
||||
}
|
||||
|
||||
export const { parseLink, renderSong, validateSong, clearCache } = VencordNative.pluginHelpers
|
||||
.SongSpotlight as PluginNative<typeof import("./native")>;
|
||||
367
src/equicordplugins/songSpotlight.desktop/style.css
Normal file
367
src/equicordplugins/songSpotlight.desktop/style.css
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
.vc-songspotlight-mono {
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
|
||||
.vc-songspotlight-icon {
|
||||
color: var(--interactive-icon-default);
|
||||
}
|
||||
|
||||
.vc-songspotlight-sub {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vc-songspotlight-errored {
|
||||
color: var(--text-feedback-critical);
|
||||
}
|
||||
|
||||
.vc-songspotlight-header {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.vc-songspotlight-clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* components */
|
||||
.vc-songspotlight-icon-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--opacity-20);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 64px;
|
||||
transition-property: opacity, background;
|
||||
transition-duration: 0.18s;
|
||||
|
||||
&:disabled {
|
||||
cursor: unset;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&[data-toggled="true"] {
|
||||
background: var(--opacity-28);
|
||||
}
|
||||
|
||||
&:enabled:hover {
|
||||
background: var(--opacity-32);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-icon-spinner {
|
||||
> span {
|
||||
gap: 2px;
|
||||
|
||||
> span {
|
||||
width: 5px !important;
|
||||
height: 5px !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-explicit-tag {
|
||||
background: var(--opacity-40);
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* settings */
|
||||
.vc-songspotlight-editable-song-container {
|
||||
position: relative;
|
||||
|
||||
&[data-dragging="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&[data-insert="top"]::before,
|
||||
&[data-insert="bottom"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--green-360);
|
||||
inset-inline: -8px 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&[data-insert="top"]::before {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
&[data-insert="bottom"]::after {
|
||||
bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-editable-song {
|
||||
background: var(--background-surface-higher);
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: 0.18s background;
|
||||
isolation: isolate;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-surface-highest);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-add-song {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.vc-songspotlight-empty-song {
|
||||
background: var(--background-base-low);
|
||||
height: 30px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.vc-songspotlight-twin-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* popout */
|
||||
.vc-songspotlight-songs-container {
|
||||
--base-height: 68px;
|
||||
--list-height: calc(var(--base-height) + 80px);
|
||||
--number-size: 32px;
|
||||
|
||||
padding: 8px;
|
||||
gap: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.user-profile-sidebar .vc-songspotlight-songs-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.vc-songspotlight-songs-more {
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* widget */
|
||||
.vc-songspotlight-widget-container {
|
||||
--base-height: 88px;
|
||||
--list-height: calc(var(--base-height) + 120px);
|
||||
--number-size: 38px;
|
||||
}
|
||||
|
||||
/* somg sponglight */
|
||||
.vc-songspotlight-song-container {
|
||||
--spread: 50px;
|
||||
--time: 0.2s;
|
||||
|
||||
position: relative;
|
||||
height: var(--base-height);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.vc-songspotlight-list-song-container {
|
||||
height: var(--list-height);
|
||||
}
|
||||
|
||||
&::after {
|
||||
--anim-duration: 1.8s;
|
||||
--anim-offset: mod(calc(0.15s * var(--index)), var(--anim-duration));
|
||||
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(90deg,
|
||||
var(--opacity-12) 0,
|
||||
var(--opacity-32) var(--spread),
|
||||
var(--opacity-12) calc(var(--spread) * 2));
|
||||
background-size: 600% 200%;
|
||||
animation: songspotlight-skeleton-shine var(--anim-duration) var(--anim-offset) infinite linear backwards;
|
||||
pointer-events: none;
|
||||
transition: var(--time) opacity;
|
||||
}
|
||||
|
||||
&:has(> .vc-songspotlight-song)::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.user-profile-popout:not(.custom-user-profile-theme) .vc-songspotlight-song-container {
|
||||
background: var(--opacity-16);
|
||||
}
|
||||
|
||||
.vc-songspotlight-song {
|
||||
height: 100%;
|
||||
animation: songspotlight-fade-in var(--time) linear forwards;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* song grid */
|
||||
.vc-songspotlight-song-grid {
|
||||
height: var(--base-height);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* song core */
|
||||
.vc-songspotlight-song-core {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
border-radius: inherit;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vc-songspotlight-song-cover {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: inherit;
|
||||
background: var(--background-mod-muted);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 3px 12px 0 rgb(0 0 0 / 16%);
|
||||
transition: 0.18s transform;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
> a {
|
||||
border-radius: inherit;
|
||||
|
||||
> img {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
transition: 0.18s background;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
/* song service */
|
||||
.vc-songspotlight-song-service {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
/* song player */
|
||||
.vc-songspotlight-song-player {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
transition: 0.18s opacity;
|
||||
|
||||
&[data-idle="true"] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-song:hover .vc-songspotlight-song-player {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-songspotlight-song-progress-container {
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.vc-songspotlight-song-progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
|
||||
> circle {
|
||||
will-change: stroke-dashoffset;
|
||||
|
||||
&[data-empty="true"] {
|
||||
transition: 0.18s stroke-dashoffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-song-divider {
|
||||
width: 100%;
|
||||
background: var(--user-profile-border);
|
||||
height: 1px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* song list */
|
||||
.vc-songspotlight-song-list {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* song entry */
|
||||
.vc-songspotlight-song-entry {
|
||||
padding-right: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition-property: opacity, background, transform;
|
||||
transition-duration: 0.18s;
|
||||
cursor: pointer;
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
cursor: unset;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&[aria-disabled="false"][data-playing="true"] {
|
||||
background: var(--opacity-12);
|
||||
}
|
||||
|
||||
&[aria-disabled="false"]:hover {
|
||||
background: var(--opacity-20);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-songspotlight-song-entry-number {
|
||||
width: var(--number-size);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes songspotlight-skeleton-shine {
|
||||
0% {
|
||||
background-position: calc(var(--spread) * -2) 0;
|
||||
}
|
||||
|
||||
30%,
|
||||
100% {
|
||||
background-position: calc(500px + var(--spread) * 2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes songspotlight-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
224
src/equicordplugins/songSpotlight.desktop/ui/common.tsx
Normal file
224
src/equicordplugins/songSpotlight.desktop/ui/common.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/index";
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { classes } from "@utils/index";
|
||||
import { findByCodeLazy, findCssClassesLazy } from "@webpack";
|
||||
import { Tooltip } from "@webpack/common";
|
||||
import { ComponentType, HTMLAttributes, JSX } from "react";
|
||||
|
||||
export const OverlayClasses = findCssClassesLazy("overlay", "inner", "outer");
|
||||
export const CardClasses = findCssClassesLazy("card", "headerText", "headerContextMenu");
|
||||
export const WidgetClasses = findCssClassesLazy("tabPanelScroller", "tabListScroller");
|
||||
|
||||
export enum SpinnerTypes {
|
||||
CHASING_DOTS = "chasingDots",
|
||||
LOW_MOTION = "lowMotion",
|
||||
PULSING_ELLIPSIS = "pulsingEllipsis",
|
||||
SPINNING_CIRCLE = "spinningCircle",
|
||||
SPINNING_CIRCLE_SIMPLE = "spinningCircleSimple",
|
||||
WANDERING_CUBES = "wanderingCubes",
|
||||
}
|
||||
|
||||
type Spinner =
|
||||
& ComponentType<
|
||||
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||
type?: SpinnerTypes;
|
||||
}
|
||||
>
|
||||
& {
|
||||
Type: typeof SpinnerTypes;
|
||||
};
|
||||
|
||||
export const Spinner = findByCodeLazy('"pulsingEllipsis"') as unknown as Spinner;
|
||||
|
||||
// basic components
|
||||
export function ExplicitTag({ className }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<Tooltip text="Explicit">
|
||||
{tooltipProps => (
|
||||
<BaseText
|
||||
{...tooltipProps}
|
||||
size="xs"
|
||||
weight="medium"
|
||||
className={classes(cl("explicit-tag"), className)}
|
||||
>
|
||||
E
|
||||
</BaseText>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
interface PlayButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
state: boolean;
|
||||
disabled?: boolean;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
export function PlayButton({ state, disabled, ...props }: PlayButtonProps) {
|
||||
const Icon = state ? PauseIcon : PlayIcon;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
data-toggled={state}
|
||||
className={classes(cl("icon-button"), props.className)}
|
||||
>
|
||||
{disabled
|
||||
? <Spinner type={Spinner.Type.PULSING_ELLIPSIS} className={cl("icon-spinner")} />
|
||||
: <Icon width={16} height={16} className={cl("icon")} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// icons
|
||||
export function AppleMusicIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M19.7 1C21.5 1 23 2.5 23 4.3v15.4c0 1.8-1.5 3.3-3.3 3.3H4.3A3.3 3.3 0 0 1 1 19.7V4.3C1 2.5 2.5 1 4.3 1h15.4Zm-2.4 3.8s0-.9-.7-.7L9.3 5.6s-.6.1-.6.8v8.7c0 .5-.7.8-2 .9-2.4.4-2 4.5 1.3 3.4 1.3-.5 1.6-1.7 1.6-3V9.6c0-.3 0-.5.4-.5l6-1.2c.2 0 .4 0 .4.4v5.2c0 .5-.6.8-2 1-2.4.3-2 4.4 1.4 3.3 1.3-.5 1.5-1.7 1.5-3v-10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function ImageBrokenIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M5 2a3 3 0 0 0-3 3v4.7l3.33 3.33 3.39-3.38a1.5 1.5 0 0 1 2.12 0l3.38 3.38 3.39-3.38a1.5 1.5 0 0 1 2.12 0L22 11.92V5a3 3 0 0 0-3-3H5Z"
|
||||
/>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="m22 14.75-3.33-3.34-3.39 3.39a1.5 1.5 0 0 1-2.12 0L9.78 11.4 6.39 14.8a1.5 1.5 0 0 1-2.12 0L2 12.53V19a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4.25Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function MoreHorizontalIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M4 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm8 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function PauseIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M6 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H6ZM15 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function PlayIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M9.25 3.35C7.87 2.45 6 3.38 6 4.96v14.08c0 1.58 1.87 2.5 3.25 1.61l10.85-7.04a1.9 1.9 0 0 0 0-3.22L9.25 3.35Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function PuzzlePieceIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M16 4a3 3 0 1 1-5.98-.31c.03-.35-.21-.69-.56-.69H7a3 3 0 0 0-3 3v2.5c0 .28-.23.5-.5.54a3 3 0 0 0 0 5.92c.27.04.5.26.5.54V18a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3h-2.46c-.35 0-.6.34-.56.69L16 4Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function SoundcloudIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M1 15.8q0 .4.3.7t.6.1q.3 0 .5-.2l.1-.6V13a.7.7 0 0 0-.2-.5.7.7 0 0 0-.6-.2.7.7 0 0 0-.5.2.7.7 0 0 0-.2.5zm2.4 1.3q0 .3.2.4a1 1 0 0 0 .5.2l.6-.2q.2-.1.2-.4v-6.8a.7.7 0 0 0-.3-.5.7.7 0 0 0-.5-.2.7.7 0 0 0-.5.2.7.7 0 0 0-.2.5zm2.3.3q0 .3.2.5a1 1 0 0 0 .6.1 1 1 0 0 0 .5-.1q.2-.2.2-.5v-6.2a.7.7 0 0 0-.2-.5.7.7 0 0 0-.5-.2.8.8 0 0 0-.6.2.7.7 0 0 0-.2.5zm2.4 0q0 .6.7.6.8 0 .8-.6v-10q0-.9-.6-1l-.6.2Q8 7 8 7.4zm2.3.3v-11q0-.4.4-.5a6 6 0 0 1 4.2.5q1.4.7 2.2 2t1 2.8q.6-.3 1.3-.3a3.4 3.4 0 0 1 2.5 1q1 1 1 2.4T22 17a3.4 3.4 0 0 1-2.5 1h-8.9a.2.2 0 0 1-.1-.1.3.3 0 0 1 0-.2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function SpotifyIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M12 1a11 11 0 1 0 0 22 11 11 0 0 0 0-22Zm4.8 15.8a.7.7 0 0 1-1 .2c-2.5-1.6-5.8-2-9.6-1a.7.7 0 1 1-.3-1.4A14 14 0 0 1 16.6 16c.3.2.4.6.2.9Zm1.3-3a.9.9 0 0 1-1.1.3c-3-1.8-7.5-2.3-11-1.3a.9.9 0 0 1-.5-1.6c4-1.2 9-.6 12.4 1.4.4.3.5.8.2 1.2Zm1.5-3.5a1 1 0 0 1-1.4.4A18.1 18.1 0 0 0 5.6 9.4a1 1 0 0 1-.6-2C8.8 6.4 15.2 6.6 19.3 9c.5.3.6 1 .4 1.4Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function TrashIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z"
|
||||
/>
|
||||
<path
|
||||
fill={props.fill || "currentColor"}
|
||||
d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z"
|
||||
clipRule="evenodd"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { logger } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { RenderInfoEntry } from "@song-spotlight/api/handlers";
|
||||
import { showToast, Toasts, useCallback, useEffect, useMemo, useRef } from "@webpack/common";
|
||||
import { RefObject } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioRef: RefObject<HTMLAudioElement | undefined>;
|
||||
list: RenderInfoEntry[];
|
||||
playing: number | false;
|
||||
setPlaying(playing: number | false): void;
|
||||
setLoaded(index: number, state: boolean): void;
|
||||
}
|
||||
|
||||
const DEFAULT_VOLUME = 0.35;
|
||||
|
||||
// only allow one song to play at a time
|
||||
let globalPlaying: HTMLAudioElement | undefined = undefined;
|
||||
|
||||
export default function AudioPlayer({ audioRef, list, playing, setPlaying, setLoaded }: AudioPlayerProps) {
|
||||
const urls = useMemo(() => list.map(x => x.audio?.previewUrl), [list]);
|
||||
const audios = useRef(new Map<number, HTMLAudioElement>());
|
||||
const loaded = useRef(new Set<number>());
|
||||
|
||||
useEffect(() => {
|
||||
if (playing !== false) {
|
||||
const audio = audios.current.get(playing);
|
||||
if (audio && loaded.current.has(playing)) {
|
||||
if (globalPlaying) globalPlaying.pause();
|
||||
|
||||
audio.currentTime = 0;
|
||||
audio.volume = DEFAULT_VOLUME;
|
||||
audio.play().catch(error => {
|
||||
showToast("Failed to play song preview!", Toasts.Type.FAILURE);
|
||||
logger.error("Failed to play audio", error);
|
||||
setPlaying(false);
|
||||
});
|
||||
|
||||
globalPlaying = audio;
|
||||
} else {
|
||||
setPlaying(false);
|
||||
}
|
||||
} else {
|
||||
for (const audio of audios.current.values()) {
|
||||
if (globalPlaying === audio) audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
for (const audio of audios.current.values()) {
|
||||
if (audio !== globalPlaying && !audio.paused) {
|
||||
audio.pause();
|
||||
} else if (audio === globalPlaying && audio.paused) {
|
||||
globalPlaying = undefined;
|
||||
audioRef.current = undefined;
|
||||
} else if (audio === globalPlaying) {
|
||||
audioRef.current = audio;
|
||||
}
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
const handleRef = useCallback((index: number, audio: HTMLAudioElement | null) => {
|
||||
if (audio) audios.current.set(index, audio);
|
||||
else audios.current.delete(index);
|
||||
}, []);
|
||||
|
||||
const handleLoaded = useCallback((index: number) => {
|
||||
loaded.current.add(index);
|
||||
setLoaded(index, true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleErrored = useCallback((index: number) => {
|
||||
loaded.current.delete(index);
|
||||
setLoaded(index, false);
|
||||
}, [setLoaded]);
|
||||
|
||||
// onPaused runs before onEnded
|
||||
const justPaused = useRef<number>(undefined);
|
||||
|
||||
const handleEnded = useCallback((index: number) => {
|
||||
if (justPaused.current !== index && playing !== index) return;
|
||||
|
||||
const nextIndex = urls.findIndex((url, j) => url && j > index);
|
||||
setPlaying(nextIndex !== -1 ? nextIndex : false);
|
||||
}, [playing, setPlaying]);
|
||||
|
||||
const handlePaused = useCallback((index: number) => {
|
||||
if (playing === index) {
|
||||
justPaused.current = index;
|
||||
setPlaying(false);
|
||||
}
|
||||
}, [playing, setPlaying]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "none" }} aria-hidden="true">
|
||||
{urls.map((url, i) =>
|
||||
url && (
|
||||
<audio
|
||||
key={url}
|
||||
src={url}
|
||||
preload="metadata"
|
||||
ref={audio => handleRef(i, audio)}
|
||||
onLoadedData={() => handleLoaded(i)}
|
||||
onError={() => handleErrored(i)}
|
||||
onEnded={() => handleEnded(i)}
|
||||
onPause={() => handlePaused(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "@webpack/common";
|
||||
import { JSX, RefObject } from "react";
|
||||
|
||||
interface ProgressCircleProps extends SvgProps {
|
||||
border: number;
|
||||
audioRef: RefObject<HTMLAudioElement | undefined>;
|
||||
}
|
||||
type SvgProps = JSX.IntrinsicElements["svg"];
|
||||
|
||||
export default function ProgressCircle({ border, audioRef, ...props }: ProgressCircleProps) {
|
||||
const { radius, stroke, circumference } = useMemo(() => {
|
||||
const radius = 50 - border * 2;
|
||||
return {
|
||||
radius,
|
||||
stroke: border * 2,
|
||||
circumference: Math.PI * 2 * radius,
|
||||
};
|
||||
}, [border]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let handle = requestAnimationFrame(function update() {
|
||||
const audio = audioRef.current;
|
||||
if (audio && !Number.isNaN(audio.duration) && !audio.paused) {
|
||||
setProgress(audio.currentTime / audio.duration);
|
||||
} else {
|
||||
setProgress(0);
|
||||
}
|
||||
|
||||
handle = requestAnimationFrame(update);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(handle);
|
||||
}, [audioRef]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
cx={50}
|
||||
cy={50}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - progress)}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 50 50)"
|
||||
data-empty={progress === 0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { AppleMusicIcon, SoundcloudIcon, SpotifyIcon } from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import { getServiceLabel } from "@song-spotlight/api/util";
|
||||
import { Tooltip, useMemo } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
const serviceIcons = {
|
||||
applemusic: AppleMusicIcon,
|
||||
soundcloud: SoundcloudIcon,
|
||||
spotify: SpotifyIcon,
|
||||
};
|
||||
|
||||
interface ServiceIconProps extends SvgProps {
|
||||
service: string;
|
||||
}
|
||||
type SvgProps = JSX.IntrinsicElements["svg"];
|
||||
|
||||
export default function ServiceIcon({ service, width, height, ...props }: ServiceIconProps) {
|
||||
const Icon: typeof SpotifyIcon = useMemo(() => serviceIcons[service], [service]);
|
||||
const label = useMemo(() => getServiceLabel(service), [service]);
|
||||
|
||||
return Icon && label && (
|
||||
<Tooltip text={label}>
|
||||
{tooltipProps => (
|
||||
<Icon
|
||||
{...tooltipProps}
|
||||
{...props}
|
||||
className={cl("icon")}
|
||||
width={width ?? 20}
|
||||
height={height ?? 20}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Button } from "@components/Button";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { PlusIcon } from "@components/Icons";
|
||||
import { cl, logger } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { parseLink } from "@equicordplugins/songSpotlight.desktop/service";
|
||||
import { parsers } from "@song-spotlight/api/handlers";
|
||||
import { Song } from "@song-spotlight/api/structs";
|
||||
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||
import { Clickable, TextInput, useState } from "@webpack/common";
|
||||
|
||||
interface AddSongModalProps {
|
||||
modalProps: ModalProps;
|
||||
close(): void;
|
||||
onAdd(song: Song): string | undefined;
|
||||
}
|
||||
|
||||
function AddSongModal({ modalProps, close, onAdd }: AddSongModalProps) {
|
||||
const [url, setURL] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ModalRoot {...modalProps}>
|
||||
<ModalHeader>
|
||||
<BaseText size="xl" weight="bold">Add a new song</BaseText>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Flex flexDirection="column" gap="10px">
|
||||
<BaseText size="md" weight="normal" className={cl("sub")}>
|
||||
Song Spotlight supports these services: <b>{parsers.map(x => x.label).join(", ")}</b>
|
||||
</BaseText>
|
||||
<TextInput
|
||||
placeholder="https://open.spotify.com/..."
|
||||
error={error}
|
||||
onChange={value => {
|
||||
setURL(value);
|
||||
try {
|
||||
if (value) new URL(value);
|
||||
setError(undefined);
|
||||
} catch {
|
||||
setError("Invalid URL");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
<ModalFooter>
|
||||
<Flex justifyContent="flex-end" gap={0}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const parsed = await parseLink(url);
|
||||
if (!parsed) {
|
||||
setError("Invalid link");
|
||||
return setPending(false);
|
||||
}
|
||||
|
||||
const result = onAdd(parsed);
|
||||
if (result) {
|
||||
setError(result);
|
||||
return setPending(false);
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
logger.error("parseLink error", error);
|
||||
|
||||
setError("Failed to parse link");
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
disabled={!url || !!error || pending}
|
||||
>
|
||||
Add song
|
||||
</Button>
|
||||
</Flex>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddSongProps {
|
||||
onAdd(song: Song): string | undefined;
|
||||
}
|
||||
|
||||
export default function AddSong({ onAdd }: AddSongProps) {
|
||||
return (
|
||||
<Clickable
|
||||
onClick={() => {
|
||||
const key = openModal(modalProps => (
|
||||
<AddSongModal
|
||||
modalProps={modalProps}
|
||||
close={() => closeModal(key)}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
));
|
||||
}}
|
||||
>
|
||||
<Flex alignItems="center" gap="12px" className={cl("editable-song", "add-song")}>
|
||||
<PlusIcon width={28} height={28} className={cl("icon")} />
|
||||
<Flex flexDirection="column" justifyContent="center" gap={0}>
|
||||
<BaseText size="md" weight="medium">Add song</BaseText>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Clickable>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { LinkIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { apiConstants } from "@equicordplugins/songSpotlight.desktop/lib/api";
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { useRender } from "@equicordplugins/songSpotlight.desktop/service";
|
||||
import { TrashIcon } from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import ServiceIcon from "@equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon";
|
||||
import AddSong from "@equicordplugins/songSpotlight.desktop/ui/settings/AddSong";
|
||||
import { Song, UserData } from "@song-spotlight/api/structs";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import {
|
||||
ContextMenuApi,
|
||||
FluxDispatcher,
|
||||
Menu,
|
||||
React,
|
||||
showToast,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@webpack/common";
|
||||
import { DragEvent } from "react";
|
||||
|
||||
interface EditableSongProps {
|
||||
song: Song;
|
||||
insert?: "top" | "bottom";
|
||||
setSongRef(div: HTMLAnchorElement | null): void;
|
||||
onDrag(event: DragEvent<HTMLAnchorElement>): void;
|
||||
onDrop(song: Song): void;
|
||||
onRemove(song: Song): void;
|
||||
}
|
||||
|
||||
function EditableSong({ song, insert, setSongRef, onDrag, onDrop, onRemove }: EditableSongProps) {
|
||||
const { render, failed } = useRender(song);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={render?.link}
|
||||
onContextMenu={e =>
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu
|
||||
navId="vc-songspotlight-editable-song"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={render?.label}
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id="copy-link"
|
||||
label="Copy link"
|
||||
icon={LinkIcon}
|
||||
action={() => render && copyWithToast(render.link)}
|
||||
disabled={!render}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id="remove-song"
|
||||
color="danger"
|
||||
label="Remove song"
|
||||
icon={TrashIcon}
|
||||
action={() => onRemove(song)}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
))}
|
||||
draggable="true"
|
||||
data-dragging={dragging}
|
||||
data-insert={insert}
|
||||
onDragStart={event => {
|
||||
setDragging(true);
|
||||
onDrag(event);
|
||||
}}
|
||||
onDrag={onDrag}
|
||||
onDragEnd={() => {
|
||||
setDragging(false);
|
||||
onDrop(song);
|
||||
}}
|
||||
useDefaultUnderlineStyles={false}
|
||||
ref={div => setSongRef(div)}
|
||||
className={cl("editable-song-container")}
|
||||
>
|
||||
<Flex alignItems="center" gap="12px" className={cl("editable-song")}>
|
||||
<ServiceIcon width={28} height={28} service={song.service} />
|
||||
{failed
|
||||
? <BaseText size="md" weight="normal" className={cl("errored")}>Failed to load!</BaseText>
|
||||
: (
|
||||
<Flex flexDirection="column" justifyContent="center" gap={0}>
|
||||
<BaseText size="md" weight="medium" className={cl("clamped")}>
|
||||
{render ? render.label : "..."}
|
||||
</BaseText>
|
||||
<BaseText size="sm" weight="normal" className={cl("clamped", "sub")}>
|
||||
{render ? render.sublabel : "..."}
|
||||
</BaseText>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type Editable = {
|
||||
slot: "song";
|
||||
song: Song;
|
||||
last: boolean;
|
||||
} | {
|
||||
slot: "add" | "empty";
|
||||
song: undefined;
|
||||
last: undefined;
|
||||
};
|
||||
|
||||
interface SongListProps {
|
||||
localData: UserData;
|
||||
setLocalData(localData: UserData): void;
|
||||
}
|
||||
|
||||
export default function SongList({ localData, setLocalData }: SongListProps) {
|
||||
const editable = useMemo<Editable[]>(
|
||||
() => {
|
||||
if (!localData) return [];
|
||||
|
||||
return new Array(apiConstants.songLimit).fill(null).map((_, i) => {
|
||||
if (localData[i]) {
|
||||
return {
|
||||
slot: "song",
|
||||
song: localData[i],
|
||||
last: !localData[i + 1],
|
||||
};
|
||||
} else if (localData[i - 1] || i === 0) return { slot: "add" };
|
||||
else return { slot: "empty" };
|
||||
});
|
||||
},
|
||||
[localData],
|
||||
);
|
||||
|
||||
const songs = useRef(new Map<number, HTMLAnchorElement>());
|
||||
const [insert, setInsert] = useState<number>();
|
||||
|
||||
const handleRef = useCallback((index: number, element: HTMLAnchorElement | null) => {
|
||||
if (element) songs.current.set(index, element);
|
||||
else songs.current.delete(index);
|
||||
}, []);
|
||||
|
||||
const handleDrag = useCallback((index: number, event: DragEvent<HTMLAnchorElement>) => {
|
||||
const mapped = songs.current.entries().map(([index, element]) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
index,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
size: rect.height,
|
||||
};
|
||||
}).toArray().sort((a, b) => a.index - b.index);
|
||||
|
||||
const last = mapped[mapped.length - 1];
|
||||
const spots = [
|
||||
...mapped.map(({ index, top, size }) => ({ spot: index, position: top, size })),
|
||||
{ spot: last.index + 1, position: last.bottom, size: last.size },
|
||||
].filter(({ spot }) => spot !== index && spot !== index + 1);
|
||||
|
||||
let closest: number | undefined, closestDist = Infinity;
|
||||
for (const { spot, position, size } of spots) {
|
||||
const dist = Math.abs(event.clientY - position);
|
||||
if (dist < closestDist && dist < size) {
|
||||
closestDist = dist;
|
||||
closest = spot;
|
||||
}
|
||||
}
|
||||
|
||||
setInsert(closest);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((song: Song) => {
|
||||
if (insert === undefined) return;
|
||||
setInsert(undefined);
|
||||
|
||||
const i = localData.indexOf(song);
|
||||
if (i === -1) return;
|
||||
|
||||
const adjusted = insert > i ? insert - 1 : insert;
|
||||
setLocalData(localData.toSpliced(i, 1).toSpliced(adjusted, 0, song));
|
||||
}, [localData, insert]);
|
||||
|
||||
const handleAdd = useCallback((song: Song) => {
|
||||
if (localData.length >= apiConstants.songLimit) return "Not enough space";
|
||||
if (localData.some(x => sid(x) === sid(song))) return "You've already added this song";
|
||||
|
||||
showToast("Added song!");
|
||||
setLocalData([
|
||||
...localData,
|
||||
song,
|
||||
]);
|
||||
}, [localData]);
|
||||
|
||||
const handleRemove = useCallback((song: Song) => {
|
||||
const i = localData.indexOf(song);
|
||||
if (i === -1) return;
|
||||
|
||||
showToast("Removed song!");
|
||||
setLocalData(localData.toSpliced(i, 1));
|
||||
}, [localData]);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" gap="6px">
|
||||
{editable.map(({ slot, song, last }, i) => {
|
||||
if (slot === "song") {
|
||||
return (
|
||||
<EditableSong
|
||||
song={song}
|
||||
insert={last && insert === i + 1 ? "bottom" : insert === i ? "top" : undefined}
|
||||
setSongRef={element => handleRef(i, element)}
|
||||
onDrag={event => handleDrag(i, event)}
|
||||
onDrop={handleDrop}
|
||||
onRemove={handleRemove}
|
||||
key={sid(song)}
|
||||
/>
|
||||
);
|
||||
} else if (slot === "add") {
|
||||
return <AddSong onAdd={handleAdd} key={slot} />;
|
||||
} else return <div className={cl("empty-song")} key={`${slot}-${i}`} />;
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
232
src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx
Normal file
232
src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Button } from "@components/Button";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { apiConstants, deleteData, getData, saveData } from "@equicordplugins/songSpotlight.desktop/lib/api";
|
||||
import { presentOAuth2Modal } from "@equicordplugins/songSpotlight.desktop/lib/oauth2";
|
||||
import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore";
|
||||
import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore";
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { validateSong } from "@equicordplugins/songSpotlight.desktop/service";
|
||||
import { Spinner } from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import SongList from "@equicordplugins/songSpotlight.desktop/ui/settings/SongList";
|
||||
import { UserData, UserDataSchema } from "@song-spotlight/api/structs";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
import { readClipboard } from "@utils/clipboard";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import { ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { Alerts, Parser, showToast, Toasts, useCallback, useEffect, useMemo, useRef, useState } from "@webpack/common";
|
||||
|
||||
interface ImportButtonProps {
|
||||
overwrite: boolean;
|
||||
pending: boolean;
|
||||
setPending(pending: boolean): void;
|
||||
onImport(data: UserData): void;
|
||||
}
|
||||
|
||||
function ImportButton({ overwrite, pending, setPending, onImport }: ImportButtonProps) {
|
||||
const checkClipboard = useCallback(async () => {
|
||||
setPending(true);
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(await readClipboard());
|
||||
} catch {
|
||||
setPending(false);
|
||||
return showToast("No JSON in clipboard!", Toasts.Type.FAILURE);
|
||||
}
|
||||
|
||||
const { error, data } = UserDataSchema.max(apiConstants.songLimit).safeParse(json);
|
||||
if (error) {
|
||||
setPending(false);
|
||||
return showToast("Invalid Song Spotlight data in clipboard!", Toasts.Type.FAILURE);
|
||||
}
|
||||
|
||||
const validated = await Promise.allSettled(data.map(song => validateSong(song)));
|
||||
if (!validated.every(x => x.status === "fulfilled" && x.value)) {
|
||||
setPending(false);
|
||||
return showToast("One or more imported songs were invalid.", Toasts.Type.FAILURE);
|
||||
}
|
||||
|
||||
onImport(data);
|
||||
setPending(false);
|
||||
showToast("Imported songs from clipboard!", Toasts.Type.SUCCESS);
|
||||
}, [pending]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
if (overwrite) {
|
||||
Alerts.show({
|
||||
title: "Are you sure?",
|
||||
body: "This will overwrite your current songs.",
|
||||
onConfirm: checkClipboard,
|
||||
confirmText: "Continue",
|
||||
cancelText: "Nevermind",
|
||||
});
|
||||
} else checkClipboard();
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
Import from clipboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
templateData?: UserData;
|
||||
}
|
||||
|
||||
export default function Settings({ templateData }: SettingsProps) {
|
||||
const { isAuthorized, deleteToken } = useAuthorizationStore();
|
||||
const { self } = useSongStore();
|
||||
|
||||
const ticked = useRef(false);
|
||||
const [localData, setLocalData] = useState(templateData ?? self?.data);
|
||||
useEffect(() => {
|
||||
// only setLocalData on the second time this effect runs
|
||||
if (ticked.current) setLocalData(self?.data);
|
||||
else ticked.current = true;
|
||||
}, [self?.data]);
|
||||
const [pending, setPending] = useState(!localData);
|
||||
|
||||
const isSame = useMemo(() =>
|
||||
self?.data && localData
|
||||
? self.data.length === localData.length && self.data.map(sid).join(",") === localData.map(sid).join(",")
|
||||
: true, [self?.data, localData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthorized() && !localData) getData().then(() => setPending(false));
|
||||
}, [isAuthorized()]);
|
||||
|
||||
if (!isAuthorized()) return <Button onClick={() => presentOAuth2Modal()}>Sign in to Song Spotlight</Button>;
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" gap="20px">
|
||||
<BaseText size="md" weight="normal">
|
||||
You can also view your songs via the {Parser.parse("</songspotlight:1468320979938971802>")} command!
|
||||
</BaseText>
|
||||
{localData
|
||||
? (
|
||||
<Flex flexDirection="column" gap="12px">
|
||||
<Flex flexDirection="column" gap={0}>
|
||||
<BaseText size="lg" weight="semibold">Songs</BaseText>
|
||||
{self?.at
|
||||
&& (
|
||||
<BaseText size="xs" weight="normal" className={cl("sub")}>
|
||||
Last updated <b>{Intl.DateTimeFormat().format(new Date(self.at))}</b>
|
||||
</BaseText>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex flexDirection="column" gap="6px">
|
||||
<SongList
|
||||
localData={localData}
|
||||
setLocalData={setLocalData}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex flexDirection="column" gap="8px">
|
||||
<div className={cl("twin-buttons")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => copyWithToast(JSON.stringify(localData))}
|
||||
disabled={pending}
|
||||
>
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
<ImportButton
|
||||
overwrite={!!localData[0]}
|
||||
pending={pending}
|
||||
setPending={setPending}
|
||||
onImport={setLocalData}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await saveData(localData);
|
||||
showToast("Successfully saved songs!", Toasts.Type.SUCCESS);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSame || pending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
: <Spinner type={Spinner.Type.WANDERING_CUBES} />}
|
||||
<Flex flexDirection="column" gap="12px">
|
||||
<BaseText size="lg" weight="semibold">Authorization</BaseText>
|
||||
<div className={cl("twin-buttons")}>
|
||||
<Button
|
||||
variant="dangerPrimary"
|
||||
onClick={() => {
|
||||
deleteToken();
|
||||
showToast("Successfully signed out!", Toasts.Type.SUCCESS);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
<Button
|
||||
variant="dangerSecondary"
|
||||
onClick={() =>
|
||||
Alerts.show({
|
||||
title: "Are you sure?",
|
||||
body: "This will permanently delete all of your songs.",
|
||||
onConfirm: async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await deleteData();
|
||||
deleteToken();
|
||||
|
||||
showToast("Successfully deleted songs!", Toasts.Type.SUCCESS);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
confirmColor: "danger",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Nevermind",
|
||||
})}
|
||||
disabled={!self?.data[0] || pending}
|
||||
>
|
||||
Delete songs
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ modalProps, ...props }: SettingsProps & { modalProps: ModalProps; }) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
|
||||
<ModalHeader>
|
||||
<BaseText size="xl" weight="bold">Song Spotlight</BaseText>
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<Settings {...props} />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function openSettingsModal(templateData?: UserData) {
|
||||
openModal(modalProps => <SettingsModal modalProps={modalProps} templateData={templateData} />);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { LinkIcon, PencilIcon } from "@components/Icons";
|
||||
import { listData } from "@equicordplugins/songSpotlight.desktop/lib/api";
|
||||
import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore";
|
||||
import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore";
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import {
|
||||
CardClasses,
|
||||
MoreHorizontalIcon,
|
||||
OverlayClasses,
|
||||
Spinner,
|
||||
} from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import { classes } from "@utils/misc";
|
||||
import { ContextMenuApi, FluxDispatcher, Menu, React, Tooltip, useEffect, UserStore, useState } from "@webpack/common";
|
||||
|
||||
import Song from ".";
|
||||
|
||||
interface ProfileSongsProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export default function ProfileSongs({ userId }: ProfileSongsProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { isAuthorized } = useAuthorizationStore();
|
||||
const { users } = useSongStore();
|
||||
|
||||
const data = users[userId]?.data, at = users[userId]?.at;
|
||||
useEffect(() => {
|
||||
if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true));
|
||||
}, [isAuthorized()]);
|
||||
|
||||
const owned = UserStore.getCurrentUser().id === userId;
|
||||
|
||||
if (isAuthorized() && !data && !failed) {
|
||||
return <Spinner type={Spinner.Type.WANDERING_CUBES} />;
|
||||
} else if (!data?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes(OverlayClasses.overlay, CardClasses.card, cl("songs-container"))}
|
||||
key="song-spotlight-profile-songs"
|
||||
>
|
||||
<Flex justifyContent="space-between">
|
||||
<BaseText size="xs" weight="semibold" className={cl("header")}>Song Spotlight</BaseText>
|
||||
<Tooltip text="More">
|
||||
{props => (
|
||||
<MoreHorizontalIcon
|
||||
{...props}
|
||||
width={16}
|
||||
height={16}
|
||||
onClick={e =>
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu
|
||||
navId="vc-songspotlight-profile-songs"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={"Profile songs"}
|
||||
>
|
||||
{owned && (
|
||||
<Menu.MenuItem
|
||||
id="edit-songs"
|
||||
label="Edit songs"
|
||||
icon={PencilIcon}
|
||||
action={() => openSettingsModal()}
|
||||
/>
|
||||
)}
|
||||
<Menu.MenuItem
|
||||
id="copy-link"
|
||||
label="Copy JSON"
|
||||
icon={LinkIcon}
|
||||
action={() => copyWithToast(JSON.stringify(data))}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
))}
|
||||
className={cl("icon", "songs-more")}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex flexDirection="column" gap="6px">
|
||||
{data.map((song, i) => <Song owned={owned} song={song} index={i} key={sid(song)} />)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Button } from "@components/Button";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { listData } from "@equicordplugins/songSpotlight.desktop/lib/api";
|
||||
import { useAuthorizationStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore";
|
||||
import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore";
|
||||
import { cl } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { Spinner, WidgetClasses } from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings";
|
||||
import { sid } from "@song-spotlight/api/util";
|
||||
import { classes } from "@utils/misc";
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { React, ScrollerThin, useEffect, UserStore, useState } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import Song from ".";
|
||||
|
||||
interface WidgetSongsProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function WidgetSongs({ user }: WidgetSongsProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { isAuthorized } = useAuthorizationStore();
|
||||
const { users } = useSongStore();
|
||||
|
||||
const data = users[user.id]?.data;
|
||||
useEffect(() => {
|
||||
if (isAuthorized() && !data) listData(user.id).catch(() => setFailed(true));
|
||||
}, [isAuthorized()]);
|
||||
|
||||
const owned = UserStore.getCurrentUser().id === user.id;
|
||||
|
||||
let full: JSX.Element | undefined;
|
||||
if (failed) {
|
||||
full = (
|
||||
<BaseText size="md" weight="semibold" className={cl("errored")}>
|
||||
Uh oh! Song Spotlight failed to load. You can check the console for more details.
|
||||
</BaseText>
|
||||
);
|
||||
} else if (isAuthorized() && !data) {
|
||||
full = <Spinner type={Spinner.Type.SPINNING_CIRCLE} />;
|
||||
} else if (!data?.[0]) {
|
||||
full = (
|
||||
<>
|
||||
<BaseText size="lg" weight="semibold">Looks like there's nothing here!</BaseText>
|
||||
<BaseText size="md" weight="normal">
|
||||
{owned
|
||||
? "Well? What are you waiting for? Go add some songs to your Song Spotlight!"
|
||||
: `Tell ${user.globalName || user.username} to add some songs to their Song Spotlight!`}
|
||||
</BaseText>
|
||||
{owned && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{ marginTop: "14px", flexShrink: 1 }}
|
||||
onClick={() => openSettingsModal()}
|
||||
>
|
||||
Edit songs
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollerThin
|
||||
fade
|
||||
className={classes(WidgetClasses.tabPanelScroller, cl("widget-container"))}
|
||||
key="song-spotlight-widget-songs"
|
||||
>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent={full && "center"}
|
||||
alignItems={full && "center"}
|
||||
gap={full ? "8px" : "10px"}
|
||||
style={full && { flex: 1, textAlign: "center" }}
|
||||
>
|
||||
{full
|
||||
|| data.map((song, i) => <Song owned={owned} song={song} index={i} big key={sid(song)} />)}
|
||||
</Flex>
|
||||
</ScrollerThin>
|
||||
);
|
||||
}
|
||||
360
src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx
Normal file
360
src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { LinkIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { apiConstants } from "@equicordplugins/songSpotlight.desktop/lib/api";
|
||||
import { useSongStore } from "@equicordplugins/songSpotlight.desktop/lib/stores/SongStore";
|
||||
import { cl, formatCoverTooltip, formatDurationMs } from "@equicordplugins/songSpotlight.desktop/lib/utils";
|
||||
import { parseLink, useRender } from "@equicordplugins/songSpotlight.desktop/service";
|
||||
import {
|
||||
CardClasses,
|
||||
ExplicitTag,
|
||||
ImageBrokenIcon,
|
||||
OverlayClasses,
|
||||
PlayButton,
|
||||
PuzzlePieceIcon,
|
||||
TrashIcon,
|
||||
} from "@equicordplugins/songSpotlight.desktop/ui/common";
|
||||
import AudioPlayer from "@equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer";
|
||||
import ProgressCircle from "@equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle";
|
||||
import ServiceIcon from "@equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon";
|
||||
import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings";
|
||||
import { RenderInfoEntryBased, RenderSongInfo } from "@song-spotlight/api/handlers";
|
||||
import { Song as SongType } from "@song-spotlight/api/structs";
|
||||
import { isListLayout, sid } from "@song-spotlight/api/util";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import { classes } from "@utils/misc";
|
||||
import {
|
||||
Clickable,
|
||||
ContextMenuApi,
|
||||
FluxDispatcher,
|
||||
Menu,
|
||||
React,
|
||||
ScrollerThin,
|
||||
showToast,
|
||||
Toasts,
|
||||
Tooltip,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "@webpack/common";
|
||||
|
||||
interface SongEntryProps {
|
||||
entry: RenderInfoEntryBased;
|
||||
number: string;
|
||||
isLoaded: boolean;
|
||||
isPlaying: boolean;
|
||||
big?: boolean;
|
||||
onClick(): void;
|
||||
}
|
||||
|
||||
function SongEntry({ entry, number, isLoaded, isPlaying, big, onClick }: SongEntryProps) {
|
||||
const baseSize = big ? "md" : "sm";
|
||||
const subSize = big ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<Clickable
|
||||
onContextMenu={e =>
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu
|
||||
navId="vc-songspotlight-song-entry"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={entry.label}
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id="copy-link"
|
||||
label="Copy link"
|
||||
icon={LinkIcon}
|
||||
action={() => copyWithToast(entry.link)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id="steal-song"
|
||||
label="Steal song"
|
||||
icon={PuzzlePieceIcon}
|
||||
action={async () => {
|
||||
const self = useSongStore.getState().self?.data ?? [];
|
||||
if (self.length >= apiConstants.songLimit) {
|
||||
return showToast("You don't have enough space!");
|
||||
}
|
||||
|
||||
const song = await parseLink(entry.link);
|
||||
if (!song) {
|
||||
return showToast("Uh oh, this song doesn't exist!", Toasts.Type.FAILURE);
|
||||
}
|
||||
|
||||
if (self.find(x => sid(x) === sid(song))) {
|
||||
return showToast("You already have this song added!");
|
||||
}
|
||||
|
||||
openSettingsModal([...self, song]);
|
||||
}}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
))}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
gap="4px"
|
||||
className={cl("song-entry")}
|
||||
aria-disabled={!isLoaded}
|
||||
data-playing={isPlaying}
|
||||
>
|
||||
<Flex alignItems="center" justifyContent="center" gap={0} className={cl("song-entry-number")}>
|
||||
<BaseText size={baseSize} weight="medium">{number}</BaseText>
|
||||
</Flex>
|
||||
<Flex flexDirection="column" justifyContent="center" gap={0}>
|
||||
<Flex alignItems="center" gap="6px">
|
||||
<BaseText size={subSize} weight="semibold" className={cl("clamped")} title={entry.label}>
|
||||
{entry.label}
|
||||
</BaseText>
|
||||
{entry.explicit && <ExplicitTag />}
|
||||
</Flex>
|
||||
<BaseText size={subSize} weight="normal" className={cl("clamped", "sub")} title={entry.sublabel}>
|
||||
{entry.sublabel}
|
||||
</BaseText>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Clickable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SongInfoProps {
|
||||
owned: boolean;
|
||||
song: SongType;
|
||||
render: RenderSongInfo;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
function SongInfo({ owned, song, render, big }: SongInfoProps) {
|
||||
const [playing, setPlaying] = useState<number | false>(false);
|
||||
const [loaded, setLoaded] = useState(new Set<number>());
|
||||
const audios = useMemo(() => render.form === "single" ? [render.single] : render.list, [render]);
|
||||
const audioRef = useRef<HTMLAudioElement>(undefined);
|
||||
const duration = useMemo(
|
||||
() => {
|
||||
if (playing !== false) {
|
||||
return audios[playing].audio?.duration;
|
||||
} else {
|
||||
return render.form === "single" ? render.single.audio?.duration : undefined;
|
||||
}
|
||||
},
|
||||
[playing, render],
|
||||
);
|
||||
|
||||
const setLoadedAudio = useCallback((index: number, state: boolean) =>
|
||||
setLoaded(ld => {
|
||||
if (state) ld.add(index);
|
||||
else ld.delete(index);
|
||||
return new Set(ld);
|
||||
}), []);
|
||||
|
||||
const baseSize = big ? "md" : "sm";
|
||||
const subSize = big ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" gap={0} className={cl("song")}>
|
||||
<AudioPlayer
|
||||
audioRef={audioRef}
|
||||
list={audios}
|
||||
playing={playing}
|
||||
setPlaying={setPlaying}
|
||||
setLoaded={setLoadedAudio}
|
||||
/>
|
||||
<div className={cl("song-grid")}>
|
||||
<Flex gap="8px" alignItems="center" className={cl("song-core")}>
|
||||
<Tooltip text={formatCoverTooltip(song, render)}>
|
||||
{props => (
|
||||
<Flex
|
||||
{...props}
|
||||
onContextMenu={e =>
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<Menu.Menu
|
||||
navId="vc-songspotlight-song"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={render.label}
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id="copy-link"
|
||||
label="Copy link"
|
||||
icon={LinkIcon}
|
||||
action={() => copyWithToast(render.link)}
|
||||
/>
|
||||
{owned
|
||||
? (
|
||||
<Menu.MenuItem
|
||||
id="steal-song"
|
||||
label="Steal song"
|
||||
icon={PuzzlePieceIcon}
|
||||
action={() => {
|
||||
const self = useSongStore.getState().self?.data ?? [];
|
||||
if (self.length >= apiConstants.songLimit) {
|
||||
return showToast("You don't have enough space!");
|
||||
}
|
||||
if (self.find(x => sid(x) === sid(song))) {
|
||||
return showToast("You already have this song added!");
|
||||
}
|
||||
|
||||
openSettingsModal([...self, song]);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Menu.MenuItem
|
||||
id="remove-song"
|
||||
color="danger"
|
||||
label="Remove song"
|
||||
icon={TrashIcon}
|
||||
action={() => {
|
||||
const self = useSongStore.getState().self?.data ?? [];
|
||||
|
||||
const i = self.findIndex(x => sid(x) === sid(song));
|
||||
if (i === -1) {
|
||||
return showToast("You... don't have this song added?");
|
||||
}
|
||||
|
||||
openSettingsModal(self.toSpliced(i, 1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Menu.Menu>
|
||||
))}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
gap={0}
|
||||
className={cl("song-cover")}
|
||||
>
|
||||
<Link href={render.link} style={{ display: "contents" }}>
|
||||
{render.thumbnailUrl
|
||||
? <img src={render.thumbnailUrl} alt={render.label} />
|
||||
: (
|
||||
<ImageBrokenIcon
|
||||
className={cl("icon")}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Flex>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
gap={0}
|
||||
className={cl("clamped")}
|
||||
>
|
||||
<Flex alignItems="center" gap="6px">
|
||||
<BaseText size={baseSize} weight="semibold" className={cl("clamped")} title={render.label}>
|
||||
{render.label}
|
||||
</BaseText>
|
||||
{render.explicit && <ExplicitTag />}
|
||||
</Flex>
|
||||
<BaseText
|
||||
size={baseSize}
|
||||
weight="normal"
|
||||
className={cl("clamped", "sub")}
|
||||
title={render.sublabel}
|
||||
>
|
||||
{render.sublabel}
|
||||
</BaseText>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex justifyContent="flex-end" className={cl("song-service")}>
|
||||
<ServiceIcon service={song.service} />
|
||||
</Flex>
|
||||
<Flex
|
||||
justifyContent="flex-end"
|
||||
alignItems="flex-end"
|
||||
gap="6px"
|
||||
className={cl("song-player")}
|
||||
data-idle={playing === false && !big}
|
||||
>
|
||||
{duration && (
|
||||
<BaseText size={subSize} weight="medium" className={cl("mono", "sub")}>
|
||||
{formatDurationMs(duration)}
|
||||
</BaseText>
|
||||
)}
|
||||
<div className={cl("song-progress-container")}>
|
||||
<ProgressCircle
|
||||
border={2}
|
||||
audioRef={audioRef}
|
||||
className={cl("song-progress")}
|
||||
/>
|
||||
<PlayButton
|
||||
state={playing !== false}
|
||||
disabled={loaded.size < 1}
|
||||
onClick={() => {
|
||||
const loadedI = loaded.values().toArray().sort()[0];
|
||||
if (loadedI === undefined) return;
|
||||
|
||||
if (playing !== false) setPlaying(false);
|
||||
else setPlaying(loadedI);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
{render.form === "list" && (
|
||||
<>
|
||||
<div className={cl("song-divider")} />
|
||||
<ScrollerThin fade className={cl("song-list")}>
|
||||
<Flex flexDirection="column" gap="3px">
|
||||
{render.list.map((entry, i) => (
|
||||
<SongEntry
|
||||
entry={entry}
|
||||
number={(i + 1).toString()}
|
||||
isLoaded={loaded.has(i)}
|
||||
isPlaying={playing === i}
|
||||
onClick={() => {
|
||||
if (!loaded.has(i)) return;
|
||||
|
||||
if (playing !== i) setPlaying(i);
|
||||
else setPlaying(false);
|
||||
}}
|
||||
big={big}
|
||||
key={entry.link}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollerThin>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface SongInfoContainerProps {
|
||||
owned: boolean;
|
||||
song: SongType;
|
||||
index: number;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
export default function Song({ owned, song, index, big }: SongInfoContainerProps) {
|
||||
const { failed, render } = useRender(song);
|
||||
|
||||
if (failed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes(
|
||||
OverlayClasses.overlay,
|
||||
CardClasses.card,
|
||||
cl("song-container", {
|
||||
"list-song-container": isListLayout(song, render || undefined),
|
||||
}),
|
||||
)}
|
||||
style={{
|
||||
["--index" as any]: index.toString(),
|
||||
}}
|
||||
>
|
||||
{render && <SongInfo owned={owned} song={song} render={render} big={big} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,3 +7,7 @@
|
|||
export function copyToClipboard(text: string): Promise<void> {
|
||||
return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.copy(text) : navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function readClipboard(): Promise<string> {
|
||||
return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.read() : navigator.clipboard.readText();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue