mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-19 03:17:02 -04:00
Add 4 Plugins
Added: FriendCodes by HypedDomi IconViewer by iamme SpotifyLyrics by Joona PanelSettings by nin0dev
This commit is contained in:
parent
c5230c94b0
commit
220b44b4ed
30 changed files with 3130 additions and 1 deletions
|
@ -10,7 +10,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
|||
|
||||
### Extra included plugins
|
||||
<details>
|
||||
<summary>151 additional plugins</summary>
|
||||
<summary>155 additional plugins</summary>
|
||||
|
||||
### All Platforms
|
||||
- AllCallTimers by MaxHerbold & D3SOX
|
||||
|
@ -55,6 +55,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
|||
- FixFileExtensions by thororen
|
||||
- FollowVoiceUser by TheArmagan
|
||||
- FrequentQuickSwitcher by Samwich
|
||||
- FriendCodes by HypedDomi
|
||||
- FriendshipRanks by Samwich
|
||||
- FullVcPfp by mochie
|
||||
- FriendTags by Samwich
|
||||
|
@ -71,6 +72,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
|||
- HomeTyping by Samwich
|
||||
- HopOn by ImLvna
|
||||
- Husk by nin0dev
|
||||
- IconViewer by iamme
|
||||
- Identity by Samwich
|
||||
- IgnoreCalls by TheArmagan
|
||||
- IgnoreTerms by D3SOX
|
||||
|
@ -126,6 +128,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
|||
- StatsfmRPC by Crxaw & vmohammad
|
||||
- Slap by Korbo
|
||||
- SoundBoardLogger by Moxxie, fres, echo, maintained by thororen
|
||||
- SpotifyLyrics by Joona
|
||||
- StatusPresets by iamme
|
||||
- SteamStatusSync by niko
|
||||
- StickerBlocker by Samwich
|
||||
|
@ -145,6 +148,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
|||
- UwUifier by echo
|
||||
- VCSupport by thororen
|
||||
- VCNarratorCustom by Loukios, ported by example-git
|
||||
- VCPanelSettings by nin0dev
|
||||
- VencordRPC by AutumnVN
|
||||
- VideoSpeed by Samwich
|
||||
- ViewRawVariant (ViewRaw2) by Kyuuhachi
|
||||
|
|
131
src/equicordplugins/friendCodes/FriendCodesPanel.tsx
Normal file
131
src/equicordplugins/friendCodes/FriendCodesPanel.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
29
src/equicordplugins/friendCodes/index.tsx
Normal file
29
src/equicordplugins/friendCodes/index.tsx
Normal 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 />;
|
||||
}
|
||||
});
|
34
src/equicordplugins/friendCodes/styles.css
Normal file
34
src/equicordplugins/friendCodes/styles.css
Normal 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;
|
||||
}
|
26
src/equicordplugins/friendCodes/types.ts
Normal file
26
src/equicordplugins/friendCodes/types.ts
Normal 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;
|
||||
}
|
112
src/equicordplugins/iconViewer/IconModal.tsx
Normal file
112
src/equicordplugins/iconViewer/IconModal.tsx
Normal 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} />);
|
||||
}
|
||||
|
117
src/equicordplugins/iconViewer/IconsTab.css
Normal file
117
src/equicordplugins/iconViewer/IconsTab.css
Normal 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;
|
||||
}
|
88
src/equicordplugins/iconViewer/IconsTab.tsx
Normal file
88
src/equicordplugins/iconViewer/IconsTab.tsx
Normal 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");
|
54
src/equicordplugins/iconViewer/index.tsx
Normal file
54
src/equicordplugins/iconViewer/index.tsx
Normal 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);
|
||||
},
|
||||
});
|
150
src/equicordplugins/iconViewer/names.ts
Normal file
150
src/equicordplugins/iconViewer/names.ts
Normal 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;
|
||||
}
|
70
src/equicordplugins/iconViewer/rawModal.tsx
Normal file
70
src/equicordplugins/iconViewer/rawModal.tsx
Normal 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} />);
|
||||
}
|
||||
|
153
src/equicordplugins/iconViewer/saveModal.tsx
Normal file
153
src/equicordplugins/iconViewer/saveModal.tsx
Normal 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} />);
|
||||
}
|
||||
|
118
src/equicordplugins/iconViewer/subComponents.tsx
Normal file
118
src/equicordplugins/iconViewer/subComponents.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
|
100
src/equicordplugins/iconViewer/utils.tsx
Normal file
100
src/equicordplugins/iconViewer/utils.tsx
Normal 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;
|
||||
});
|
||||
|
132
src/equicordplugins/spotifyLyrics/api.tsx
Normal file
132
src/equicordplugins/spotifyLyrics/api.tsx
Normal 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", {});
|
||||
}
|
76
src/equicordplugins/spotifyLyrics/components/ctxMenu.tsx
Normal file
76
src/equicordplugins/spotifyLyrics/components/ctxMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
94
src/equicordplugins/spotifyLyrics/components/lyrics.tsx
Normal file
94
src/equicordplugins/spotifyLyrics/components/lyrics.tsx
Normal 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 />;
|
||||
}
|
78
src/equicordplugins/spotifyLyrics/components/modal.tsx
Normal file
78
src/equicordplugins/spotifyLyrics/components/modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
92
src/equicordplugins/spotifyLyrics/components/util.tsx
Normal file
92
src/equicordplugins/spotifyLyrics/components/util.tsx
Normal 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 };
|
||||
}
|
61
src/equicordplugins/spotifyLyrics/index.tsx
Normal file
61
src/equicordplugins/spotifyLyrics/index.tsx
Normal 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();
|
||||
},
|
||||
});
|
|
@ -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
|
||||
};
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
152
src/equicordplugins/spotifyLyrics/providers/store.ts
Normal file
152
src/equicordplugins/spotifyLyrics/providers/store.ts
Normal 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;
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
];
|
23
src/equicordplugins/spotifyLyrics/providers/types.ts
Normal file
23
src/equicordplugins/spotifyLyrics/providers/types.ts
Normal 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;
|
||||
}
|
143
src/equicordplugins/spotifyLyrics/settings.tsx
Normal file
143
src/equicordplugins/spotifyLyrics/settings.tsx
Normal 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;
|
86
src/equicordplugins/spotifyLyrics/styles.css
Normal file
86
src/equicordplugins/spotifyLyrics/styles.css
Normal 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));
|
||||
}
|
251
src/equicordplugins/vcPanelSettings/index.tsx
Normal file
251
src/equicordplugins/vcPanelSettings/index.tsx
Normal 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()"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
3
src/equicordplugins/vcPanelSettings/style.css
Normal file
3
src/equicordplugins/vcPanelSettings/style.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.vc-panelsettings-underline-on-hover:hover {
|
||||
text-decoration: underline;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue