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:
nexpid 2026-03-02 17:06:18 +01:00 • committed by GitHub
parent 33fcf774cc
commit df890ac455
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2462 additions and 2 deletions

View file

@ -42,6 +42,7 @@
"@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1", "@ffmpeg/util": "^0.12.1",
"@intrnl/xxhash64": "^0.1.2", "@intrnl/xxhash64": "^0.1.2",
"@song-spotlight/api": "^2.0.0",
"@streamparser/json": "^0.0.22", "@streamparser/json": "^0.0.22",
"@types/less": "^3.0.8", "@types/less": "^3.0.8",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",

20
pnpm-lock.yaml generated
View file

@ -25,6 +25,9 @@ importers:
'@intrnl/xxhash64': '@intrnl/xxhash64':
specifier: ^0.1.2 specifier: ^0.1.2
version: 0.1.2 version: 0.1.2
'@song-spotlight/api':
specifier: ^2.0.0
version: 2.0.0(typescript@5.9.3)
'@streamparser/json': '@streamparser/json':
specifier: ^0.0.22 specifier: ^0.0.22
version: 0.0.22 version: 0.0.22
@ -718,6 +721,11 @@ packages:
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@song-spotlight/api@2.0.0':
resolution: {integrity: sha512-a3gyrSojd3BzwloxZHyDXA7HhjmCNQZOYATh7AZJyCXIqilBMwFPgQ2FVkUpzf6MzZhN9emeN6OGZOJb/rkECw==}
peerDependencies:
typescript: ^5
'@streamparser/json@0.0.22': '@streamparser/json@0.0.22':
resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==} resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==}
@ -2650,6 +2658,9 @@ packages:
zod@3.24.2: zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots: snapshots:
'@babel/code-frame@7.24.7': '@babel/code-frame@7.24.7':
@ -2995,6 +3006,12 @@ snapshots:
'@rtsao/scc@1.1.0': {} '@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': {} '@streamparser/json@0.0.22': {}
'@stylistic/eslint-plugin@5.6.0(eslint@9.39.1)': '@stylistic/eslint-plugin@5.6.0(eslint@9.39.1)':
@ -5286,3 +5303,6 @@ snapshots:
q: 1.5.1 q: 1.5.1
zod@3.24.2: {} zod@3.24.2: {}
zod@4.3.6:
optional: true

View file

@ -7,9 +7,9 @@
import "./Link.css"; import "./Link.css";
import { classes } from "@utils/misc"; 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; disabled?: boolean;
useDefaultUnderlineStyles?: boolean; useDefaultUnderlineStyles?: boolean;
} }
@ -24,6 +24,7 @@ export function Link({
children, children,
...restProps ...restProps
}: PropsWithChildren<LinkProps>) { }: PropsWithChildren<LinkProps>) {
const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href); const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href);
const needsSafeAttrs = href && !isInternal; const needsSafeAttrs = href && !isInternal;

View 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 }),
});

View 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;
});
}

View 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);
}
}}
/>
));
}

View file

@ -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 }),
},
),
)
);

View file

@ -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>,
)
);

View 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>;
};
}

View 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}`;
}
}

View 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();
}

View 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")>;

View 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;
}
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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} />);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -7,3 +7,7 @@
export function copyToClipboard(text: string): Promise<void> { export function copyToClipboard(text: string): Promise<void> {
return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.copy(text) : navigator.clipboard.writeText(text); 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();
}