Add 4 Plugins

Added:
FriendCodes by HypedDomi
IconViewer by iamme
SpotifyLyrics by Joona
PanelSettings by nin0dev
This commit is contained in:
thororen1234 2025-03-10 14:07:29 -04:00
parent c5230c94b0
commit 220b44b4ed
No known key found for this signature in database
30 changed files with 3130 additions and 1 deletions

View file

@ -10,7 +10,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
### Extra included plugins ### Extra included plugins
<details> <details>
<summary>151 additional plugins</summary> <summary>155 additional plugins</summary>
### All Platforms ### All Platforms
- AllCallTimers by MaxHerbold & D3SOX - AllCallTimers by MaxHerbold & D3SOX
@ -55,6 +55,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- FixFileExtensions by thororen - FixFileExtensions by thororen
- FollowVoiceUser by TheArmagan - FollowVoiceUser by TheArmagan
- FrequentQuickSwitcher by Samwich - FrequentQuickSwitcher by Samwich
- FriendCodes by HypedDomi
- FriendshipRanks by Samwich - FriendshipRanks by Samwich
- FullVcPfp by mochie - FullVcPfp by mochie
- FriendTags by Samwich - FriendTags by Samwich
@ -71,6 +72,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- HomeTyping by Samwich - HomeTyping by Samwich
- HopOn by ImLvna - HopOn by ImLvna
- Husk by nin0dev - Husk by nin0dev
- IconViewer by iamme
- Identity by Samwich - Identity by Samwich
- IgnoreCalls by TheArmagan - IgnoreCalls by TheArmagan
- IgnoreTerms by D3SOX - IgnoreTerms by D3SOX
@ -126,6 +128,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- StatsfmRPC by Crxaw & vmohammad - StatsfmRPC by Crxaw & vmohammad
- Slap by Korbo - Slap by Korbo
- SoundBoardLogger by Moxxie, fres, echo, maintained by thororen - SoundBoardLogger by Moxxie, fres, echo, maintained by thororen
- SpotifyLyrics by Joona
- StatusPresets by iamme - StatusPresets by iamme
- SteamStatusSync by niko - SteamStatusSync by niko
- StickerBlocker by Samwich - StickerBlocker by Samwich
@ -145,6 +148,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- UwUifier by echo - UwUifier by echo
- VCSupport by thororen - VCSupport by thororen
- VCNarratorCustom by Loukios, ported by example-git - VCNarratorCustom by Loukios, ported by example-git
- VCPanelSettings by nin0dev
- VencordRPC by AutumnVN - VencordRPC by AutumnVN
- VideoSpeed by Samwich - VideoSpeed by Samwich
- ViewRawVariant (ViewRaw2) by Kyuuhachi - ViewRawVariant (ViewRaw2) by Kyuuhachi

View file

@ -0,0 +1,131 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { findByPropsLazy } from "@webpack";
import { Button, Clipboard, Flex, Forms, Parser, Text, useEffect, useState } from "@webpack/common";
import { FriendInvite } from "./types";
const FormStyles = findByPropsLazy("header", "title", "emptyState");
const { createFriendInvite, getAllFriendInvites, revokeFriendInvites } = findByPropsLazy("createFriendInvite");
function CopyButton({ copyText, copiedText, onClick }) {
const [copied, setCopied] = useState(false);
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setCopied(true);
setTimeout(() => setCopied(false), 1000);
onClick(e);
};
return (
<Button
onClick={handleButtonClick}
color={copied ? Button.Colors.GREEN : Button.Colors.BRAND}
size={Button.Sizes.SMALL}
look={Button.Looks.FILLED}
>
{copied ? copiedText : copyText}
</Button>
);
}
function FriendInviteCard({ invite }: { invite: FriendInvite; }) {
return (
<div className="vc-friend-codes-card">
<Flex justify={Flex.Justify.START}>
<div className="vc-friend-codes-card-title">
<Forms.FormTitle tag="h4" style={{ textTransform: "none" }}>
{invite.code}
</Forms.FormTitle>
<span>
Expires {Parser.parse(`<t:${new Date(invite.expires_at).getTime() / 1000}:R>`)} {invite.uses}/{invite.max_uses} uses
</span>
</div>
<Flex justify={Flex.Justify.END}>
<CopyButton
copyText="Copy"
copiedText="Copied!"
onClick={() => Clipboard.copy(`https://discord.gg/${invite.code}`)}
/>
</Flex>
</Flex>
</div>
);
}
export default function FriendCodesPanel() {
const [invites, setInvites] = useState<FriendInvite[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
getAllFriendInvites()
.then(setInvites)
.then(() => setLoading(false));
}, []);
return (
<>
<header className={FormStyles.header}>
<Forms.FormTitle
tag="h2"
className={FormStyles.title}
>
Your Friend Codes
</Forms.FormTitle>
<Flex
style={{ marginBottom: "16px" }}
justify={Flex.Justify.BETWEEN}
>
<h2 className="vc-friend-codes-info-header">{`Friend Codes - ${invites.length}`}</h2>
<Flex justify={Flex.Justify.END}>
<Button
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
onClick={() => createFriendInvite().then((invite: FriendInvite) => setInvites([...invites, invite]))}
>
Create Friend Code
</Button>
<Button
style={{ marginLeft: "8px" }}
color={Button.Colors.RED}
look={Button.Looks.OUTLINED}
disabled={!invites.length}
onClick={() => revokeFriendInvites().then(setInvites([]))}
>
Revoke all Friend Codes
</Button>
</Flex>
</Flex>
</header>
{loading ? (
<Text
variant="heading-md/semibold"
className="vc-friend-codes-text"
>
Loading...
</Text>
) : invites.length === 0 ? (
<Text
variant="heading-md/semibold"
className="vc-friend-codes-text"
>
You don't have any friend codes yet
</Text>
) : (
<div style={{ marginTop: "16px", display: "flex", flexWrap: "wrap", gap: "16px", justifyContent: "space-evenly" }}>
{invites.map(invite => (
<FriendInviteCard key={invite.code} invite={invite} />
))}
</div>
)}
</>
);
}

View file

@ -0,0 +1,29 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import definePlugin from "@utils/types";
import FriendCodesPanel from "./FriendCodesPanel";
import { Devs } from "@utils/constants";
export default definePlugin({
name: "FriendCodes",
description: "Generate FriendCodes to easily add friends",
authors: [Devs.HypedDomi],
patches: [
{
find: "#{intl::ADD_FRIEND})}),(",
replacement: {
match: /\.Fragment[^]*?children:\[[^]*?}\)/,
replace: "$&,$self.FriendCodesPanel"
}
}
],
get FriendCodesPanel() {
return <FriendCodesPanel />;
}
});

View file

@ -0,0 +1,34 @@
.vc-friend-codes-card {
padding: 20px;
margin-bottom: var(--custom-margin-margin-small);
border-width: 1px;
border-style: solid;
border-radius: 5px;
border-color: var(--background-tertiary);
background-color: var(--background-secondary);
}
.vc-friend-codes-card-title span {
color: var(--header-secondary);
font-family: var(--font-primary);
font-size: 14px;
font-weight: 400;
}
.vc-friend-codes-info-header {
margin-top: 16px;
margin-bottom: 8px;
color: var(--header-secondary);
text-transform: uppercase;
font-size: 12px;
line-height: 16px;
letter-spacing: .02em;
font-family: var(--font-display);
font-weight: 600;
}
.vc-friend-codes-text {
display: flex;
justify-content: center;
align-items: center;
}

View file

@ -0,0 +1,26 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface FriendInvite {
channel: null;
code: string;
created_at: string;
expires_at: string;
inviter: {
avatar: string;
avatar_decoration_data: unknown;
clan: unknown;
discriminator: string;
global_name: string;
id: string;
public_flags: number;
username: string;
};
max_age: number;
max_uses: number;
type: number;
uses: number;
}

View file

@ -0,0 +1,112 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CodeBlock } from "@components/CodeBlock";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import { Button, FluxDispatcher, TooltipContainer, useCallback, useEffect, useState } from "@webpack/common";
import * as t from "@webpack/types";
import { IconsFinds } from "./names";
import { openRawModal } from "./rawModal";
import { openSaveModal } from "./saveModal";
import { ModalHeaderTitle } from "./subComponents";
import { _cssColors, cssColors, iconSizes } from "./utils";
const defaultColor = 209;
function ModalComponent(props: { iconName: string; Icon: t.Icon; } & ModalProps) {
const [color, SetColor] = useState(defaultColor);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
if (e.key === "ArrowLeft") {
SetColor(color + -1);
} else if (e.key === "ArrowRight") {
SetColor(color + 1);
}
}
}, [color]);
const onColorChange = useCallback((e: { type: string; color: string; }) => {
SetColor(_cssColors.indexOf(e.color));
}, [color]);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
// @ts-ignore
FluxDispatcher.subscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
return () => {
document.removeEventListener("keydown", onKeyDown);
// @ts-ignore
FluxDispatcher.unsubscribe("ICONVIEWER_COLOR_CHANGE", onColorChange);
};
}, [onKeyDown]);
if (color < 0 || color >= cssColors.length) {
SetColor(0);
}
const { iconName, Icon } = props;
return (<ModalRoot {...props} size={ModalSize.DYNAMIC} className="vc-ic-modals-root vc-ic-icon-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="icon" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
{IconsFinds[iconName] ?
<div className="vc-icon-modal-codeblock">
<CodeBlock lang="ts" content={`const ${iconName + "Icon"} = findComponentByCode(${JSON.stringify(IconsFinds[iconName])})`} />
</div>
: null
}
<div className="vc-icon-modal-main-container">
<div className="vc-icon-display-box" aria-label={cssColors[color]?.name}>
<Icon className="vc-icon-modal-icon" color={cssColors[color]?.css} />
</div>
<div className="vc-icon-other-icon-sizes">
{iconSizes.map((size, idx) =>
<TooltipContainer text={`${size} size`} key={`vc-iv-size-${size}-${idx}`}>
<Icon className="vc-icon-modal-size-ex-icon" size={size} color={cssColors[color]?.css} style={{
marginLeft: "25px"
}} />
</TooltipContainer>
)}
</div>
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.BRAND}
onClick={() => openSaveModal(iconName, Icon, color)}
>
Save as
</Button>
<Button
color={Button.Colors.YELLOW}
className={classes(Margins.right8, "vc-iv-raw-modal-button")}
onClick={() => openRawModal(iconName, Icon, color)}
>
Raw
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openIconModal(iconName: string, Icon: t.Icon) {
openModal(props => <ModalComponent iconName={iconName} Icon={Icon} {...props} />);
}

View file

@ -0,0 +1,117 @@
.vc-icon-modal-codeblock {
margin-left: 10%;
margin-top: 30px;
}
.vc-icon-icon {
margin-left: 5%;
}
.vc-icon-modal-main-container {
display: flex;
}
.vc-ic-unordered-list li {
margin-left: 5%;
list-style: disc;
}
.vc-icon-other-icon-sizes {
height: 32px;
display: flex;
margin-top: 15%;
margin-left: 5%;
}
.vc-ic-icon-modal-root {
height: 450px;
width: 700px;
}
.vc-icons-tab-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 8px;
}
.vc-icon-modal-size-ex-icon {
margin-right: 5%;
}
.vc-icon-modal-icon {
height: 164px;
width: 164px;
}
.vc-icon-tab-search-bar-grid {
display: grid;
height: 50px;
gap: 10px;
grid-template-columns: 1fr 10px;
}
.vc-icon-display-box {
height: 164px;
width: 164px;
margin-top: 5%;
margin-left: 15%;
background-image: repeating-linear-gradient(
45deg,
#ffffff1a 0,
#ffffff1a 10px,
#0000001a 10px,
#0000001a 20px
);
border-radius: 10px;
}
.vc-icon-container {
margin-top: 5px;
padding: 15px;
border-radius: 5px;
border: 3px solid transparent;
box-sizing: border-box;
}
.vc-icon-container:hover {
border-radius: 5px;
border: 3px solid var(--background-tertiary);
box-sizing: border-box;
}
.vc-icon-title {
font-size: 0.8em;
margin-top: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vc-icon-modal-color-tooltip:hover {
background-color: var(--info-help-background);
}
.vc-save-modal {
margin-top: 10%;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
}
.vc-save-select-option-1 {
margin-bottom: 5%;
}
.vc-save-select-option-2 {
margin-top: 5%;
}
.vc-ic-modals-root {
border-radius: 25px;
}
.vc-ic-modals-footer {
border-bottom-left-radius: 25px;
border-bottom-right-radius: 25px;
}

View file

@ -0,0 +1,88 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./IconsTab.css";
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { Button, Clickable, Forms, React, TextInput, TooltipContainer } from "@webpack/common";
import * as t from "@webpack/types";
import { openIconModal } from "./IconModal";
import { getNameByIcon } from "./names";
import { findAllByCode, IconsDef } from "./utils";
export let Icons: IconsDef | null = null;
function searchMatch(search: string, name: string, Icon: t.Icon, searchbyFunction: boolean): boolean {
if (search === "") return true;
if (searchbyFunction) {
return String(Icon).includes(search);
}
const words = name.replace(/([A-Z]([a-z]+)?)/g, " $1").toLowerCase().split(" ");
const searchKeywords = search.toLowerCase().split(" ");
return searchKeywords.every(keyword => words.includes(keyword)) || words.every(keyword => searchKeywords.includes(keyword)) || name.toLowerCase().includes(search.toLowerCase());
}
function RenderIcons({ search, searchbyFunction }: { search: string; searchbyFunction: boolean; }) {
if (Icons === null) {
const OrgIcons = Array.from(new Set(findAllByCode("[\"size\",\"width\",\"height\",\"color\",\"colorClass\"]")));
Icons = Object.fromEntries(Object.keys(OrgIcons).map(k => [String(getNameByIcon(OrgIcons[k], k)), OrgIcons[k]])) as IconsDef;
}
return <div className="vc-icons-tab-grid-container">
{Object.entries(Icons).map(([iconName, Icon], index) =>
searchMatch(search, iconName, Icon, searchbyFunction) && <React.Fragment key={`iv-${iconName}`}>
<div className="vc-icon-box">
<Clickable onClick={() => openIconModal(iconName, Icon)}>
<div className="vc-icon-container">
<Icon className="vc-icon-icon" size="xxl" />
</div>
</Clickable>
<Forms.FormTitle className="vc-icon-title" tag="h3">{iconName}</Forms.FormTitle>
</div>
</React.Fragment>
)}</div>;
}
function IconsTab() {
const [search, setSearch] = React.useState<string>("");
const [searchByFunction, setSearchByFunction] = React.useState<boolean>(false);
const MemoRenderIcons = React.memo(RenderIcons);
return (
<SettingsTab title="Icons">
<div className={classes(Margins.top16, "vc-icon-tab-search-bar-grid")}>
<TextInput autoFocus value={search} placeholder="Search for an icon..." onChange={setSearch} />
<TooltipContainer text="Search by function context">
<Button
size={Button.Sizes.SMALL}
aria-label="Search by function context"
style={{ marginTop: "50%" }}
color={searchByFunction ? Button.Colors.GREEN : Button.Colors.PRIMARY}
onClick={() => setSearchByFunction(!searchByFunction)}
>Func</Button>
</TooltipContainer>
</div>
<MemoRenderIcons search={search} searchbyFunction={searchByFunction} />
</SettingsTab>
);
}
export default wrapTab(IconsTab, "IconsTab");

View file

@ -0,0 +1,54 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { EquicordDevs } from "@utils/constants";
import definePlugin, { StartAt } from "@utils/types";
import { SettingsRouter } from "@webpack/common";
import IconsTab from "./IconsTab";
import { SettingsAbout } from "./subComponents";
export default definePlugin({
name: "IconViewer",
description: "Adds a new tab to settings, to preview all icons",
authors: [EquicordDevs.iamme],
dependencies: ["Settings"],
startAt: StartAt.WebpackReady,
toolboxActions: {
"Open Icons Tab"() {
SettingsRouter.open("VencordDiscordIcons");
},
},
settingsAboutComponent: SettingsAbout,
start() {
const customSettingsSections = (
Vencord.Plugins.plugins.Settings as any as {
customSections: ((ID: Record<string, unknown>) => any)[];
}
).customSections;
const IconViewerSection = () => ({
section: "VencordDiscordIcons",
label: "Icons",
element: IconsTab,
className: "vc-discord-icons",
id: "IconViewer"
});
customSettingsSections.push(IconViewerSection);
},
stop() {
const customSettingsSections = (
Vencord.Plugins.plugins.Settings as any as {
customSections: ((ID: Record<string, unknown>) => any)[];
}
).customSections;
const i = customSettingsSections.findIndex(section => section({}).id === "IconViewer");
if (i !== -1) customSettingsSections.splice(i, 1);
},
});

View file

@ -0,0 +1,150 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as t from "@webpack/types";
// name: pattern
export const IconsFinds = {
Discord: "1.6 5.64-2.87",
XboxNeutral: "8.68-.62c.89-.81 1.5",
PlaystationNeutral: "2.04Zm-9.35",
TwitterNeutral: "M13.86 10.47", // even discord calls it twitter lmao
InstagramNeutral: "4.12.07Zm.1 2c-",
YoutubeNeutral: "11.5s0 3.95.5 5.85a3.",
FacebookNeutral: "2.8V12h2.8V9",
NintendoSwitchNeutral: "14V2.32c0",
Pencil: "0-2.82 0l-1.38 1.38a1",
AngleBrackets: "0-.4.8v1.98c0",
NitroWheel: "2h3a1 1 0 1 1 0 2H5.5",
Bill: "75.34.75.75C6 7.99 5 9",
Chat: "2.2 22H12Z\",",
ChatVoice: "22H12Zm2-5.26c0",
ChatX: ".23.46-.48.47L12 22H2.",
ChatSmile: "04-.61A10 10 0 1 1 22 ",
ChatRetry: "14.07.3.09.44.04a7",
ChatPlus: "1.24-.37V12a10 10 0 1 ",
Bug: "1.1.27.1.37 0a6.66 6.6",
B: "9V2.9c0-.5.4-.9.9-.9h7",
Eye: "19.1 21 12 21c-7.11 0-",
EyeSlash: "2.63-2.63c",
EyePlus: "3.32ZM19 14a1 ",
Heart: "0C9.43 20.48 1 15.09 1",
Star: ".73-2.25h6.12l1.9-5.83Z",
StarOutline: "3.1h5.26l1.62",
StarShooting: "1.35l2.95 2.14",
QrCode: "0v3ZM20",
Friends: "12h1a8",
PlusSmall: "0v-5h5a1",
CircleQuestion: "10.58l-3.3-3.3a1",
Pin: "1-.06-.63L6.16",
PinUpright: "5H8v4.35l-3.39",
PinUprightSlash: "1.56ZM11.08 ",
ArrowsLeftRight: "18.58V3a1",
XSmall: "13.42l5.3",
XLarge: "13.42l7.3 7.3Z",
XSmallBold: "12l4.94-4.94a1.5",
XLargeBold: "12l6.94-6.94a1.5",
Lock: "3Zm9-3v3H9V6a3",
LockUnlocked: "1-1.33-1.5ZM14",
Video: "1.45-.9V7.62a1",
VideoSlash: "1.4l20-20ZM9.2",
VideoLock: "1.32-.5V7.62a1",
Fire: "14Zm9.26-.84a.57.57",
Warning: "3.15H3.29c-1.74",
Download: "1.42l3.3 3.3V3a1",
Upload: "0ZM3 20a1 1",
// QuestionMark: "0ZM5.5 7a1.5" Unknown name
Quest: "10.47a.76.76",
Play: "4.96v14.08c0",
Emoji: " 0 0 0 0 22ZM6.5",
Gif: "3H5Zm2.18 13.8",
Trash: "2.81h8.36a3",
Bell: "9.5v2.09c0",
Screen: "0-3-3H5ZM13.5",
ScreenArrow: "3V5Zm16",
ScreenStream: " 2-2h3a2",
ScreenSystemRequirements: "3V5Zm3", // a guess
ScreenSlash: "5.8ZM17.15",
ScreenX: "1-3-3V5Zm6.3.3a1",
Plus: "0v8H3a1 1 0 1 0 0 2h8v8a1",
Id: "15h2.04V7.34H6V17Zm4",
Tv: "0-3-3H4ZM6 20a1",
Crown: "1.18l.82.82-3.61",
React: "04-4ZM16.96 4.08c",
Camera: "1.34 1.71 1.34H20a3",
Sticker: "1-.58.82l-4.24",
StageX: "13.07-1.38ZM16.7",
StageLock: "7.14-3.85ZM18.98",
Stage: "20.03c-.25.72.12",
ConnectionFine: "1 0 1 1-2 0A17 17 ",
ConnectionAverage: "\"M3 7a1 1 0 0",
ConnectionBad: "\"M2 13a1 1 0 0",
ConnectionUnknown: "15.86-.6.9-.2.02",
ChatWarning: ".54.5H2.2a1",
ChatCheck: "22H12c.22",
Hammer: "1.42ZM7.76",
StickerSmall: "1-.5.5H7a4",
StickerSad: "1.66-1.12 5.5",
StickerDeny: "\"M21.76 14.83a", // a guess
MagnifyingGlassPlus: "M11 7a1 1 0",
MagnifyingGlassMinus: "3v12H5.5a1.5 1.5",
// MagnifyingGlass: "???", // not quite possible
ChatArrowRight: "2.43l.06",
Bookmark: "1-1.67.74l",
ChannelList: "1-1-1ZM2 8a1",
ChannelListMagnifyingGlass: "2h18a1 1 0 1 0 0-2H3ZM2",
Activities: "1h3a3 3 0 0 0 3-3Z\"",
ActivitiesPlus: "14.35v1.29a",
AnnouncementsLock: "1-2.46-1.28 3.86",
AnnouncementsWarning: "1-2.46-1.28 3.85",
Announcements: ".42.27.79.62",
ShieldLock: "2.83v2.67a.5.5",
ShieldUser: "9.77V6.75c0-.57.17",
ShieldAt: "14.42-.35.75",
Shield: "M4.27 5.22A2.66", // a guess
Slash: "1-.43-.76L15.78",
SlashBox: "0-3-3H5Zm12.79",
Apps: "2.95H20a2 2 0",
CheckmarkLarge: "1.4l-12 12a1",
CheckmarkLargeBold: "2.12-2.12L9",
CheckmarkSmallBold: "13.88l6.94-6.94a1.5",
CheckmarkSmall: "1-1.4 0l-4-4a1",
DoubleCheckmark: "1.4l4.5 4.5a1",
NewUser: "0-.92h-.03a2", // a guess
UserCheck: "0l1.8-1.8c.17",
User: "2.9.06.24.26.",
UserMinus: "3-3h5.02c.38",
UserPlus: "2.07ZM12",
UserPlay: "0-3.61-.71h-.94Z",
UserBox: "0-3-3H5Zm10 6a3", // a guess
Settings: "0ZM16 12a4",
SettingsInfo: "10Zm1-4a1",
Hashtag: "8 4.84a1", // a guess
HashtagLocked: "2.02.31.03", // a guess
HashtagWarning: "8h1.26Z", // a guess
HashtagPlay: "52.88H9.85l", // a guess
Flag: "5.85v7.3a2",
Language: "5.43h3.85l",
Lightbulb: "8.5ZM15.1 19c.5",
Key: "23-.24ZM10 16a2",
InBox: "3H5ZM4 5.5C4",
BookmarkOutline: "0-1-1ZM7 2a3",
Food: "7.58V8a1 1"
};
// 13l4.91-8.05a1.8
export const namePatterns = new Map(Object.entries(IconsFinds).map(([name, pattern]) => [name, pattern]));
export function getNameByIcon(Icon: t.Icon, defaultName: any) {
for (const [name, pattern] of namePatterns) {
if (String(Icon).includes(pattern)) {
namePatterns.delete(name); // remove pattern from map after being found prevent overshadowing
return name;
}
}
return defaultName;
}

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CodeBlock } from "@components/CodeBlock";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import { Button, Toasts } from "@webpack/common";
import * as t from "@webpack/types";
import { ModalHeaderTitle } from "./subComponents";
function ModalComponent(props: { func: Function; iconName: string; color: number; } & ModalProps) {
const { func, iconName, color } = props;
return (<ModalRoot {...props} size={ModalSize.LARGE} className="vc-ic-modals-root vc-ic-raw-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="raw" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<div className="vc-iv-raw-modal">
<CodeBlock content={String(func)} lang="js" />
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.PRIMARY}
className={"vc-iv-raw-modal-copy-button"}
onClick={() => {
// silly typescript
// @ts-ignore
Clipboard.copy(String(func));
Toasts.show({
id: Toasts.genId(),
message: `Copied raw \`${iconName}\` to clipboard`,
type: Toasts.Type.SUCCESS
});
}}
>
Copy
</Button>
<Button
color={Button.Colors.YELLOW}
className={classes(Margins.right8, "vc-iv-log-to-console-button")}
onClick={() => { console.log(func); }}
>
log to console
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openRawModal(iconName: string, Icon: t.Icon, colorIndex: number) {
openModal(props => <ModalComponent iconName={iconName} func={Icon} color={colorIndex} {...props} />);
}

View file

@ -0,0 +1,153 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import {
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal
} from "@utils/modal";
import { Button, Forms, Select, TextInput, useCallback, useEffect, useState } from "@webpack/common";
import * as t from "@webpack/types";
import { ModalHeaderTitle } from "./subComponents";
import { convertComponentToHtml, cssColors, iconSizesInPx, saveIcon } from "./utils";
type IDivElement = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export function NumericComponent({ onChange, value, className, style }: { onChange: (value: number) => void, value: number; className?: string; style?: React.CSSProperties; }) {
const handleChange = (value: string) => {
const newValue = Number(value);
if (isNaN(newValue)) return;
onChange(newValue);
};
return (
<div className={className} style={style}>
<TextInput
type="number"
pattern="-?[0-9]+"
value={value}
placeholder="Enter a number"
onChange={handleChange}
/>
</div>
);
}
export function SelectComponent({ option, onChange, onError, className }: IDivElement & { option: any, onChange: (value: any) => void, onError: (msg: string | null) => void; className?: string; }) {
const [state, setState] = useState(option.options?.find(o => o.default)?.value ?? null);
const [error, setError] = useState<string | null>(null);
useEffect(() => onError(error), [error]);
const handleChange = (newValue: any) => {
const isValid = option.isValid?.call({}, newValue) ?? true;
if (!isValid) setError("Invalid input provided.");
else {
setError(null);
setState(newValue);
onChange(newValue);
}
};
return (<div className={className}>
<Select
options={option.options}
placeholder={"Select an option"}
maxVisibleItems={5}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === state}
serialize={v => String(v)}
/>
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
</div>);
}
function ModalComponent(props: { iconName: string, Icon: t.Icon; color: number; } & ModalProps) {
const [color, SetColor] = useState((props.color ?? 187));
const [iconSize, SetIconSize] = useState("lg");
const [saveType, SetSaveType] = useState("png");
const [customSize, SetCustomSize] = useState(32);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
if (e.key === "ArrowLeft") {
SetColor(color + -1);
} else if (e.key === "ArrowRight") {
SetColor(color + 1);
}
}
}, [color]);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
const { iconName, Icon } = props;
return (<ModalRoot {...props} size={ModalSize.MEDIUM} className="vc-ic-modals-root vc-ic-save-modal-root">
<ModalHeader>
<ModalHeaderTitle iconName={iconName} color={color} name="save" />
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent>
<div className="vc-save-modal">
<div className="vc-icon-display-box vc-save-modal-icon-display-box" aria-label={cssColors[color]?.name} style={{ marginLeft: "0", marginTop: "0" }}>
<Icon className="vc-icon-modal-icon" color={cssColors[color].css} />
</div>
<div className="vc-save-options" style={{ marginTop: "0", marginLeft: "0" }}>
<SelectComponent className="vc-save-select-option-1"
option={{
options: [
{ "label": "large", "value": "lg", "default": true },
{ "label": "medium", "value": "md" },
{ "label": "small", "value": "sm" },
{ "label": "extra small", "value": "xs" },
{ "label": "extra extra small", "value": "xxs" },
{ "label": "custom", "value": "custom" }
]
}} onChange={newValue => SetIconSize(newValue)} onError={() => { }} />
<NumericComponent style={{ visibility: iconSize === "custom" ? "visible" : "hidden" }} value={customSize} onChange={(value: number) => SetCustomSize(value)} />
<SelectComponent className="vc-save-select-option-2"
option={{
options: [
{ "label": "png", "value": "image/png", "default": true },
{ "label": "jpeg", "value": "image/jpeg" },
{ "label": "gif", "value": "image/gif" },
{ "label": "avif", "value": "image/avif" },
{ "label": "webp", "value": "image/webp" },
{ "label": "svg", "value": "image/svg+xml" },
]
}} onChange={newValue => SetSaveType(newValue)} onError={() => { }} />
</div>
</div>
</ModalContent>
<ModalFooter className="vc-ic-modals-footer">
<Button
color={Button.Colors.BRAND}
onClick={() => saveIcon(iconName,
saveType === "image/svg+xml" || document.querySelector(".vc-icon-modal-icon") == null ?
convertComponentToHtml(<Icon className="vc-icon-modal-icon" color={cssColors[color].css} />) :
document.querySelector(".vc-icon-modal-icon") as Element,
color, iconSizesInPx[iconSize] ?? customSize, saveType)}
>
Save
</Button>
</ModalFooter>
</ModalRoot>);
}
export function openSaveModal(iconName: string, Icon: t.Icon, colorIndex: number) {
openModal(props => <ModalComponent iconName={iconName} Icon={Icon} color={colorIndex} {...props} />);
}

View file

@ -0,0 +1,118 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getIntlMessage } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { Clickable, ContextMenuApi, FluxDispatcher, Forms, Menu, Text, TooltipContainer, useState } from "@webpack/common";
import type { ComponentPropsWithRef, PropsWithChildren } from "react";
import { _cssColors, cssColors } from "./utils";
function searchMatch(search: string, name: string): boolean {
if (search === "") return true;
const words = name.toLowerCase().split("_");
const searchKeywords = search.toLowerCase().split(" ").filter(keyword => keyword !== "");
return searchKeywords.every(keyword => words.includes(keyword)) || words.every(keyword => searchKeywords.includes(keyword)) || name.toLowerCase().includes(search.toLowerCase());
}
export type ClickableProps<T extends "a" | "div" | "span" | "li" = "div"> = PropsWithChildren<ComponentPropsWithRef<T>> & {
tag?: T;
};
export function IconTooltip({ children, copy, className, ...props }: ClickableProps & { children: string; copy: string; }) {
return <TooltipContainer text={"Click to copy"} className={className}>
<Clickable onClick={() => {
// @ts-ignore
Clipboard.copy(copy);
}} {...props}>{children}</Clickable>
</TooltipContainer>;
}
export const ModalHeaderTitle = ({ iconName, color, name }: { iconName: string; color: number; name: string; }) => {
return <Text variant="heading-lg/semibold"
style={{ flexGrow: 1, display: "flex" }}
className={classes("vc-ic-modal-header-title", `vc-ic-${name}-modal-header-title`)}>
<IconTooltip copy={iconName} className={classes(Margins.right8, "vc-icon-modal-color-tooltip")}>
{iconName}
</IconTooltip>
{" - "}
<IconTooltip copy={cssColors[color]?.css} className={classes(Margins.left8, "vc-icon-modal-color-tooltip")}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => {
const [query, setQuery] = useState("");
return (<Menu.Menu
navId="vc-ic-colors-header-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
color="danger"
aria-label="Icon Viewer Colors"
>
<Menu.MenuControlItem
id="vc-ic-colors-search"
control={(props, ref) => (
<Menu.MenuSearchControl
{...props}
query={query}
onChange={setQuery}
ref={ref}
placeholder={getIntlMessage("SEARCH")}
autoFocus
/>
)}
/>
{!!_cssColors.length && <Menu.MenuSeparator />}
{_cssColors.map(p => (
searchMatch(query, p) && <Menu.MenuItem
key={p}
id={p}
label={p}
action={() => {
// @ts-ignore
FluxDispatcher.dispatch({ type: "ICONVIEWER_COLOR_CHANGE", color: p });
}}
/>
))}
</Menu.Menu>);
});
}}>
{cssColors[color]?.name}
</IconTooltip>
</Text >;
};
export function SettingsAbout() {
return <>
<Forms.FormTitle tag="h3">Features</Forms.FormTitle>
<Forms.FormText>
<Text variant="heading-sm/normal">
<ul className="vc-ic-unordered-list">
<li>Preview icons</li>
<li>Copy icon names and CSS variables</li>
<li>Ability to download icons in different formats (SVG, PNG, GIF, etc.)</li>
<li>Copy pre-made icon finds for your plugins (Only some icons have this, submit finds either in a server or DMs)</li>
<li>Find icons by function context (helpful when creating finds)</li>
<li>Search for colors by right-clicking the color name in the modal title</li>
</ul>
</Text>
</Forms.FormText>
<Forms.FormTitle tag="h3">Special thanks</Forms.FormTitle>
<Forms.FormText>
<Text variant="heading-sm/normal" className="vc-ic-unordered-list">
<ul>
<li>krystalskullofficial._.</li>
<li>davr1</li>
<li>suffocate</li>
</ul>
</Text>
</Forms.FormText>
</>;
}

View file

@ -0,0 +1,100 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { saveFile } from "@utils/web";
import { filters, findAll, findByPropsLazy, waitFor } from "@webpack";
import { React, ReactDOM } from "@webpack/common";
import * as t from "@webpack/types";
export let _cssColors: string[] = [];
export type IconsDef = { [k: string]: t.Icon; };
export const iconSizesInPx = findByPropsLazy("md", "lg", "xxs");
export const Colors = findByPropsLazy("colors", "layout");
export const cssColors = new Proxy(
{
},
{
get: (target, key) => {
const colorKey = _cssColors[key];
return key in target
? target[key]
: Colors.colors[colorKey]?.css != null ? (target[key] = { name: colorKey.split("_").map((x: string) => x[0].toUpperCase() + x.toLowerCase().slice(1)).join(" "), css: Colors.colors[colorKey].css, key: colorKey }) : undefined;
},
set: (target, key, value) => {
target[key] = value;
return true;
}
}
) as unknown as Array<{ name: string; css: string; key: string; }>;
export const iconSizes = ["xxs", "xs", "sm", "md", "lg"];
const CrosspendingTypes: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/gif": "gif",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/avif": "avif"
};
export function saveIcon(iconName: string, icon: EventTarget & SVGSVGElement | Element | string, color: number, size: number, type = "image/png") {
const filename = `${iconName}-${cssColors[color]?.name ?? "unknown"}-${size}px.${CrosspendingTypes[type] ?? "png"}`;
if (typeof icon === "string") {
const file = new File([icon], filename, { type: "text/plain" });
saveFile(file);
return;
}
const innerElements = icon.children;
for (const el of innerElements) {
const fill = el.getAttribute("fill");
if (fill && fill.startsWith("var(")) {
el.setAttribute("fill", getComputedStyle(icon).getPropertyValue(fill.replace("var(", "").replace(")", "")));
}
}
// save svg as the given type
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, size, size);
const link = document.createElement("a");
link.download = filename;
link.href = canvas.toDataURL(type);
link.click();
};
img.src = `data:image/svg+xml;base64,${btoa(icon.outerHTML)}`;
}
export function convertComponentToHtml(component?: React.ReactElement): string {
const container = document.createElement("div");
const root = ReactDOM.createRoot(container);
ReactDOM.flushSync(() => root.render(component));
const content = container.innerHTML;
root.unmount();
return content;
}
export const findAllByCode = (code: string) => findAll(filters.byCode(code));
waitFor(["colors", "layout"], m => {
_cssColors = Object.keys(m.colors);
cssColors.length = _cssColors.length;
});

View file

@ -0,0 +1,132 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { getLyricsLrclib } from "./providers/lrclibAPI";
import { getLyricsSpotify } from "./providers/SpotifyAPI";
import { LyricsData, Provider, SyncedLyric } from "./providers/types";
import settings from "./settings";
const LyricsCacheKey = "SpotifyLyricsCacheNew";
interface NullLyricCacheEntry {
[Provider.Lrclib]?: boolean;
[Provider.Spotify]?: boolean;
}
const nullLyricCache = new Map<string, NullLyricCacheEntry>();
export const lyricFetchers = {
[Provider.Spotify]: async (track: Track) => await getLyricsSpotify(track.id),
[Provider.Lrclib]: getLyricsLrclib,
};
export const providers = Object.keys(lyricFetchers) as Provider[];
export async function getLyrics(track: Track | null): Promise<LyricsData | null> {
if (!track) return null;
const cacheKey = track.id;
const cached = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
if (cached?.[cacheKey]) {
return cached[cacheKey];
}
const nullCacheEntry = nullLyricCache.get(cacheKey);
if (nullCacheEntry) {
const provider = settings.store.LyricsProvider;
if (!settings.store.FallbackProvider && nullCacheEntry[provider]) {
return null;
}
if (nullCacheEntry[Provider.Spotify] && nullCacheEntry[Provider.Lrclib]) {
return null;
}
}
const providersToTry = [settings.store.LyricsProvider, ...providers.filter(p => p !== settings.store.LyricsProvider)];
for (const provider of providersToTry) {
const lyricsInfo = await lyricFetchers[provider](track);
if (lyricsInfo) {
await DataStore.set(LyricsCacheKey, { ...cached, [cacheKey]: lyricsInfo });
return lyricsInfo;
}
const updatedNullCacheEntry = nullLyricCache.get(cacheKey) || {};
nullLyricCache.set(cacheKey, { ...updatedNullCacheEntry, [provider]: true });
}
return null;
}
export async function clearLyricsCache() {
nullLyricCache.clear();
await DataStore.set(LyricsCacheKey, {});
}
export async function getLyricsCount(): Promise<number> {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
return Object.keys(cache).length;
}
export async function updateLyrics(trackId: string, newLyrics: SyncedLyric[], provider: Provider) {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
const current = cache[trackId];
await DataStore.set(LyricsCacheKey,
{
...cache, [trackId]: {
...current,
useLyric: provider,
lyricsVersions: {
...current?.lyricsVersions,
[provider]: newLyrics
}
}
}
);
}
export async function removeTranslations() {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
const newCache = {} as Record<string, LyricsData | null>;
for (const [trackId, trackData] of Object.entries(cache)) {
const { Translated, ...lyricsVersions } = trackData?.lyricsVersions || {};
const newUseLyric = !!lyricsVersions[Provider.Spotify] ? Provider.Spotify : Provider.Lrclib;
newCache[trackId] = { lyricsVersions, useLyric: newUseLyric };
}
await DataStore.set(LyricsCacheKey, newCache);
}
export async function migrateOldLyrics() {
const oldCache = await DataStore.get("SpotifyLyricsCache");
if (!oldCache || !Object.entries(oldCache).length) return;
const filteredCache = Object.entries(oldCache).filter(lrc => lrc[1]);
const result = {};
filteredCache.forEach(([trackId, lyrics]) => {
result[trackId] = {
lyricsVersions: {
// @ts-ignore
LRCLIB: lyrics.map(({ time, text }) => ({ time, text }))
},
useLyric: "LRCLIB"
};
});
await DataStore.set(LyricsCacheKey, result);
await DataStore.set("SpotifyLyricsCache", {});
}

View file

@ -0,0 +1,76 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { copyWithToast } from "@utils/misc";
import { findComponentByCodeLazy } from "@webpack";
import { FluxDispatcher, Menu } from "@webpack/common";
import { Provider } from "../providers/types";
import { useLyrics } from "./util";
const CopyIcon = findComponentByCodeLazy(" 1-.5.5H10a6");
const lyricsActualProviders = [Provider.Lrclib, Provider.Spotify];
const lyricsAlternative = [Provider.Translated, Provider.Romanized];
function ProviderMenuItem(toProvider: Provider, currentProvider?: Provider) {
return (
(!currentProvider || currentProvider !== toProvider) && (
<Menu.MenuItem
key={`switch-provider-${toProvider.toLowerCase()}`}
id={`switch-provider-${toProvider.toLowerCase()}`}
label={`Switch to ${toProvider}`}
action={() => {
FluxDispatcher.dispatch({
// @ts-ignore
type: "SPOTIFY_LYRICS_PROVIDER_CHANGE",
provider: toProvider,
});
}}
/>
)
);
}
export function LyricsContextMenu() {
const { lyricsInfo, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric];
const hasAShowingLyric = currLrcIndex !== null && currLrcIndex >= 0;
const hasLyrics = !!(lyricsInfo?.lyricsVersions[Provider.Lrclib] || lyricsInfo?.lyricsVersions[Provider.Spotify]);
return (
<Menu.Menu
navId="spotify-lyrics-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="Spotify Lyrics Menu"
>
{hasAShowingLyric && (
<Menu.MenuItem
key="copy-lyric"
id="copy-lyric"
label="Copy lyric"
action={() => {
copyWithToast(currentLyrics![currLrcIndex].text!, "Lyric copied!");
}}
icon={CopyIcon}
/>
)}
<Menu.MenuItem
navId="spotify-lyrics-provider"
id="spotify-lyrics-provider"
label="Lyrics Provider"
>
{lyricsActualProviders.map(provider =>
ProviderMenuItem(provider, lyricsInfo?.useLyric)
)}
{hasLyrics && lyricsAlternative.map(provider =>
ProviderMenuItem(provider, lyricsInfo?.useLyric)
)}
</Menu.MenuItem>
</Menu.Menu>
);
}

View file

@ -0,0 +1,94 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openModal } from "@utils/modal";
import { ContextMenuApi, React, Text, TooltipContainer, useEffect, useState, useStateFromStores } from "@webpack/common";
import { SpotifyLrcStore } from "../providers/store";
import settings from "../settings";
import { LyricsContextMenu } from "./ctxMenu";
import { LyricsModal } from "./modal";
import { cl, NoteSvg, useLyrics } from "./util";
function LyricsDisplay() {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { lyricsInfo, lyricRefs, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
const NoteElement = NoteSvg(cl("music-note"));
const makeClassName = (index: number): string => {
if (currLrcIndex === null) return "";
const diff = index - currLrcIndex;
if (diff === 0) return cl("current");
return cl(diff > 0 ? "next" : "prev");
};
if (!lyricsInfo) {
return ShowMusicNoteOnNoLyrics && (
<div className="vc-spotify-lyrics"
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
<TooltipContainer text="No synced lyrics found">
{NoteElement}
</TooltipContainer>
</div>
);
}
return (
<div
className="vc-spotify-lyrics"
onClick={() => openModal(props => <LyricsModal rootProps={props} />)}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
{currentLyrics?.map((line, i) => (
<div ref={lyricRefs[i]} key={i}>
<Text
variant={currLrcIndex === i ? "text-sm/normal" : "text-xs/normal"}
className={makeClassName(i)}
>
{line.text || NoteElement}
</Text>
</div>
))}
</div>
);
}
export function Lyrics() {
const track = useStateFromStores(
[SpotifyLrcStore],
() => SpotifyLrcStore.track,
null,
(prev, next) => (prev?.id ? prev.id === next?.id : prev?.name === next?.name)
);
const device = useStateFromStores(
[SpotifyLrcStore],
() => SpotifyLrcStore.device,
null,
(prev, next) => prev?.id === next?.id
);
const isPlaying = useStateFromStores([SpotifyLrcStore], () => SpotifyLrcStore.isPlaying);
const [shouldHide, setShouldHide] = useState(false);
useEffect(() => {
setShouldHide(false);
if (!isPlaying) {
const timeout = setTimeout(() => setShouldHide(true), 1000 * 60 * 5);
return () => clearTimeout(timeout);
}
}, [isPlaying]);
if (!track || !device?.is_active || shouldHide) return null;
return <LyricsDisplay />;
}

View file

@ -0,0 +1,78 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { openImageModal } from "@utils/discord";
import { ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { React, Text } from "@webpack/common";
import { SpotifyStore, Track } from "plugins/spotifyControls/SpotifyStore";
import { cl, NoteSvg, scrollClasses, useLyrics } from "./util";
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
function ModalHeaderContent({ track }: { track: Track; }) {
return (
<ModalHeader>
<div className={cl("header-content")}>
{track?.album?.image?.url && (
<img
src={track.album.image.url}
alt={track.album.name}
className={cl("album-image")}
onClick={() => openImageModal({
url: track.album.image.url,
width: track.album.image.width,
height: track.album.image.height,
})}
/>
)}
<div>
<Text selectable variant="text-sm/semibold">{track.name}</Text>
<Text selectable variant="text-sm/normal">by {track.artists.map(a => a.name).join(", ")}</Text>
<Text selectable variant="text-sm/normal">on {track.album.name}</Text>
</div>
</div>
</ModalHeader>
);
}
export function LyricsModal({ rootProps }: { rootProps: ModalProps; }) {
const { track, lyricsInfo, currLrcIndex } = useLyrics();
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
return (
<ModalRoot {...rootProps}>
<ModalHeaderContent track={track} />
<ModalContent>
<div className={cl("lyrics-modal-container") + ` ${scrollClasses.auto}`}>
{currentLyrics ? (
currentLyrics.map((line, i) => (
<Text
key={i}
variant={currLrcIndex === i ? "text-md/semibold" : "text-sm/normal"}
selectable
className={currLrcIndex === i ? cl("modal-line-current") : cl("modal-line")}
>
<span className={cl("modal-timestamp")} onClick={() => SpotifyStore.seek(line.time * 1000)}>
{formatTime(line.time)}
</span>
{line.text || NoteSvg(cl("modal-note"))}
</Text>
))
) : (
<Text variant="text-sm/normal" className={cl("modal-no-lyrics")}>
No lyrics available :(
</Text>
)}
</div>
</ModalContent>
</ModalRoot>
);
}

View file

@ -0,0 +1,92 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { findByPropsLazy } from "@webpack";
import { React, useEffect, useState, useStateFromStores } from "@webpack/common";
import { SpotifyLrcStore } from "../providers/store";
import { SyncedLyric } from "../providers/types";
import settings from "../settings";
export const scrollClasses = findByPropsLazy("auto", "customTheme");
export const cl = classNameFactory("vc-spotify-lyrics-");
export function NoteSvg(className: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 480 720" fill="currentColor" className={className} >
<path d="m160,-240 q -66,0 -113,-47 -47,-47 -47,-113 0,-66 47,-113 47,-47 113,-47 23,0 42.5,5.5 19.5,5.5 37.5,16.5 v -422 h 240 v 160 H 320 v 400 q 0,66 -47,113 -47,47 -113,47 z" />
</svg>
);
}
const calculateIndexes = (lyrics: SyncedLyric[], position: number, delay: number) => {
const posInSec = position / 1000;
const currentIndex = lyrics.findIndex(l => l.time - (delay / 1000) > posInSec && l.time < posInSec + 8) - 1;
const nextLyric = lyrics.findIndex(l => l.time >= posInSec);
return [currentIndex, nextLyric];
};
export function useLyrics() {
const [track, storePosition, isPlaying, lyricsInfo] = useStateFromStores(
[SpotifyLrcStore],
() => [
SpotifyLrcStore.track!,
SpotifyLrcStore.mPosition,
SpotifyLrcStore.isPlaying,
SpotifyLrcStore.lyricsInfo
]
);
const { LyricDelay } = settings.use(["LyricDelay"]);
const [currLrcIndex, setCurrLrcIndex] = useState<number | null>(null);
const [nextLyric, setNextLyric] = useState<number | null>(null);
const [position, setPosition] = useState(storePosition);
const [lyricRefs, setLyricRefs] = useState<React.RefObject<HTMLDivElement | null>[]>([]);
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
useEffect(() => {
if (currentLyrics) {
setLyricRefs(currentLyrics.map(() => React.createRef()));
}
}, [currentLyrics]);
useEffect(() => {
if (currentLyrics && position) {
const [currentIndex, nextLyric] = calculateIndexes(currentLyrics, position, LyricDelay);
setCurrLrcIndex(currentIndex);
setNextLyric(nextLyric);
}
}, [currentLyrics, position]);
useEffect(() => {
if (currLrcIndex !== null) {
if (currLrcIndex >= 0) {
lyricRefs[currLrcIndex].current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
if (currLrcIndex < 0 && nextLyric !== null) {
lyricRefs[nextLyric]?.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, [currLrcIndex, nextLyric]);
useEffect(() => {
if (isPlaying) {
setPosition(SpotifyLrcStore.position);
const interval = setInterval(() => {
setPosition(p => p + 1000);
}, 1000);
return () => clearInterval(interval);
}
}, [storePosition, isPlaying]);
return { track, lyricsInfo, lyricRefs, currLrcIndex, nextLyric };
}

View file

@ -0,0 +1,61 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { Player } from "plugins/spotifyControls/PlayerComponent";
import { migrateOldLyrics } from "./api";
import { Lyrics } from "./components/lyrics";
import settings from "./settings";
export default definePlugin({
name: "SpotifyLyrics",
authors: [Devs.Joona],
description: "Adds lyrics to SpotifyControls",
dependencies: ["SpotifyControls"],
patches: [
{
find: "this.isCopiedStreakGodlike",
replacement: {
match: /Vencord.Plugins.plugins\["SpotifyControls"\].PanelWrapper/,
replace: "$self.FakePanelWrapper",
},
predicate: () => Settings.plugins.SpotifyControls.enabled,
noWarn: true,
},
],
FakePanelWrapper({ VencordOriginal, ...props }) {
const { LyricsPosition } = settings.use(["LyricsPosition"]);
return (
<>
<ErrorBoundary
fallback={() => (
<div className="vc-spotify-fallback">
<p>Failed to render Spotify Lyrics Modal :(</p>
<p>Check the console for errors</p>
</div>
)}
>
{LyricsPosition === "above" && <Lyrics />}
<Player />
{LyricsPosition === "below" && <Lyrics />}
</ErrorBoundary>
<VencordOriginal {...props} />
</>
);
},
settings,
async start() {
await migrateOldLyrics();
},
});

View file

@ -0,0 +1,49 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { LyricsData, Provider } from "../types";
interface LyricsAPIResp {
error: boolean;
syncType: string;
lines: Line[];
}
interface Line {
startTimeMs: string;
words: string;
syllables: any[];
endTimeMs: string;
}
export async function getLyricsSpotify(trackId: string): Promise<LyricsData | null> {
const resp = await fetch("https://spotify-lyrics-api-pi.vercel.app/?trackid=" + trackId);
if (!resp.ok) return null;
let data: LyricsAPIResp;
try {
data = await resp.json() as LyricsAPIResp;
} catch (e) {
return null;
}
const lyrics = data.lines;
if (lyrics[0].startTimeMs === "0" && lyrics[lyrics.length - 1].startTimeMs === "0") return null;
return {
useLyric: Provider.Spotify,
lyricsVersions: {
Spotify: lyrics.map(line => {
const trimmedText = line.words.trim();
return {
time: Number(line.startTimeMs) / 1000,
text: (trimmedText === "" || trimmedText === "♪") ? null : trimmedText
};
})
}
};
}

View file

@ -0,0 +1,67 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { LyricsData, Provider } from "../types";
const baseUrlLrclib = "https://lrclib.net/api/get";
interface LrcLibResponse {
id: number;
name: string;
trackName: string;
artistName: string;
albumName: string;
duration: number;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
function lyricTimeToSeconds(time: string) {
const [minutes, seconds] = time.slice(1, -1).split(":").map(Number);
return minutes * 60 + seconds;
}
export async function getLyricsLrclib(track: Track): Promise<LyricsData | null> {
const info = {
track_name: track.name,
artist_name: track.artists[0].name,
album_name: track.album.name,
duration: track.duration / 1000
};
const params = new URLSearchParams(info as any);
const url = `${baseUrlLrclib}?${params.toString()}`;
const response = await fetch(url, {
headers: {
"User-Agent": "https://github.com/Masterjoona/vc-spotifylyrics"
}
});
if (!response.ok) return null;
const data = await response.json() as LrcLibResponse;
if (!data.syncedLyrics) return null;
const lyrics = data.syncedLyrics;
const lines = lyrics.split("\n");
return {
useLyric: Provider.Lrclib,
lyricsVersions: {
LRCLIB: lines.map(line => {
const [lrcTime, text] = line.split("]");
const trimmedText = text.trim();
return {
time: lyricTimeToSeconds(lrcTime),
text: (trimmedText === "" || trimmedText === "♪") ? null : trimmedText
};
})
}
};
}

View file

@ -0,0 +1,152 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotification } from "@api/Notifications";
import { proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
import { Track } from "plugins/spotifyControls/SpotifyStore";
import { getLyrics, lyricFetchers, updateLyrics } from "../api";
import settings from "../settings";
import { romanizeLyrics, translateLyrics } from "./translator";
import { LyricsData, Provider } from "./types";
interface PlayerStateMin {
track: Track | null;
device?: Device;
isPlaying: boolean,
position: number,
}
interface Device {
id: string;
is_active: boolean;
}
function showNotif(title: string, body: string) {
if (settings.store.ShowFailedToasts) {
showNotification({
color: "#ee2902",
title,
body,
noPersist: true
});
}
}
// steal from spotifycontrols
export const SpotifyLrcStore = proxyLazyWebpack(() => {
class SpotifyLrcStore extends Flux.Store {
public mPosition = 0;
private start = 0;
public track: Track | null = null;
public device: Device | null = null;
public isPlaying = false;
public lyricsInfo: LyricsData | null = null;
public fetchingsTracks: string[] = [];
public get position(): number {
let pos = this.mPosition;
if (this.isPlaying) {
pos += Date.now() - this.start;
}
return pos;
}
public set position(p: number) {
this.mPosition = p;
this.start = Date.now();
}
}
const store = new SpotifyLrcStore(FluxDispatcher, {
async SPOTIFY_PLAYER_STATE(e: PlayerStateMin) {
if (store.fetchingsTracks.includes(e.track?.id ?? "")) return;
store.fetchingsTracks.push(e.track?.id ?? "");
store.track = e.track;
store.isPlaying = e.isPlaying ?? false;
store.position = e.position ?? 0;
store.device = e.device ?? null;
store.lyricsInfo = await getLyrics(e.track);
const { LyricsConversion } = settings.store;
if (LyricsConversion !== Provider.None) {
// @ts-ignore
FluxDispatcher.dispatch({ type: "SPOTIFY_LYRICS_PROVIDER_CHANGE", provider: LyricsConversion });
}
store.fetchingsTracks = store.fetchingsTracks.filter(id => id !== e.track?.id);
store.emitChange();
},
SPOTIFY_SET_DEVICES({ devices }: { devices: Device[]; }) {
store.device = devices.find(d => d.is_active) ?? devices[0] ?? null;
store.emitChange();
},
// @ts-ignore
async SPOTIFY_LYRICS_PROVIDER_CHANGE(e: { provider: Provider; }) {
const currentInfo = await getLyrics(store.track);
const { provider } = e;
if (currentInfo?.useLyric === provider) return;
if (currentInfo?.lyricsVersions[provider]) {
store.lyricsInfo = { ...currentInfo, useLyric: provider };
await updateLyrics(store.track!.id, currentInfo.lyricsVersions[provider]!, provider);
store.emitChange();
return;
}
if (provider === Provider.Translated || provider === Provider.Romanized) {
if (!currentInfo?.useLyric) {
showNotif("No lyrics", `No lyrics to ${provider === Provider.Translated ? "translate" : "romanize"}`);
return;
}
const fetcher = provider === Provider.Translated ? translateLyrics : romanizeLyrics;
const fetchResult = await fetcher(currentInfo.lyricsVersions[currentInfo.useLyric]);
if (!fetchResult) {
showNotif("Lyrics fetch failed", `Failed to fetch ${provider === Provider.Translated ? "translation" : "romanization"}`);
return;
}
store.lyricsInfo = {
...currentInfo,
useLyric: provider,
lyricsVersions: {
...currentInfo.lyricsVersions,
[Provider.Translated]: fetchResult
}
};
await updateLyrics(store.track!.id, fetchResult, provider);
store.emitChange();
return;
}
const newLyricsInfo = await lyricFetchers[e.provider](store.track!);
if (!newLyricsInfo) {
showNotif("Lyrics fetch failed", `Failed to fetch ${e.provider} lyrics`);
return;
}
store.lyricsInfo = newLyricsInfo;
updateLyrics(store.track!.id, newLyricsInfo.lyricsVersions[e.provider], e.provider);
store.emitChange();
}
});
return store;
});

View file

@ -0,0 +1,90 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import settings from "../../settings";
import { LyricsData, Provider, SyncedLyric } from "../types";
// stolen from src\plugins\translate\utils.ts
interface GoogleData {
src: string;
sentences: {
// 🏳️‍⚧️
trans: string;
orig: string;
src_translit?: string;
}[];
}
async function googleTranslate(text: string, targetLang: string, romanize: boolean): Promise<GoogleData | null> {
const url = "https://translate.googleapis.com/translate_a/single?" + new URLSearchParams({
// see https://stackoverflow.com/a/29537590 for more params
// holy shidd nvidia
client: "gtx",
// source language
sl: "auto",
// target language
tl: targetLang,
// what to return, t = translation probably
dt: romanize ? "rm" : "t",
// Send json object response instead of weird array
dj: "1",
source: "input",
// query, duh
q: text
});
const res = await fetch(url);
if (!res.ok)
return null;
return await res.json();
}
async function processLyrics(
lyrics: LyricsData["lyricsVersions"][Provider],
targetLang: string,
romanize: boolean
): Promise<SyncedLyric[] | null> {
if (!lyrics) return null;
const nonDuplicatedLyrics = lyrics.filter((lyric, index, self) =>
self.findIndex(l => l.text === lyric.text) === index
);
const processedLyricsResp = await Promise.all(
nonDuplicatedLyrics.map(async lyric => {
if (!lyric.text) return [lyric.text, null];
const translation = await googleTranslate(lyric.text, targetLang, romanize);
if (!translation || !translation.sentences || translation.sentences.length === 0) return [lyric.text, null];
return [lyric.text, romanize ? translation.sentences[0].src_translit : translation.sentences[0].trans];
})
);
if (processedLyricsResp[0][1] === null) return null;
return lyrics.map(lyric => ({
...lyric,
text: processedLyricsResp.find(mapping => mapping[0] === lyric.text)?.[1] ?? lyric.text
}));
}
export async function translateLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
const language = settings.store.TranslateTo;
// Why not make only one request to translate?
// because occasionally it will add a new line
// and i dont have a good way to handle that
return processLyrics(lyrics, language, false);
}
export async function romanizeLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
// Why not make only one request to romanize?
// it will romanize it as one string, and how would i know where to split it?
return processLyrics(lyrics, "", true);
}

View file

@ -0,0 +1,546 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// hate having this twice but oh well
export default [
{
value: "auto",
label: "Detect language"
},
{
value: "af",
label: "Afrikaans"
},
{
value: "sq",
label: "Albanian"
},
{
value: "am",
label: "Amharic"
},
{
value: "ar",
label: "Arabic"
},
{
value: "hy",
label: "Armenian"
},
{
value: "as",
label: "Assamese"
},
{
value: "ay",
label: "Aymara"
},
{
value: "az",
label: "Azerbaijani"
},
{
value: "bm",
label: "Bambara"
},
{
value: "eu",
label: "Basque"
},
{
value: "be",
label: "Belarusian"
},
{
value: "bn",
label: "Bengali"
},
{
value: "bho",
label: "Bhojpuri"
},
{
value: "bs",
label: "Bosnian"
},
{
value: "bg",
label: "Bulgarian"
},
{
value: "ca",
label: "Catalan"
},
{
value: "ceb",
label: "Cebuano"
},
{
value: "ny",
label: "Chichewa"
},
{
value: "zh-CN",
label: "Chinese (Simplified)"
},
{
value: "zh-TW",
label: "Chinese (Traditional)"
},
{
value: "co",
label: "Corsican"
},
{
value: "hr",
label: "Croatian"
},
{
value: "cs",
label: "Czech"
},
{
value: "da",
label: "Danish"
},
{
value: "dv",
label: "Dhivehi"
},
{
value: "doi",
label: "Dogri"
},
{
value: "nl",
label: "Dutch"
},
{
value: "en",
label: "English",
default: true
},
{
value: "eo",
label: "Esperanto"
},
{
value: "et",
label: "Estonian"
},
{
value: "ee",
label: "Ewe"
},
{
value: "tl",
label: "Filipino"
},
{
value: "fi",
label: "Finnish"
},
{
value: "fr",
label: "French"
},
{
value: "fy",
label: "Frisian"
},
{
value: "gl",
label: "Galician"
},
{
value: "ka",
label: "Georgian"
},
{
value: "de",
label: "German"
},
{
value: "el",
label: "Greek"
},
{
value: "gn",
label: "Guarani"
},
{
value: "gu",
label: "Gujarati"
},
{
value: "ht",
label: "Haitian Creole"
},
{
value: "ha",
label: "Hausa"
},
{
value: "haw",
label: "Hawaiian"
},
{
value: "iw",
label: "Hebrew"
},
{
value: "hi",
label: "Hindi"
},
{
value: "hmn",
label: "Hmong"
},
{
value: "hu",
label: "Hungarian"
},
{
value: "is",
label: "Icelandic"
},
{
value: "ig",
label: "Igbo"
},
{
value: "ilo",
label: "Ilocano"
},
{
value: "id",
label: "Indonesian"
},
{
value: "ga",
label: "Irish"
},
{
value: "it",
label: "Italian"
},
{
value: "ja",
label: "Japanese"
},
{
value: "jw",
label: "Javanese"
},
{
value: "kn",
label: "Kannada"
},
{
value: "kk",
label: "Kazakh"
},
{
value: "km",
label: "Khmer"
},
{
value: "rw",
label: "Kinyarwanda"
},
{
value: "gom",
label: "Konkani"
},
{
value: "ko",
label: "Korean"
},
{
value: "kri",
label: "Krio"
},
{
value: "ku",
label: "Kurdish (Kurmanji)"
},
{
value: "ckb",
label: "Kurdish (Sorani)"
},
{
value: "ky",
label: "Kyrgyz"
},
{
value: "lo",
label: "Lao"
},
{
value: "la",
label: "Latin"
},
{
value: "lv",
label: "Latvian"
},
{
value: "ln",
label: "Lingala"
},
{
value: "lt",
label: "Lithuanian"
},
{
value: "lg",
label: "Luganda"
},
{
value: "lb",
label: "Luxembourgish"
},
{
value: "mk",
label: "Macedonian"
},
{
value: "mai",
label: "Maithili"
},
{
value: "mg",
label: "Malagasy"
},
{
value: "ms",
label: "Malay"
},
{
value: "ml",
label: "Malayalam"
},
{
value: "mt",
label: "Maltese"
},
{
value: "mi",
label: "Maori"
},
{
value: "mr",
label: "Marathi"
},
{
value: "mni-Mtei",
label: "Meiteilon (Manipuri)"
},
{
value: "lus",
label: "Mizo"
},
{
value: "mn",
label: "Mongolian"
},
{
value: "my",
label: "Myanmar (Burmese)"
},
{
value: "ne",
label: "Nepali"
},
{
value: "no",
label: "Norwegian"
},
{
value: "or",
label: "Odia (Oriya)"
},
{
value: "om",
label: "Oromo"
},
{
value: "ps",
label: "Pashto"
},
{
value: "fa",
label: "Persian"
},
{
value: "pl",
label: "Polish"
},
{
value: "pt",
label: "Portuguese"
},
{
value: "pa",
label: "Punjabi"
},
{
value: "qu",
label: "Quechua"
},
{
value: "ro",
label: "Romanian"
},
{
value: "ru",
label: "Russian"
},
{
value: "sm",
label: "Samoan"
},
{
value: "sa",
label: "Sanskrit"
},
{
value: "gd",
label: "Scots Gaelic"
},
{
value: "nso",
label: "Sepedi"
},
{
value: "sr",
label: "Serbian"
},
{
value: "st",
label: "Sesotho"
},
{
value: "sn",
label: "Shona"
},
{
value: "sd",
label: "Sindhi"
},
{
value: "si",
label: "Sinhala"
},
{
value: "sk",
label: "Slovak"
},
{
value: "sl",
label: "Slovenian"
},
{
value: "so",
label: "Somali"
},
{
value: "es",
label: "Spanish"
},
{
value: "su",
label: "Sundanese"
},
{
value: "sw",
label: "Swahili"
},
{
value: "sv",
label: "Swedish"
},
{
value: "tg",
label: "Tajik"
},
{
value: "ta",
label: "Tamil"
},
{
value: "tt",
label: "Tatar"
},
{
value: "te",
label: "Telugu"
},
{
value: "th",
label: "Thai"
},
{
value: "ti",
label: "Tigrinya"
},
{
value: "ts",
label: "Tsonga"
},
{
value: "tr",
label: "Turkish"
},
{
value: "tk",
label: "Turkmen"
},
{
value: "ak",
label: "Twi"
},
{
value: "uk",
label: "Ukrainian"
},
{
value: "ur",
label: "Urdu"
},
{
value: "ug",
label: "Uyghur"
},
{
value: "uz",
label: "Uzbek"
},
{
value: "vi",
label: "Vietnamese"
},
{
value: "cy",
label: "Welsh"
},
{
value: "xh",
label: "Xhosa"
},
{
value: "yi",
label: "Yiddish"
},
{
value: "yo",
label: "Yoruba"
},
{
value: "zu",
label: "Zulu"
}
];

View file

@ -0,0 +1,23 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface SyncedLyric {
time: number;
text: string | null;
}
export enum Provider {
Lrclib = "LRCLIB",
Spotify = "Spotify",
Translated = "Translated",
Romanized = "Romanized",
None = "None",
}
export interface LyricsData {
lyricsVersions: Partial<Record<Provider, SyncedLyric[] | null>>;
useLyric: Provider;
}

View file

@ -0,0 +1,143 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { makeRange, SettingSliderComponent } from "@components/PluginSettings/components";
import { useAwaiter } from "@utils/react";
import { OptionType } from "@utils/types";
import { Button, showToast, Text, Toasts } from "@webpack/common";
import { clearLyricsCache, getLyricsCount, removeTranslations } from "./api";
import { Lyrics } from "./components/lyrics";
import { useLyrics } from "./components/util";
import languages from "./providers/translator/languages";
import { Provider } from "./providers/types";
const sliderOptions = {
markers: makeRange(-2500, 2500, 250),
stickToMarkers: true,
};
function Details() {
const { lyricsInfo } = useLyrics();
const [count, error, loading] = useAwaiter(getLyricsCount, {
onError: () => console.error("Failed to get lyrics count"),
fallbackValue: null,
});
return (
<>
<Text>Current lyrics provider: {lyricsInfo?.useLyric || "None"}</Text>
{loading ? <Text>Loading lyrics count...</Text> : error ? <Text>Failed to get lyrics count</Text> : <Text>Lyrics count: {count}</Text>}
</>
);
}
const settings = definePluginSettings({
ShowMusicNoteOnNoLyrics: {
description: "Show a music note icon when no lyrics are found",
type: OptionType.BOOLEAN,
default: true,
},
LyricsPosition: {
description: "Position of the lyrics",
type: OptionType.SELECT,
options: [
{ value: "above", label: "Above SpotifyControls" },
{ value: "below", label: "Below SpotifyControls", default: true },
],
},
LyricsProvider: {
description: "Where lyrics are fetched from",
type: OptionType.SELECT,
options: [
{ value: Provider.Spotify, label: "Spotify (Musixmatch)", default: true },
{ value: Provider.Lrclib, label: "LRCLIB" },
],
},
FallbackProvider: {
description: "When a lyrics provider fails, try other providers",
type: OptionType.BOOLEAN,
default: true,
},
TranslateTo: {
description: "Translate lyrics to - Changing this will clear existing translations",
type: OptionType.SELECT,
options: languages,
onChange: async () => {
await removeTranslations();
showToast("Translations cleared", Toasts.Type.SUCCESS);
}
},
LyricsConversion: {
description: "Automatically translate or romanize lyrics",
type: OptionType.SELECT,
options: [
{ value: Provider.None, label: "None", default: true },
{ value: Provider.Translated, label: "Translate" },
{ value: Provider.Romanized, label: "Romanize" },
]
},
ShowFailedToasts: {
description: "Hide toasts when lyrics fail to fetch",
type: OptionType.BOOLEAN,
default: true,
},
LyricDelay: {
description: "",
type: OptionType.SLIDER,
default: 0,
hidden: true,
...sliderOptions
},
Display: {
description: "",
type: OptionType.COMPONENT,
component: () => (
<>
<SettingSliderComponent
option={{ ...sliderOptions } as any}
onChange={v => {
settings.store.LyricDelay = v;
}}
pluginSettings={Vencord.Settings.plugins.SpotifyLyrics}
id={"LyricDelay"}
onError={() => { }}
/>
<Lyrics />
</>
)
},
Details: {
description: "",
type: OptionType.COMPONENT,
component: () => <Details />,
},
PurgeLyricsCache: {
description: "Purge the lyrics cache",
type: OptionType.COMPONENT,
component: () => (
<Button
color={Button.Colors.RED}
onClick={() => {
clearLyricsCache();
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
}}
>
Purge Cache
</Button>
),
},
TestingCache: {
description: "Save songs to a testing cache instead",
type: OptionType.BOOLEAN,
default: false,
hidden: true,
}
});
export default settings;

View file

@ -0,0 +1,86 @@
.vc-spotify-lyrics {
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--background-modifier-accent);
text-align: center;
overflow: hidden;
max-height: 60px;
cursor: pointer;
}
.vc-spotify-lyrics-music-note {
font-size: 16px;
text-align: center;
animation: side-to-side 2s ease-in-out infinite;
color: var(--text-muted);
height: 18px;
margin: 3px;
}
.vc-spotify-lyrics-next,
.vc-spotify-lyrics-prev {
opacity: 0.6;
margin-bottom: 1%;
}
@keyframes side-to-side {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(-10px);
}
}
.vc-spotify-lyrics-header-content {
display: flex;
align-items: center;
width: 100%;
}
.vc-spotify-lyrics-album-image {
width: 100px;
height: 100px;
margin-right: 5%;
border-radius: 5px;
cursor: pointer;
}
.vc-spotify-lyrics-modal-line-current,
.vc-spotify-lyrics-modal-line {
margin: 4px 0;
padding: 4px 8px;
position: relative;
}
.vc-spotify-lyrics-modal-line-current {
font-weight: bold;
}
.vc-spotify-lyrics-modal-note {
height: 1lh;
}
.vc-spotify-lyrics-modal-timestamp {
color: var(--text-muted);
margin-right: 0.5em;
cursor: pointer;
}
.vc-spotify-lyrics-modal-timestamp:hover {
text-decoration: underline;
}
.vc-spotify-lyrics-modal-no-lyrics {
text-align: center;
padding: 1rem;
}
.theme-light .vc-spotify-lyrics {
background: var(--bg-overlay-3, var(--background-secondary-alt));
}
.theme-dark .vc-spotify-lyrics {
background: var(--bg-overlay-1, var(--background-secondary-alt));
}

View file

@ -0,0 +1,251 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import { Link } from "@components/Link";
import { Devs } from "@utils/constants";
import { identity } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, Forms, Select, Slider, Text, useEffect, useState } from "@webpack/common";
const configModule = findByPropsLazy("getOutputVolume");
const settings = definePluginSettings({
title1: {
type: OptionType.COMPONENT,
component: () => <Text style={{ fontWeight: "bold", fontSize: "1.27rem" }}>Appearance</Text>,
description: ""
},
uncollapseSettingsByDefault: {
type: OptionType.BOOLEAN,
default: false,
description: "Automatically uncollapse voice settings by default"
},
title2: {
type: OptionType.COMPONENT,
component: () => <Text style={{ fontWeight: "bold", fontSize: "1.27rem" }}>Settings to show</Text>,
description: ""
},
outputVolume: {
type: OptionType.BOOLEAN,
default: true,
description: "Show an output volume slider"
},
inputVolume: {
type: OptionType.BOOLEAN,
default: true,
description: "Show an input volume slider"
},
outputDevice: {
type: OptionType.BOOLEAN,
default: true,
description: "Show an output device selector"
},
inputDevice: {
type: OptionType.BOOLEAN,
default: true,
description: "Show an input device selector"
},
camera: {
type: OptionType.BOOLEAN,
default: false,
description: "Show a camera selector"
},
title3: {
type: OptionType.COMPONENT,
component: () => <Text style={{ fontWeight: "bold", fontSize: "1.27rem" }}>Headers to show</Text>,
description: ""
},
showOutputVolumeHeader: {
type: OptionType.BOOLEAN,
default: true,
description: "Show header above output volume slider"
},
showInputVolumeHeader: {
type: OptionType.BOOLEAN,
default: true,
description: "Show header above input volume slider"
},
showOutputDeviceHeader: {
type: OptionType.BOOLEAN,
default: false,
description: "Show header above output device selector"
},
showInputDeviceHeader: {
type: OptionType.BOOLEAN,
default: false,
description: "Show header above input device selector"
},
showVideoDeviceHeader: {
type: OptionType.BOOLEAN,
default: false,
description: "Show header above camera selector"
},
});
function OutputVolumeComponent() {
const [outputVolume, setOutputVolume] = useState(configModule.getOutputVolume());
useEffect(() => {
const listener = () => setOutputVolume(configModule.getOutputVolume());
FluxDispatcher.subscribe("AUDIO_SET_OUTPUT_VOLUME", listener);
});
return (
<>
{settings.store.showOutputVolumeHeader && <Forms.FormTitle>Output volume</Forms.FormTitle>}
<Slider maxValue={200} minValue={0} onValueRender={v => `${v.toFixed(0)}%`} initialValue={outputVolume} asValueChanges={volume => {
FluxDispatcher.dispatch({
type: "AUDIO_SET_OUTPUT_VOLUME",
volume
});
}} />
</>
);
}
function InputVolumeComponent() {
const [inputVolume, setInputVolume] = useState(configModule.getInputVolume());
useEffect(() => {
const listener = () => setInputVolume(configModule.getInputVolume());
FluxDispatcher.subscribe("AUDIO_SET_INPUT_VOLUME", listener);
});
return (
<>
{settings.store.showInputVolumeHeader && <Forms.FormTitle>Input volume</Forms.FormTitle>}
<Slider maxValue={100} minValue={0} initialValue={inputVolume} asValueChanges={volume => {
FluxDispatcher.dispatch({
type: "AUDIO_SET_INPUT_VOLUME",
volume
});
}} />
</>
);
}
function OutputDeviceComponent() {
const [outputDevice, setOutputDevice] = useState(configModule.getOutputDeviceId());
useEffect(() => {
const listener = () => setOutputDevice(configModule.getOutputDeviceId());
FluxDispatcher.subscribe("AUDIO_SET_OUTPUT_DEVICE", listener);
});
return (
<>
{settings.store.showOutputDeviceHeader && <Forms.FormTitle>Output device</Forms.FormTitle>}
<Select options={Object.values(configModule.getOutputDevices()).map((device: any /* i am NOT typing this*/) => {
return { value: device.id, label: settings.store.showOutputDeviceHeader ? device.name : `🔊 ${device.name}` };
})}
serialize={identity}
isSelected={value => value === outputDevice}
select={id => {
FluxDispatcher.dispatch({
type: "AUDIO_SET_OUTPUT_DEVICE",
id
});
}}>
</Select>
</>
);
}
function InputDeviceComponent() {
const [inputDevice, setInputDevice] = useState(configModule.getInputDeviceId());
useEffect(() => {
const listener = () => setInputDevice(configModule.getInputDeviceId());
FluxDispatcher.subscribe("AUDIO_SET_INPUT_DEVICE", listener);
});
return (
<div style={{ marginTop: "10px" }}>
{settings.store.showInputDeviceHeader && <Forms.FormTitle>Input device</Forms.FormTitle>}
<Select options={Object.values(configModule.getInputDevices()).map((device: any /* i am NOT typing this*/) => {
return { value: device.id, label: settings.store.showInputDeviceHeader ? device.name : `🎤 ${device.name}` };
})}
serialize={identity}
isSelected={value => value === inputDevice}
select={id => {
FluxDispatcher.dispatch({
type: "AUDIO_SET_INPUT_DEVICE",
id
});
}}>
</Select>
</div>
);
}
function VideoDeviceComponent() {
const [videoDevice, setVideoDevice] = useState(configModule.getVideoDeviceId());
useEffect(() => {
const listener = () => setVideoDevice(configModule.getVideoDeviceId());
FluxDispatcher.subscribe("MEDIA_ENGINE_SET_VIDEO_DEVICE", listener);
});
return (
<div style={{ marginTop: "10px" }}>
{settings.store.showVideoDeviceHeader && <Forms.FormTitle>Camera</Forms.FormTitle>}
<Select options={Object.values(configModule.getVideoDevices()).map((device: any /* i am NOT typing this*/) => {
return { value: device.id, label: settings.store.showVideoDeviceHeader ? device.name : `📷 ${device.name}` };
})}
serialize={identity}
isSelected={value => value === videoDevice}
select={id => {
FluxDispatcher.dispatch({
type: "MEDIA_ENGINE_SET_VIDEO_DEVICE",
id
});
}}>
</Select>
</div>
);
}
function VoiceSettings() {
const [showSettings, setShowSettings] = useState(settings.store.uncollapseSettingsByDefault);
return <div style={{ marginTop: "20px" }}>
<div style={{ marginBottom: "10px" }}>
<Link className="vc-panelsettings-underline-on-hover" style={{ color: "var(--header-secondary)" }} onClick={() => { setShowSettings(!showSettings); }}>{!showSettings ? "► Settings" : "▼ Hide"}</Link>
</div>
{
showSettings && <>
{settings.store.outputVolume && <OutputVolumeComponent />}
{settings.store.inputVolume && <InputVolumeComponent />}
{settings.store.outputDevice && <OutputDeviceComponent />}
{settings.store.inputDevice && <InputDeviceComponent />}
{settings.store.camera && <VideoDeviceComponent />}
</>
}
</div>;
}
export default definePlugin({
name: "VCPanelSettings",
description: "Control voice settings right from the voice panel",
authors: [Devs.nin0dev],
settings,
renderVoiceSettings() { return <VoiceSettings />; },
patches: [
{
find: "this.renderChannelButtons()",
replacement: {
match: /this.renderChannelButtons\(\)/,
replace: "this.renderChannelButtons(), $self.renderVoiceSettings()"
}
}
]
});

View file

@ -0,0 +1,3 @@
.vc-panelsettings-underline-on-hover:hover {
text-decoration: underline;
}