diff --git a/package.json b/package.json index a07f6f350..6e8ac3d1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 654192c0b..a877bef20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/Link.tsx b/src/components/Link.tsx index cad60bb6e..df82de115 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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 { +export interface LinkProps extends DetailedHTMLProps, HTMLAnchorElement> { disabled?: boolean; useDefaultUnderlineStyles?: boolean; } @@ -24,6 +24,7 @@ export function Link({ children, ...restProps }: PropsWithChildren) { + const isInternal = href && /^(?:discord:\/)?\/[a-zA-Z0-9_-]/.test(href); const needsSafeAttrs = href && !isInternal; diff --git a/src/equicordplugins/songSpotlight.desktop/index.tsx b/src/equicordplugins/songSpotlight.desktop/index.tsx new file mode 100644 index 000000000..360dd6c7e --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/index.tsx @@ -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: () => , + }, + }), + 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 }), +}); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/api.ts b/src/equicordplugins/songSpotlight.desktop/lib/api.ts new file mode 100644 index 000000000..868f79132 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/api.ts @@ -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("") && 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 { + 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 { + 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 { + 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 { + return await authFetch(new URL("api/data", apiConstants.api), { + method: "DELETE", + }) + .then(res => res?.json()) + .then(json => { + useSongStore.getState().delete(); + return json; + }); +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx new file mode 100644 index 000000000..3ff3bcdf9 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/oauth2.tsx @@ -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 => ( + { + 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); + } + }} + /> + )); +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts new file mode 100644 index 000000000..48e1469d5 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/AuthorizationStore.ts @@ -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; + getToken(): string | undefined; + setToken(token: string): void; + deleteToken(): void; + isAuthorized(): boolean; +} + +export const useAuthorizationStore: PersistedZustandStore = 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, + { + 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 }), + }, + ), + ) +); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts new file mode 100644 index 000000000..b94f57b06 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/SongStore.ts @@ -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; + self?: Data; + update(props: { + userId?: string; + data: UserData; + at?: string; + }): void; + delete(userId?: string): void; + $refresh(): void; +} + +export const useSongStore: ZustandStore = 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, + ) +); diff --git a/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts b/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts new file mode 100644 index 000000000..46a068ea9 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/stores/zustand.d.ts @@ -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 = (set: (state: Partial) => void, get: () => T) => T; + +export interface ZustandStore { + (): T; + getState(): T; +} + +export interface PersistedZustandStore extends ZustandStore { + persist: { + rehydrate(): Promise; + }; +} diff --git a/src/equicordplugins/songSpotlight.desktop/lib/utils.ts b/src/equicordplugins/songSpotlight.desktop/lib/utils.ts new file mode 100644 index 000000000..5c5118657 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/lib/utils.ts @@ -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}`; + } +} diff --git a/src/equicordplugins/songSpotlight.desktop/native.ts b/src/equicordplugins/songSpotlight.desktop/native.ts new file mode 100644 index 000000000..ea8fc1929 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/native.ts @@ -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(); +} diff --git a/src/equicordplugins/songSpotlight.desktop/service.ts b/src/equicordplugins/songSpotlight.desktop/service.ts new file mode 100644 index 000000000..e7334d52a --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/service.ts @@ -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(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; diff --git a/src/equicordplugins/songSpotlight.desktop/style.css b/src/equicordplugins/songSpotlight.desktop/style.css new file mode 100644 index 000000000..42f8cb293 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/style.css @@ -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; + } +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/common.tsx b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx new file mode 100644 index 000000000..0e5454fad --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/common.tsx @@ -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, "children"> & { + type?: SpinnerTypes; + } + > + & { + Type: typeof SpinnerTypes; + }; + +export const Spinner = findByCodeLazy('"pulsingEllipsis"') as unknown as Spinner; + +// basic components +export function ExplicitTag({ className }: HTMLAttributes) { + return ( + + {tooltipProps => ( + + E + + )} + + ); +} +interface PlayButtonProps extends HTMLAttributes { + state: boolean; + disabled?: boolean; + onClick(): void; +} + +export function PlayButton({ state, disabled, ...props }: PlayButtonProps) { + const Icon = state ? PauseIcon : PlayIcon; + + return ( + + ); +} + +// icons +export function AppleMusicIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function ImageBrokenIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + + ); +} +export function MoreHorizontalIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PauseIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PlayIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function PuzzlePieceIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function SoundcloudIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function SpotifyIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} +export function TrashIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx new file mode 100644 index 000000000..144efb75f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/AudioPlayer.tsx @@ -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; + 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()); + const loaded = useRef(new Set()); + + 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(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 ( + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx new file mode 100644 index 000000000..26654a288 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle.tsx @@ -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; +} +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 ( + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx b/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx new file mode 100644 index 000000000..5d0ead98b --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon.tsx @@ -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 && ( + + {tooltipProps => ( + + )} + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx new file mode 100644 index 000000000..084ed2534 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/AddSong.tsx @@ -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(); + const [pending, setPending] = useState(false); + + return ( + + + + Add a new song + + + + + Song Spotlight supports these services: {parsers.map(x => x.label).join(", ")} + + { + setURL(value); + try { + if (value) new URL(value); + setError(undefined); + } catch { + setError("Invalid URL"); + } + }} + /> + + + + + + + + + + ); +} + +interface AddSongProps { + onAdd(song: Song): string | undefined; +} + +export default function AddSong({ onAdd }: AddSongProps) { + return ( + { + const key = openModal(modalProps => ( + closeModal(key)} + onAdd={onAdd} + /> + )); + }} + > + + + + Add song + + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx new file mode 100644 index 000000000..f09bdae34 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/SongList.tsx @@ -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): 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 ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={render?.label} + > + render && copyWithToast(render.link)} + disabled={!render} + /> + onRemove(song)} + /> + + ))} + 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")} + > + + + {failed + ? Failed to load! + : ( + + + {render ? render.label : "..."} + + + {render ? render.sublabel : "..."} + + + )} + + + ); +} + +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( + () => { + 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()); + const [insert, setInsert] = useState(); + + 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) => { + 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 ( + + {editable.map(({ slot, song, last }, i) => { + if (slot === "song") { + return ( + handleRef(i, element)} + onDrag={event => handleDrag(i, event)} + onDrop={handleDrop} + onRemove={handleRemove} + key={sid(song)} + /> + ); + } else if (slot === "add") { + return ; + } else return
; + })} + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx new file mode 100644 index 000000000..8dd5a166f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/settings/index.tsx @@ -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 ( + + ); +} + +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 ; + + return ( + + + You can also view your songs via the {Parser.parse("")} command! + + {localData + ? ( + + + Songs + {self?.at + && ( + + Last updated {Intl.DateTimeFormat().format(new Date(self.at))} + + )} + + + + + +
+ + +
+ +
+
+ ) + : } + + Authorization +
+ + +
+
+
+ ); +} + +export function SettingsModal({ modalProps, ...props }: SettingsProps & { modalProps: ModalProps; }) { + return ( + + + + Song Spotlight + + +
+ +
+
+
+
+ ); +} + +export function openSettingsModal(templateData?: UserData) { + openModal(modalProps => ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx new file mode 100644 index 000000000..9bc46c95f --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/ProfileSongs.tsx @@ -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 ; + } else if (!data?.[0]) { + return null; + } + + return ( +
+ + Song Spotlight + + {props => ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={"Profile songs"} + > + {owned && ( + openSettingsModal()} + /> + )} + copyWithToast(JSON.stringify(data))} + /> + + ))} + className={cl("icon", "songs-more")} + /> + )} + + + + {data.map((song, i) => )} + +
+ ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx new file mode 100644 index 000000000..26c613886 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/WidgetSongs.tsx @@ -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 = ( + + Uh oh! Song Spotlight failed to load. You can check the console for more details. + + ); + } else if (isAuthorized() && !data) { + full = ; + } else if (!data?.[0]) { + full = ( + <> + Looks like there's nothing here! + + {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!`} + + {owned && ( + + )} + + ); + } + + return ( + + + {full + || data.map((song, i) => )} + + + ); +} diff --git a/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx b/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx new file mode 100644 index 000000000..f78c71819 --- /dev/null +++ b/src/equicordplugins/songSpotlight.desktop/ui/songs/index.tsx @@ -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 ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={entry.label} + > + copyWithToast(entry.link)} + /> + { + 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]); + }} + /> + + ))} + onClick={onClick} + > + + + {number} + + + + + {entry.label} + + {entry.explicit && } + + + {entry.sublabel} + + + + + ); +} + +interface SongInfoProps { + owned: boolean; + song: SongType; + render: RenderSongInfo; + big?: boolean; +} + +function SongInfo({ owned, song, render, big }: SongInfoProps) { + const [playing, setPlaying] = useState(false); + const [loaded, setLoaded] = useState(new Set()); + const audios = useMemo(() => render.form === "single" ? [render.single] : render.list, [render]); + const audioRef = useRef(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 ( + + +
+ + + {props => ( + + ContextMenuApi.openContextMenu(e, () => ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={render.label} + > + copyWithToast(render.link)} + /> + {owned + ? ( + { + 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]); + }} + /> + ) + : ( + { + 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)); + }} + /> + )} + + ))} + justifyContent="center" + alignItems="center" + gap={0} + className={cl("song-cover")} + > + + {render.thumbnailUrl + ? {render.label} + : ( + + )} + + + )} + + + + + {render.label} + + {render.explicit && } + + + {render.sublabel} + + + + + + + + {duration && ( + + {formatDurationMs(duration)} + + )} +
+ + { + const loadedI = loaded.values().toArray().sort()[0]; + if (loadedI === undefined) return; + + if (playing !== false) setPlaying(false); + else setPlaying(loadedI); + }} + /> +
+
+
+ {render.form === "list" && ( + <> +
+ + + {render.list.map((entry, i) => ( + { + if (!loaded.has(i)) return; + + if (playing !== i) setPlaying(i); + else setPlaying(false); + }} + big={big} + key={entry.link} + /> + ))} + + + + )} + + ); +} + +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 ( +
+ {render && } +
+ ); +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index c098a5490..643324ec5 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -7,3 +7,7 @@ export function copyToClipboard(text: string): Promise { return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.copy(text) : navigator.clipboard.writeText(text); } + +export function readClipboard(): Promise { + return IS_DISCORD_DESKTOP ? DiscordNative.clipboard.read() : navigator.clipboard.readText(); +}