setIsHovered(true)}
diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css
index 4227d3c9d..006c8b6db 100644
--- a/src/equicordplugins/channelTabs/style.css
+++ b/src/equicordplugins/channelTabs/style.css
@@ -294,11 +294,16 @@
}
/* stylelint-disable-next-line no-descending-specificity */
-.vc-channeltabs-tab-selected .vc-channeltabs-name-text {
+.vc-channeltabs-dark-theme.vc-channeltabs-tab-selected .vc-channeltabs-name-text {
font-weight: 600;
color: var(--white-500);
}
+/* stylelint-disable-next-line no-descending-specificity */
+.vc-channeltabs-light-theme.vc-channeltabs-tab-selected .vc-channeltabs-name-text {
+ font-weight: 600;
+}
+
.vc-channeltabs-tab-selected .vc-channeltabs-guild-acronym-icon span {
color: var(--white-500);
}
@@ -1229,4 +1234,4 @@
/* disable transitions when compact expand animation is disabled */
.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge {
transition: none !important;
-}
+}
\ No newline at end of file
diff --git a/src/equicordplugins/declutter/index.tsx b/src/equicordplugins/declutter/index.tsx
new file mode 100644
index 000000000..468595bb2
--- /dev/null
+++ b/src/equicordplugins/declutter/index.tsx
@@ -0,0 +1,334 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { definePluginSettings, migratePluginSetting, migratePluginSettings } from "@api/Settings";
+import { HeadingSecondary } from "@components/Heading";
+import { Devs, EquicordDevs } from "@utils/index";
+import definePlugin, { OptionType } from "@utils/types";
+
+migratePluginSettings("Declutter", "BetterUserArea", "Anammox");
+
+const migrationsAnammox = [
+ ["dms", "removeShopAboveDM"],
+ ["quests", "removeQuestsAboveDM"],
+ ["serverBoost", "removeServerBoostInfo"],
+ ["billing", "removeBillingSettings"],
+ ["gift", "removeGiftButton"],
+ ["emojiList", "removeUnavailableEmojiPicker"],
+];
+
+for (const [oldKey, newKey] of migrationsAnammox) {
+ migratePluginSetting("Anammox", newKey, oldKey);
+}
+
+export const settings = definePluginSettings({
+ userProfileHeader: {
+ type: OptionType.COMPONENT,
+ component: () => SectionSeparator("User Profile"),
+ },
+ removeAvatarDecoration: {
+ type: OptionType.BOOLEAN,
+ description: "Remove avatar decorations.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeNameplate: {
+ type: OptionType.BOOLEAN,
+ description: "Remove nameplates.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeProfileEffect: {
+ type: OptionType.BOOLEAN,
+ description: "Remove profile animation effects on open.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeClanTag: {
+ type: OptionType.BOOLEAN,
+ description: "Remove clan tags.",
+ default: true,
+ restartNeeded: true,
+ },
+ alwaysShowUsername: {
+ type: OptionType.BOOLEAN,
+ description: "Always show username instead of status.",
+ default: true,
+ restartNeeded: true
+ },
+ removeUsernameStyles: {
+ type: OptionType.BOOLEAN,
+ description: "Remove username colors and effects.",
+ default: true,
+ restartNeeded: true
+ },
+ friendsListHeader: {
+ type: OptionType.COMPONENT,
+ component: () => SectionSeparator("Above Friends/DMs List"),
+ },
+ removeShopAboveDM: {
+ type: OptionType.BOOLEAN,
+ description: "Remove shops above DMs list.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeQuestsAboveDM: {
+ type: OptionType.BOOLEAN,
+ description: "Remove quests above DMs list.",
+ default: false,
+ restartNeeded: true,
+ },
+ miscHeader: {
+ type: OptionType.COMPONENT,
+ component: () => SectionSeparator("Misc"),
+ },
+ removeServerBoostInfo: {
+ type: OptionType.BOOLEAN,
+ description: "Remove server boost info above channel list.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeBillingSettings: {
+ type: OptionType.BOOLEAN,
+ description: "Remove billing settings.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeGiftButton: {
+ type: OptionType.BOOLEAN,
+ description: "Remove gift button.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeUnavailableEmojiPicker: {
+ type: OptionType.BOOLEAN,
+ description: "Remove unavailable categories from the emoji picker.",
+ default: true,
+ restartNeeded: true,
+ },
+ removeAudioMenus: {
+ type: OptionType.BOOLEAN,
+ description: "Remove menus next to mute and deafen buttons.",
+ default: true,
+ restartNeeded: true
+ },
+ removeButtonTooltips: {
+ type: OptionType.BOOLEAN,
+ description: "Remove button tooltips.",
+ default: false,
+ restartNeeded: true
+ },
+});
+
+function SectionSeparator(title: string) {
+ return (
+ <>
+
+
{title}
+
+ >
+ );
+}
+
+export default definePlugin({
+ name: "Declutter",
+ description: "Declutter discord ui by removing unwanted elements such as profile customizations, shops, quests and more.",
+ authors: [EquicordDevs.Leon135, Devs.prism, Devs.Kyuuhachi],
+ settings,
+ patches: [
+ {
+ // Avatar decoration
+ find: "getAvatarDecorationURL:",
+ replacement: {
+ match: /(?<=function \i\((\i)\){)(?=.{0,150}let{avatarDecoration)/,
+ replace: "$&return null;"
+ },
+ predicate: () => settings.store.removeAvatarDecoration,
+ },
+ {
+ // Nameplate
+ find: "#{intl::AVATAR_MALLOW}",
+ replacement: {
+ match: /function \i\(\i\)\{(?=.{0,25}skuId:)/,
+ replace: "$&return null;"
+ },
+ predicate: () => settings.store.removeNameplate,
+ },
+ {
+ // Profile banner animation effect
+ find: "bannerAdjustment,noBorderRadius",
+ replacement: {
+ match: /\i=\i=>\{(?=.{0,50}\.useReducedMotion\))/,
+ replace: "$&return null;"
+ },
+ predicate: () => settings.store.removeProfileEffect,
+ },
+ {
+ // Clan tag
+ find: ".GuildFeatures.GUILD_TAGS)",
+ replacement: {
+ match: /(?<=\.profile\?\.badge.{0,50}\i\)\{)/,
+ replace: "return false;"
+ },
+ predicate: () => settings.store.removeClanTag,
+ },
+ {
+ // Username styles and allways show username
+ find: ".NITRO_PRIVACY_PERK_BETA_COACHMARK));",
+ replacement: [
+ {
+ match: /displayNameStyles:(\i),/,
+ replace: "displayNameStyles:void 0,",
+ predicate: () => settings.store.removeUsernameStyles
+ },
+ {
+ match: /hoverText:(\i),forceHover:\i,children:/g,
+ replace: "hoverText:$1,forceHover:!0,children:",
+ predicate: () => settings.store.alwaysShowUsername
+ },
+ ],
+ },
+ {
+ // Button tooltips in user area
+ find: '"MicrophoneButton"',
+ replacement: [
+ {
+ // Button tooltips
+ match: /:\{tooltipText:\i\};/,
+ replace: ":{tooltipText:void 0};",
+ predicate: () => settings.store.removeButtonTooltips
+ },
+ {
+ // Audio menus
+ match: /(?<=#{intl::MUTE}\),)className:\i\.\i,/,
+ replace: "",
+ predicate: () => settings.store.removeAudioMenus
+ },
+ {
+ // Audio menus
+ match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,600}#{intl::ACCOUNT_INPUT_OPTIONS}\)\}\)(?=\])/,
+ replace: "",
+ predicate: () => settings.store.removeAudioMenus
+ },
+ ],
+ },
+ {
+ // Button tooltips in right click audio settings
+ find: "#{intl::f+DDY/::raw},{outputDeviceName",
+ replacement: [
+ {
+ // Button tooltips
+ match: /(?<=role:"switch",)tooltipText:\i\}/,
+ replace: "tooltipText:void 0}",
+ predicate: () => settings.store.removeButtonTooltips
+ },
+ {
+ // Audio menus
+ match: /(?<=#{intl::DEAFEN}\),)className:\i\.\i,/,
+ replace: "",
+ predicate: () => settings.store.removeAudioMenus
+ },
+ {
+ // Audio menus
+ match: /,\(0,\i\.jsxs?\)\(\i\.\i,\{.{0,650}#{intl::ACCOUNT_OUTPUT_OPTIONS}\)\}\)(?=\])/,
+ replace: "",
+ predicate: () => settings.store.removeAudioMenus
+ }
+ ],
+ },
+ {
+ // ? Another button tooltips
+ find: "#{intl::USER_SETTINGS_WITH_BUILD_OVERRIDE}",
+ replacement: {
+ match: /tooltipText:\i,tooltipPositionKey/,
+ replace: "tooltipText:void 0,tooltipPositionKey"
+ },
+ predicate: () => settings.store.removeButtonTooltips
+ },
+ {
+ // Above DMs section, shops and quests
+ find: 'tutorialId:"direct-messages"',
+ replacement: [
+ {
+ match: /"nitro-tab-group"\)/,
+ replace: "$&&&undefined",
+ predicate: () => settings.store.removeShopAboveDM,
+ },
+ {
+ match: /"discord-shop"\)/,
+ replace: "$&&&undefined",
+ predicate: () => settings.store.removeShopAboveDM
+
+ },
+ {
+ match: /"quests"\)/,
+ replace: "$&&&undefined",
+ predicate: () => settings.store.removeQuestsAboveDM
+ },
+ ],
+ },
+ {
+ // Above DMs section, keyboard navigation
+ find: ".hasLibraryApplication()&&!",
+ replacement: [
+ {
+ match: /\i\.\i\.APPLICATION_STORE,/,
+ replace: "/*$&*/",
+ },
+ {
+ match: /\i\.\i\.COLLECTIBLES_SHOP,/,
+ replace: "/*$&*/",
+ },
+ ],
+ predicate: () => settings.store.removeShopAboveDM,
+ },
+ {
+ // Channel list server boost progress bar
+ find: "useGuildActionRow",
+ replacement: {
+ match: /(GUILD_NEW_MEMBER_ACTIONS_PROGRESS_BAR\)):(\i(?:\.premiumProgressBarEnabled)?)/,
+ replace: "$1:null"
+ },
+ predicate: () => settings.store.removeServerBoostInfo,
+ },
+ {
+ // Billing settings
+ find: ".BILLING_SECTION,",
+ replacement: {
+ match: /(?<=buildLayout:\(\)=>)\[.+?\]/,
+ replace: "[]",
+ },
+ predicate: () => settings.store.removeBillingSettings,
+ },
+ {
+ // Gift button
+ find: '"sticker")',
+ replacement: {
+ match: /&&\i\.push\(\([^&]*?,"gift"\)\)/,
+ replace: "",
+ },
+ predicate: () => settings.store.removeGiftButton,
+ },
+ {
+ // Emoji list
+ find: "#{intl::EMOJI_PICKER_EXPAND_EMOJI_SECTION}),size:",
+ replacement: {
+ match: /(\i)=\i\|\|!\i&&\i.\i.isEmojiCategoryNitroLocked\(\{[^}]*\}\);/,
+ replace: "$&$1||"
+ },
+ predicate: () => settings.store.removeUnavailableEmojiPicker,
+ },
+ {
+ // Emoji category list
+ find: "#{intl::EMOJI_CATEGORY_TOP_GUILD_EMOJI},{guildName:",
+ replacement: {
+ match: /(?<=(\i)\.unshift\((\i)\):)(?=\1\.push\(\2\))/,
+ replace: "$2.isNitroLocked||"
+ },
+ predicate: () => settings.store.removeUnavailableEmojiPicker,
+ }
+ ],
+});
diff --git a/src/equicordplugins/jumpTo/index.tsx b/src/equicordplugins/jumpTo/index.tsx
index e45c99d3a..bed0cb014 100644
--- a/src/equicordplugins/jumpTo/index.tsx
+++ b/src/equicordplugins/jumpTo/index.tsx
@@ -9,11 +9,10 @@ import { Devs } from "@utils/constants";
import { t } from "@utils/translation";
import definePlugin from "@utils/types";
import { Channel, Message, User } from "@vencord/discord-types";
-import { Constants, Menu, NavigationRouter, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts } from "@webpack/common";
+import { ChannelStore, Constants, Menu, NavigationRouter, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts } from "@webpack/common";
function jumpToFirstMessage(channelId: string, guildId?: string | null) {
- const url = `/channels/${guildId ?? "@me"}/${channelId}/0`;
- NavigationRouter.transitionTo(url);
+ NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/0`);
}
async function jumpToLastMessage(channelId: string, guildId?: string | null) {
@@ -21,12 +20,9 @@ async function jumpToLastMessage(channelId: string, guildId?: string | null) {
url: Constants.Endpoints.MESSAGES(channelId),
query: { limit: 1 }
});
-
const messageId = res.body?.[0]?.id;
if (!messageId) return;
-
- const url = `/channels/${guildId ?? "@me"}/${channelId}/${messageId}`;
- NavigationRouter.transitionTo(url);
+ NavigationRouter.transitionTo(`/channels/${guildId ?? "@me"}/${channelId}/${messageId}`);
}
async function jumpToUserMessage(channelId: string, guildId: string, userId: string, first: boolean) {
@@ -51,8 +47,7 @@ async function jumpToUserMessage(channelId: string, guildId: string, userId: str
return;
}
- const url = `/channels/${guildId}/${channelId}/${messageId}`;
- NavigationRouter.transitionTo(url);
+ NavigationRouter.transitionTo(`/channels/${guildId}/${channelId}/${messageId}`);
} catch (e) {
Toasts.show({
type: Toasts.Type.FAILURE,
@@ -62,28 +57,35 @@ async function jumpToUserMessage(channelId: string, guildId: string, userId: str
}
}
-const ChannelMenuPatch: NavContextMenuPatchCallback = (children, { channel }: { channel: Channel; }) => {
- if (!channel) return;
+const ChannelMenuPatch: NavContextMenuPatchCallback = (
+ children,
+ { channel, thread }: { channel?: Channel; thread?: Channel; }
+) => {
+ const selectedId = SelectedChannelStore.getChannelId();
+ const selectedChannel = selectedId ? ChannelStore.getChannel(selectedId) : null;
+ const forumChild = channel?.isForumLikeChannel?.() && selectedChannel?.isThread?.() && selectedChannel.parent_id === channel.id
+ ? selectedChannel
+ : null;
+ const targetChannel = thread ?? forumChild ?? channel;
+ if (!targetChannel) return;
children.push(
jumpToFirstMessage(channel.id, channel.guild_id)}
+ action={() => jumpToFirstMessage(targetChannel.id, targetChannel.guild_id)}
/>,
jumpToLastMessage(channel.id, channel.guild_id)}
+ action={() => jumpToLastMessage(targetChannel.id, targetChannel.guild_id)}
/>
);
};
const UserMenuPatch: NavContextMenuPatchCallback = (children, { user, channel }: { user: User; channel?: Channel; }) => {
if (!user) return;
-
if (!channel || channel.guild_id) return;
-
children.push(
{
if (!message) return;
-
const channelId = SelectedChannelStore.getChannelId();
const guildId = SelectedGuildStore.getGuildId();
if (!channelId || !guildId) return;
-
children.push(
, "children"> &
// https://github.com/Kyuuhachi/VencordPlugins/blob/main/MessageLinkTooltip/index.tsx#L11-L33
export const Spinner = findComponentByCodeLazy('"pulsingEllipsis"') as unknown as Spinner;
-export const QrCodeIcon = findComponentByCodeLazy("0v3ZM20");
-
export const cl = classNameFactory("qrlogin-");
diff --git a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx
index 2f7ce90fc..302640d56 100644
--- a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx
+++ b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx
@@ -5,6 +5,7 @@
*/
import { BaseText } from "@components/BaseText";
+import { QrCodeIcon } from "@components/Icons";
import { wrapTab } from "@components/settings";
import loginWithQR from "@equicordplugins/loginWithQR";
import { images } from "@equicordplugins/loginWithQR/images";
@@ -21,7 +22,7 @@ import {
import jsQR, { QRCode } from "jsqr";
import { MutableRefObject, ReactElement } from "react";
-import { cl, QrCodeIcon, Spinner, SpinnerTypes } from "..";
+import { cl, Spinner, SpinnerTypes } from "..";
import openVerifyModal from "./VerifyModal";
enum LoginStateType {
diff --git a/src/equicordplugins/messageLoggerEnhanced/db.ts b/src/equicordplugins/messageLoggerEnhanced/db.ts
index b4d8c7f73..8d80e0213 100644
--- a/src/equicordplugins/messageLoggerEnhanced/db.ts
+++ b/src/equicordplugins/messageLoggerEnhanced/db.ts
@@ -9,6 +9,7 @@ import { DBSchema, IDBPDatabase, openDB } from "idb";
import { LoggedMessageJSON } from "./types";
import { getMessageStatus } from "./utils";
+import { stripTransientRenderState } from "./utils/cleanUp";
import { DB_NAME, DB_VERSION } from "./utils/constants";
import { getAttachmentBlobUrl } from "./utils/saveImage";
@@ -61,6 +62,7 @@ async function cacheRecords(records: DBMessageRecord[]) {
async function cacheRecord(record?: DBMessageRecord | null) {
if (!record) return record;
+ stripTransientRenderState(record.message);
cachedMessages.set(record.message_id, record.message);
return record;
}
@@ -207,6 +209,8 @@ export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: strin
}
export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) {
+ stripTransientRenderState(message);
+
await db.put("messages", {
channel_id: message.channel_id,
message_id: message.id,
@@ -218,6 +222,8 @@ export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessag
}
export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) {
+ messages.forEach(stripTransientRenderState);
+
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
diff --git a/src/equicordplugins/messageLoggerEnhanced/index.tsx b/src/equicordplugins/messageLoggerEnhanced/index.tsx
index ebda9e31c..cf5a537f8 100644
--- a/src/equicordplugins/messageLoggerEnhanced/index.tsx
+++ b/src/equicordplugins/messageLoggerEnhanced/index.tsx
@@ -189,11 +189,18 @@ function messageCreateHandler(payload: MessageCreatePayload) {
async function processMessageFetch(response: FetchMessagesResponse) {
try {
- if (!response.ok || response.body.length === 0) {
+ if (!response.ok) {
Flogger.error("Failed to fetch messages", response);
return;
}
+ if (!Array.isArray(response.body)) {
+ Flogger.error("Failed to fetch messages: response body is not an array", response);
+ return;
+ }
+
+ if (response.body.length === 0) return;
+
const firstMessage = response.body[response.body.length - 1];
const messages = await idb.getMessagesByChannelAndAfterTimestampIDB(firstMessage.channel_id, firstMessage.timestamp);
diff --git a/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts b/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts
index 4e44c3c31..5948a28cf 100644
--- a/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts
+++ b/src/equicordplugins/messageLoggerEnhanced/utils/cleanUp.ts
@@ -22,8 +22,18 @@ import { MessageStore } from "@webpack/common";
import { LoggedMessageJSON, RefrencedMessage } from "../types";
import { getGuildIdByChannel, isGhostPinged } from "./index";
+export function stripTransientRenderState(message: any) {
+ // These runtime-only fields can contain React elements and must never be persisted.
+ delete message.customRenderedContent;
+ delete message.__messageloggerDiff;
+ delete message.__messageloggerDiffKey;
+ delete message.__messageloggerAggregated;
+ delete message.__messageloggerLastAppliedKey;
+}
+
export function cleanupMessage(message: any, removeDetails: boolean = true): LoggedMessageJSON {
const ret: LoggedMessageJSON = typeof message.toJS === "function" ? JSON.parse(JSON.stringify(message.toJS())) : { ...message };
+ stripTransientRenderState(ret);
if (removeDetails) {
ret.author.phone = undefined;
ret.author.email = undefined;
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 (
+
+ {urls.map((url, i) =>
+ url && (
+
+ );
+}
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.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/plugins/betterRoleContext/index.tsx b/src/plugins/betterRoleContext/index.tsx
index d4f45b7e2..942130415 100644
--- a/src/plugins/betterRoleContext/index.tsx
+++ b/src/plugins/betterRoleContext/index.tsx
@@ -19,6 +19,31 @@ const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild"
const DeveloperMode = getUserSettingLazy("appearance", "developerMode")!;
+async function openRoleIconModal(roleId: string, roleIcon: string, roleName: string) {
+ const format = settings.store.roleIconFileFormat;
+ const contentType = format === "jpg" ? "image/jpeg" : `image/${format}`;
+ const original = `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${roleId}/${roleIcon}.${format}`;
+ const url = original.replace(`//${window.GLOBAL_ENV.CDN_HOST}/`, "//media.discordapp.net/");
+
+ openImageModal({
+ url,
+ original,
+ height: 128,
+ width: 128,
+ contentType,
+ originalContentType: contentType,
+ sourceMetadata: {
+ identifier: {
+ type: "attachment",
+ attachmentId: roleId,
+ filename: `role-icon-${roleId}.${format}`,
+ title: roleName,
+ size: 1,
+ }
+ }
+ });
+}
+
function PencilIcon() {
return (