Merge branch 'dev'

This commit is contained in:
thororen1234 2025-09-07 11:50:21 -04:00
commit d0feace033
No known key found for this signature in database
23 changed files with 1086 additions and 64 deletions

View file

@ -7,5 +7,4 @@ export * from "./menu";
export * from "./stores";
export * from "./passiveupdatestate";
export * from "./utils";
export * from "./voicestate";
export * as Webpack from "../webpack";

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VoiceState } from "./voicestate";
import { VoiceState } from "./stores";
export interface PassiveUpdateState {
type: string;

View file

@ -1,6 +1,11 @@
import { FluxStore } from "..";
import { FluxStore } from "@vencord/discord-types";
export class StreamerModeStore extends FluxStore {
enabled: boolean;
autoToggle: boolean;
get autoToggle(): boolean;
get disableNotifications(): boolean;
get disableSounds(): boolean;
get enableContentProtection(): boolean;
get enabled(): boolean;
get hideInstantInvites(): boolean;
get hidePersonalInformation(): boolean;
}

View file

@ -0,0 +1,45 @@
import { DiscordRecord } from "../common";
import { FluxStore } from "./FluxStore";
export type UserVoiceStateRecords = Record<string, VoiceState>;
export type VoiceStates = Record<string, UserVoiceStateRecords>;
export interface VoiceState extends DiscordRecord {
userId: string;
channelId: string | null | undefined;
sessionId: string | null | undefined;
mute: boolean;
deaf: boolean;
selfMute: boolean;
selfDeaf: boolean;
selfVideo: boolean;
selfStream: boolean | undefined;
suppress: boolean;
requestToSpeakTimestamp: string | null | undefined;
discoverable: boolean;
isVoiceMuted(): boolean;
isVoiceDeafened(): boolean;
oldChannelId?: string;
guildId?: string;
}
export class VoiceStateStore extends FluxStore {
getAllVoiceStates(): VoiceStates;
getVoiceStates(guildId?: string | null): UserVoiceStateRecords;
getVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVideoVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVoiceState(guildId: string | null, userId: string): VoiceState | undefined;
getUserVoiceChannelId(guildId: string | null, userId: string): string | undefined;
getVoiceStateForChannel(channelId: string, userId?: string): VoiceState | undefined;
getVoiceStateForUser(userId: string): VoiceState | undefined;
getCurrentClientVoiceChannelId(guildId: string | null): string | undefined;
isCurrentClientInVoiceChannel(): boolean;
isInChannel(channelId: string, userId?: string): boolean;
hasVideo(channelId: string): boolean;
}

View file

@ -15,10 +15,12 @@ export * from "./SpellCheckStore";
export * from "./SelectedChannelStore";
export * from "./SelectedGuildStore";
export * from "./StickersStore";
export * from "./StreamerModeStore";
export * from "./ThemeStore";
export * from "./TypingStore";
export * from "./UserProfileStore";
export * from "./UserStore";
export * from "./VoiceStateStore";
export * from "./WindowStore";
/**

View file

@ -1,21 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface VoiceState {
userId: string;
channelId?: string;
oldChannelId?: string;
guildId?: string;
deaf: boolean;
mute: boolean;
selfDeaf: boolean;
selfMute: boolean;
selfStream: boolean;
selfVideo: boolean;
sessionId: string;
suppress: boolean;
requestToSpeakTimestamp: string | null;
}

View file

@ -149,7 +149,7 @@ export default definePlugin({
let { oldChannelId } = state;
if (isMe && channelId !== myLastChannelId) {
oldChannelId = myLastChannelId;
myLastChannelId = channelId;
myLastChannelId = channelId ?? undefined;
}
if (channelId !== oldChannelId) {

View file

@ -14,8 +14,17 @@
transition-property: height;
}
.vc-ytm-button-row {
height: 0;
opacity: 0;
pointer-events: none;
transition: 0.2s;
transition-property: height;
}
#eq-tdl-player:hover .eq-tdl-button-row,
#vc-spotify-player:hover .vc-spotify-button-row {
#vc-spotify-player:hover .vc-spotify-button-row,
#vc-ytm-player:hover .vc-ytm-button-row {
opacity: 1;
height: 32px;
pointer-events: auto;

View file

@ -29,6 +29,7 @@ import { SpotifyLyrics } from "./spotify/lyrics/components/lyrics";
import { SpotifyPlayer } from "./spotify/PlayerComponent";
import { TidalLyrics } from "./tidal/lyrics/components/lyrics";
import { TidalPlayer } from "./tidal/TidalPlayer";
import { YtmPlayer } from "./youtubeMusic/PlayerComponent";
export default definePlugin({
name: "MusicControls",
@ -45,11 +46,9 @@ export default definePlugin({
"TidalControls",
"TidalLyrics",
// Youtube
/* Deprecated RN
"Youtube",
"YoutubeMusic",
"YoutubeMusicControls"
*/
],
patches: [
@ -94,7 +93,7 @@ export default definePlugin({
PanelWrapper({ VencordOriginal, ...props }) {
const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition } = settings.store;
const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition, showYoutubeMusicControls } = settings.store;
return (
<>
<ErrorBoundary
@ -111,6 +110,7 @@ export default definePlugin({
{showSpotifyLyrics && LyricsPosition === "above" && <SpotifyLyrics />}
{showSpotifyControls && <SpotifyPlayer />}
{showSpotifyLyrics && LyricsPosition === "below" && <SpotifyLyrics />}
{showYoutubeMusicControls && <YtmPlayer />}
</ErrorBoundary>
<VencordOriginal {...props} />

View file

@ -189,4 +189,33 @@ export const settings = definePluginSettings({
],
default: "line",
},
YtmSectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Youtube Music</Forms.FormTitle>
</Forms.FormSection>
)
},
showYoutubeMusicControls: {
description: "Show Youtube Music Controls",
type: OptionType.BOOLEAN,
default: false
},
YoutubeMusicApiUrl: {
description: "Custom URL for the Api Server plugin",
type: OptionType.STRING,
default: "http://localhost:26538",
placeholder: "http://localhost:26538",
onChange: (value: string) => {
if (URL.canParse(value)) {
settings.store.YoutubeMusicApiUrl = value;
} else {
showToast("Invalid URL format for Custom Api Server URL: " + value, Toasts.Type.FAILURE);
settings.store.YoutubeMusicApiUrl = settings.def.YoutubeMusicApiUrl.default;
}
}
}
});

View file

@ -0,0 +1,368 @@
/*
* 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 "./ytmStyles.css";
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
import { SeekBar } from "@equicordplugins/musicControls/spotify/SeekBar";
import { debounce } from "@shared/debounce";
import { openImageModal } from "@utils/discord";
import { classes, copyWithToast } from "@utils/misc";
import { ContextMenuApi, Flex, FluxDispatcher, Forms, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
import { type Repeat, type Song, YoutubeMusicStore } from "./YtmStore";
const cl = (className: string) => `vc-ytm-${className}`;
function msToHuman(ms: number) {
const minutes = ms / 1000 / 60;
const m = Math.floor(minutes);
const s = Math.floor((minutes - m) * 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
function Svg(path: string, label: string) {
return () => (
<svg
className={classes(cl("button-icon"), cl(label))}
aria-labelledby="title"
height="24"
width="24"
viewBox="0 0 24 24"
fill="currentColor"
aria-label={label}
focusable={false}
>
<title id="title">{label}</title>
<path d={path} />
</svg>
);
}
// KraXen's icons :yesyes:
// from https://fonts.google.com/icons?icon.style=Rounded&icon.set=Material+Icons
// older material icon style, but still really good
const PlayButton = Svg("M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18c.62-.39.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z", "play");
const PauseButton = Svg("M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2zm6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2z", "pause");
const SkipPrev = Svg("M7 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1zm3.66 6.82l5.77 4.07c.66.47 1.58-.01 1.58-.82V7.93c0-.81-.91-1.28-1.58-.82l-5.77 4.07c-.57.4-.57 1.24 0 1.64z", "previous");
const SkipNext = Svg("M7.58 16.89l5.77-4.07c.56-.4.56-1.24 0-1.63L7.58 7.11C6.91 6.65 6 7.12 6 7.93v8.14c0 .81.91 1.28 1.58.82zM16 7v10c0 .55.45 1 1 1s1-.45 1-1V7c0-.55-.45-1-1-1s-1 .45-1 1z", "next");
const Repeat = Svg("M7 7h10v1.79c0 .45.54.67.85.35l2.79-2.79c.2-.2.2-.51 0-.71l-2.79-2.79c-.31-.31-.85-.09-.85.36V5H6c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1s1-.45 1-1V7zm10 10H7v-1.79c0-.45-.54-.67-.85-.35l-2.79 2.79c-.2.2-.2.51 0 .71l2.79 2.79c.31.31.85.09.85-.36V19h11c.55 0 1-.45 1-1v-4c0-.55-.45-1-1-1s-1 .45-1 1v3z", "repeat");
const Shuffle = Svg("M10.59 9.17L6.12 4.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l4.46 4.46 1.42-1.4zm4.76-4.32l1.19 1.19L4.7 17.88c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L17.96 7.46l1.19 1.19c.31.31.85.09.85-.36V4.5c0-.28-.22-.5-.5-.5h-3.79c-.45 0-.67.54-.36.85zm-.52 8.56l-1.41 1.41 3.13 3.13-1.2 1.2c-.31.31-.09.85.36.85h3.79c.28 0 .5-.22.5-.5v-3.79c0-.45-.54-.67-.85-.35l-1.19 1.19-3.13-3.14z", "shuffle");
function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={cl("button")}
{...props}
>
{props.children}
</button>
);
}
function CopyContextMenu({ name, path }: { name: string; path: string; }) {
const copyId = `ytm-copy-${name}`;
const openId = `ytm-open-${name}`;
return (
<Menu.Menu
navId={`ytm-${name}-menu`}
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label={`YouTube Music ${name} Menu`}
>
<Menu.MenuItem
key={copyId}
id={copyId}
label={`Copy ${name} Link`}
action={() => copyWithToast(`https://music.youtube.com${path}`)}
icon={LinkIcon}
/>
<Menu.MenuItem
key={openId}
id={openId}
label={`Open ${name} in YouTube Music`}
action={() => YoutubeMusicStore.openExternal(path)}
icon={OpenExternalIcon}
/>
</Menu.Menu>
);
}
function makeContextMenu(name: string, path: string) {
return (e: React.MouseEvent<HTMLElement, MouseEvent>) =>
ContextMenuApi.openContextMenu(e, () => <CopyContextMenu name={name} path={path} />);
}
function Controls() {
const [isPlaying, isShuffled, repeat] = useStateFromStores<[boolean, boolean, Repeat]>(
[YoutubeMusicStore],
() => [YoutubeMusicStore.isPlaying, YoutubeMusicStore.isShuffled, YoutubeMusicStore.repeat]
);
const [nextRepeat, repeatClassName] = (() => {
switch (repeat) {
case "NONE": return ["context", "repeat-off"] as const;
case "ALL": return ["track", "repeat-context"] as const;
case "ONE": return ["off", "repeat-track"] as const;
default: throw new Error(`Invalid repeat state ${repeat}`);
}
})();
// the 1 is using position absolute so it does not make the button jump around
return (
<Flex className={cl("button-row")} style={{ gap: 0 }}>
<Button
className={classes(cl("button"), isShuffled ? cl("shuffle-on") : cl("shuffle-off"))}
onClick={() => YoutubeMusicStore.shuffle()}
>
<Shuffle />
</Button>
<Button onClick={() => YoutubeMusicStore.prev()}>
<SkipPrev />
</Button>
<Button onClick={() => YoutubeMusicStore.setPlaying(!isPlaying)}>
{isPlaying ? <PauseButton /> : <PlayButton />}
</Button>
<Button onClick={() => YoutubeMusicStore.next()}>
<SkipNext />
</Button>
<Button
className={classes(cl("button"), cl(repeatClassName))}
onClick={() => YoutubeMusicStore.switchRepeat()}
style={{ position: "relative" }}
>
{repeat === "ONE" && <span className={cl("repeat-1")}>1</span>}
<Repeat />
</Button>
</Flex>
);
}
const seek = debounce((v: number) => {
YoutubeMusicStore.seek(v);
});
function YtmSeekBar() {
const { songDuration } = YoutubeMusicStore.song!;
const [storePosition, isSettingPosition, isPlaying] = useStateFromStores(
[YoutubeMusicStore],
() => [YoutubeMusicStore.mPosition, YoutubeMusicStore.isSettingPosition, YoutubeMusicStore.isPlaying]
);
const [position, setPosition] = useState<number>(storePosition);
useEffect(() => {
setPosition(YoutubeMusicStore.position);
}, [storePosition]);
const onChange = (v: number) => {
if (isSettingPosition) return;
setPosition(v);
seek(v);
};
return (
<div id={cl("progress-bar")}>
<Forms.FormText
variant="text-xs/medium"
className={`${cl("progress-time")} ${cl("time-left")}`}
aria-label="Progress"
>
{msToHuman(position)}
</Forms.FormText>
<SeekBar
initialValue={position}
minValue={0}
maxValue={songDuration * 1000}
onValueChange={onChange}
asValueChanges={onChange}
onValueRender={msToHuman}
/>
<Forms.FormText
variant="text-xs/medium"
className={`${cl("progress-time")} ${cl("time-right")}`}
aria-label="Total Duration"
>
{msToHuman(songDuration * 1000)}
</Forms.FormText>
</div>
);
}
function AlbumContextMenu({ track }: { track: Song; }) {
const [volume, muted] = useStateFromStores([YoutubeMusicStore], () => [YoutubeMusicStore.volume, YoutubeMusicStore.muted]);
return (
<Menu.Menu
navId="ytm-album-menu"
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
aria-label="YouTube Music Album Menu"
>
{/* <Menu.MenuItem
key="open-album"
id="open-album"
label="Open Album"
action={() => YoutubeMusicStore.openExternal(`/album/${track?.album}`)}
icon={OpenExternalIcon}
/> */}
<Menu.MenuItem
key="view-cover"
id="view-cover"
label="View Album Cover"
// trolley
action={() => track?.imageSrc && openImageModal({ url: track.imageSrc })}
icon={ImageIcon}
/>
<Menu.MenuControlItem
id="ytm-volume"
key="ytm-volume"
label="Volume"
control={(props, ref) => (
<Menu.MenuSliderControl
{...props}
ref={ref}
value={volume}
minValue={0}
maxValue={100}
onChange={debounce((v: number) => YoutubeMusicStore.setVolume(v))}
/>
)}
/>
<Menu.MenuCheckboxItem
id="ytm-muted"
key="ytm-muted"
label="Muted"
checked={muted}
action={() => YoutubeMusicStore.toggleMute()}
/>
</Menu.Menu>
);
}
function makeLinkProps(name: string, condition: unknown, path: string) {
if (!condition) return {};
return {
role: "link",
onClick: () => YoutubeMusicStore.openExternal(path),
onContextMenu: makeContextMenu(name, path)
} satisfies React.HTMLAttributes<HTMLElement>;
}
function Info({ track }: { track: NonNullable<Song>; }) {
const img = track?.imageSrc;
const [coverExpanded, setCoverExpanded] = useState(false);
const i = (
<>
{img && (
<img
id={cl("album-image")}
src={img}
alt="Album Image"
onClick={() => setCoverExpanded(!coverExpanded)}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => <AlbumContextMenu track={track} />);
}}
/>
)}
</>
);
if (coverExpanded && img) return (
<div id={cl("album-expanded-wrapper")}>
{i}
</div>
);
return (
<div id={cl("info-wrapper")}>
{i}
<div id={cl("titles")}>
<Forms.FormText
variant="text-sm/semibold"
id={cl("song-title")}
className={cl("ellipoverflow")}
title={track?.title}
{...makeLinkProps("Song", track?.videoId, `/watch?v=${track?.videoId}`)}
>
{track?.title}
</Forms.FormText>
{track.artist && (
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
by&nbsp;
<span className={cl("artist")} style={{ fontSize: "inherit" }} title={track.artist}>
{track.artist}
</span>
</Forms.FormText>
)}
{track.album && (
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
on&nbsp;
<span
id={cl("album-title")}
className={cl("album")}
style={{ fontSize: "inherit" }}
title={track.album}
>
{track.album}
</span>
</Forms.FormText>
)}
</div>
</div>
);
}
export function YtmPlayer() {
const track = useStateFromStores(
[YoutubeMusicStore],
() => YoutubeMusicStore.song,
null,
(prev, next) => prev?.videoId ? (prev.videoId === next?.videoId) : prev?.title === next?.title
);
const isPlaying = useStateFromStores([YoutubeMusicStore], () => YoutubeMusicStore.isPlaying);
const [shouldHide, setShouldHide] = useState(false);
React.useEffect(() => {
setShouldHide(false);
if (!isPlaying) {
const timeout = setTimeout(() => setShouldHide(true), 1000 * 60 * 5);
return () => clearTimeout(timeout);
}
}, [isPlaying]);
if (!track || shouldHide) return;
const exportTrackImageStyle = {
"--vc-ytm-track-image": `url(${track?.imageSrc || ""})`,
} as React.CSSProperties;
return (
<div id={cl("player")} style={exportTrackImageStyle}>
<Info track={track} />
<YtmSeekBar />
<Controls />
</div>
);
}

View file

@ -0,0 +1,299 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
import { Settings } from "Vencord";
enum MediaType {
/**
* Audio uploaded by the original artist
*/
Audio = "AUDIO",
/**
* Official music video uploaded by the original artist
*/
OriginalMusicVideo = "ORIGINAL_MUSIC_VIDEO",
/**
* Normal YouTube video uploaded by a user
*/
UserGeneratedContent = "USER_GENERATED_CONTENT",
/**
* Podcast episode
*/
PodcastEpisode = "PODCAST_EPISODE",
OtherVideo = "OTHER_VIDEO",
}
export type RepeatMode = "NONE" | "ONE" | "ALL";
export interface Song {
title: string;
artist: string;
views: number;
uploadDate?: string;
imageSrc?: string | null;
isPaused?: boolean;
songDuration: number;
elapsedSeconds?: number;
url?: string;
album?: string | null;
videoId: string;
playlistId?: string;
mediaType: MediaType;
}
enum DataTypes {
PlayerInfo = "PLAYER_INFO",
VideoChanged = "VIDEO_CHANGED",
PlayerStateChanged = "PLAYER_STATE_CHANGED",
PositionChanged = "POSITION_CHANGED",
VolumeChanged = "VOLUME_CHANGED",
RepeatChanged = "REPEAT_CHANGED",
ShuffleChanged = "SHUFFLE_CHANGED",
}
type PlayerInfo = {
type: DataTypes.PlayerInfo;
song: Song | undefined;
volume: number,
muted: boolean;
repeat: RepeatMode;
position: number;
isPlaying: boolean;
shuffle: boolean;
};
type VideoChanged = {
type: DataTypes.VideoChanged;
song: Song;
position: number;
};
type PlayerStateChanged = {
type: DataTypes.PlayerStateChanged;
isPlaying: boolean,
position: number;
};
type PositionChanged = {
type: DataTypes.PositionChanged,
position: number;
};
type VolumeChanged = {
type: DataTypes.VolumeChanged;
volume: number;
muted: boolean;
};
type RepeatChanged = {
type: DataTypes.RepeatChanged;
repeat: RepeatMode;
};
type ShuffleChanged = {
type: DataTypes.ShuffleChanged;
shuffle: boolean;
};
export type Repeat = "NONE" | "ONE" | "ALL";
export const logger = new Logger("MusicControls-Ytm");
type Message = PlayerInfo | VideoChanged | PlayerStateChanged | PositionChanged | VolumeChanged | RepeatChanged | ShuffleChanged;
type PlayerState = Partial<Omit<PlayerInfo, "type">>;
class YoutubemusicSocket {
public onChange: (e: PlayerState) => void;
private ready = false;
private connecting = false;
private socket: WebSocket | undefined;
constructor(onChange: typeof this.onChange) {
this.reconnect();
this.onChange = onChange;
}
public scheduleReconnect(ms: number) {
setTimeout(() => this.reconnect(), ms);
}
public reconnect() {
if (this.ready || this.connecting) return;
this.connecting = true;
this.initWs();
}
private async initWs() {
const url = Settings.plugins.MusicControls.YoutubeMusicApiUrl;
if (!url) {
this.connecting = false;
return;
}
try {
this.socket = new WebSocket(new URL("/api/v1/ws", url));
} catch (e) {
logger.error("Connection failed");
return;
}
this.socket.addEventListener("open", () => {
this.ready = true;
this.connecting = false;
});
this.socket.addEventListener("error", e => {
this.ready = false;
this.connecting = false;
if (!this.ready) this.scheduleReconnect(5_000);
this.onChange({ position: 0, isPlaying: false, song: undefined });
});
this.socket.addEventListener("close", e => {
this.ready = false;
this.connecting = false;
if (!this.ready) this.scheduleReconnect(10_000);
this.onChange({ position: 0, isPlaying: false, song: undefined });
});
this.socket.addEventListener("message", e => {
let message: Message;
try {
message = JSON.parse(e.data) as Message;
this.onChange(message);
} catch (err) {
logger.error("Invalid JSON:", err, `\n${e.data}`);
return;
}
});
}
}
export const YoutubeMusicStore = proxyLazyWebpack(() => {
const { Store } = Flux;
class YoutubeMusicStore extends Store {
public mPosition = 0;
private start = 0;
public song: Song | null = null;
public isPlaying = false;
public isShuffled = false;
public repeat: Repeat = "NONE";
public volume = 0;
public muted = false;
public isSettingPosition = false;
public socket = new YoutubemusicSocket((message: PlayerState) => {
if (message.song) {
store.song = message.song;
store.isPlaying = !(message.song?.isPaused ?? false);
}
if (message.isPlaying != null && !message.song) store.isPlaying = message.isPlaying;
if (message.shuffle != null) store.isShuffled = message.shuffle;
if (message.position && message.position !== store.position) store.position = message.position ?? 0;
if (message.volume) store.volume = message.volume ?? 0;
if (message.repeat) store.repeat = message.repeat;
if (message.muted != null) store.muted = message.muted;
store.isSettingPosition = false;
store.emitChange();
});
public openExternal(path: string) {
const videoId = path.match(/watch\?v=([\w-]+)/);
const url = Vencord.Plugins.isPluginEnabled("OpenInApp") && videoId
? encodeURI("youtubemusic://openVideo " + videoId[1])
: "https://music.youtube.com" + path;
logger.info("Open", url);
// https://music.youtube.com/watch?v=BSHYPb15W-Y
VencordNative.native.openExternal(url);
}
set position(p: number) {
this.mPosition = p * 1000;
this.start = Date.now();
}
get position(): number {
let pos = this.mPosition;
if (this.isPlaying) {
pos += Date.now() - this.start;
}
return pos;
}
prev() {
this.req("post", "/api/v1/previous");
}
next() {
this.req("post", "/api/v1/next");
}
setVolume(percent: number) {
this.req("post", "/api/v1/volume", {
body: {
volume: Math.floor(percent)
},
});
}
setPlaying(playing: boolean) {
if (playing) {
this.req("post", "/api/v1/play");
} else {
this.req("post", "/api/v1/pause");
}
}
switchRepeat() {
this.req("post", "/api/v1/switch-repeat", {
body: {
iteration: 1
}
});
}
shuffle() {
this.req("post", "/api/v1/shuffle");
}
seek(ms: number) {
this.req("post", "/api/v1/seek-to", {
body: {
seconds: Math.floor(ms / 1000)
}
});
}
toggleMute() {
this.req("post", "/api/v1/toggle-mute");
}
private req(method: "post" | "get" | "put", route: string, data: any = {}) {
const apiServerUrl = Settings.plugins.MusicControls.YoutubeMusicApiUrl;
if (apiServerUrl === "") return;
const url = apiServerUrl + route;
fetch(url, {
method,
...data,
...(data.body && { body: JSON.stringify(data.body), headers: { "Content-Type": "application/json" } })
});
}
}
const store = new YoutubeMusicStore(FluxDispatcher);
return store;
});

View file

@ -0,0 +1,208 @@
#vc-ytm-player {
padding: 12px;
background: var(--bg-overlay-floating, var(--background-base-low, var(--background-base-lower-alt)));
margin: 0;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
--vc-ytm-slider-gradient: linear-gradient(to right, #f03 80%, #ff2791 100%);
--vc-ytm-slider-grabber: #f03;
/* so custom themes can easily change it */
--vc-ytm-red-90: color-mix(in hsl, var(--vc-ytm-slider-grabber), transparent 90%);
--vc-ytm-red-80: color-mix(in hsl, var(--vc-ytm-slider-grabber), transparent 80%);
}
.vc-ytm-song-info-prefix {
display: none;
}
.vc-ytm-artist,
.vc-ytm-album {
color: var(--header-primary);
}
.vc-ytm-secondary-song-info {
font-size: 12px;
}
#vc-ytm-progress-bar {
position: relative;
color: var(--text-default);
width: 100%;
margin: 0.5em 0;
margin-bottom: 5px;
}
#vc-ytm-progress-bar > [class^="slider"] {
flex-grow: 1;
width: 100%;
padding: 0 !important;
}
#vc-ytm-progress-bar > [class^="slider"] [class^="bar"] {
height: 3px !important;
top: calc(12px - 4px / 2 + var(--bar-offset));
}
#vc-ytm-progress-bar > [class^="slider"] [class^="barFill"] {
background-image: var(--vc-ytm-slider-gradient);
}
#vc-ytm-progress-bar > [class^="slider"]:hover [class^="barFill"] {
background-image: var(--vc-ytm-slider-gradient);
}
#vc-ytm-progress-bar > [class^="slider"] [class^="grabber"] {
background-color: var(--vc-ytm-slider-grabber);
width: 12px !important;
height: 12px !important;
margin-top: calc(13px/-2 + var(--bar-offset)/2);
margin-left: -0.5px;
border: unset;
color: var(--interactive-normal);
opacity: 0;
transition: opacity 0.1s;
}
#vc-ytm-progress-bar:hover > [class^="slider"] [class^="grabber"] {
opacity: 1;
}
.vc-ytm-progress-time {
margin-top: 8px;
font-family: var(--font-code);
font-size: 12px;
top: 10px;
position: absolute;
}
.vc-ytm-button-row {
justify-content: center;
margin-top: 14px;
}
.vc-ytm-button {
margin: 0 2px;
border-radius: var(--radius-sm);
background: none;
color: var(--interactive-normal);
padding: 0;
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.vc-ytm-button:hover {
color: var(--interactive-hover);
background-color: var(--background-modifier-selected);
}
.vc-ytm-button svg {
height: 24px;
width: 24px;
}
[class*="shuffle"] > svg,
[class*="repeat"] > svg {
width: 22px;
height: 22px;
}
.vc-ytm-button svg path {
width: 100%;
height: 100%;
}
.vc-ytm-repeat-context,
.vc-ytm-repeat-track,
.vc-ytm-shuffle-on {
background-color: var(--vc-ytm-red-90);
}
.vc-ytm-repeat-context:hover,
.vc-ytm-repeat-track:hover,
.vc-ytm-shuffle-on:hover {
background-color: var(--vc-ytm-red-80);
}
.vc-ytm-repeat-1 {
font-size: 70%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#vc-ytm-info-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 3em;
gap: 0.5em;
}
#vc-ytm-info-wrapper img {
height: 90%;
object-fit: contain;
}
#vc-ytm-album-expanded-wrapper img {
width: 100%;
object-fit: contain;
}
#vc-ytm-titles {
display: flex;
flex-direction: column;
padding: 0.2rem;
align-items: flex-start;
place-content: flex-start center;
overflow: hidden;
}
#vc-ytm-song-title {
color: var(--header-primary);
font-size: 14px;
font-weight: 600;
}
.vc-ytm-ellipoverflow {
white-space: nowrap;
overflow: hidden;
width: 100%;
text-overflow: ellipsis;
}
.vc-ytm-artist[role="link"]:hover,
#vc-ytm-album-title[role="link"]:hover,
#vc-ytm-song-title[role="link"]:hover {
text-decoration: underline;
cursor: pointer;
}
#vc-ytm-album-image {
border-radius: 3px;
transition: filter 0.2s;
}
#vc-ytm-album-image:hover {
filter: brightness(1.2);
cursor: pointer;
}
#vc-ytm-progress-text {
margin: 0;
}
.vc-ytm-time-left {
left: 0;
}
.vc-ytm-time-right {
right: 0;
}

View file

@ -19,6 +19,7 @@
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { VoiceState } from "@vencord/discord-types";
import { ChannelStore, FluxDispatcher, GuildMemberStore, Toasts, UserStore, VoiceStateStore } from "@webpack/common";
interface ChannelState {
@ -78,7 +79,7 @@ const settings = definePluginSettings({
});
let ws: WebSocket | null = null;
let currentChannel = null;
let currentChannel: string | null = null;
async function waitForPopulate(fn) {
while (true) {
@ -88,7 +89,7 @@ async function waitForPopulate(fn) {
}
}
function stateToPayload(guildId: string, state: ChannelState) {
function stateToPayload(guildId: string, state: VoiceState) {
const user = UserStore.getUser(state.userId);
const nickname = GuildMemberStore.getNick(guildId, state.userId);
return {
@ -130,9 +131,9 @@ const incoming = payload => {
case "STOP_STREAM": {
const userId = UserStore.getCurrentUser().id;
const voiceState = VoiceStateStore.getVoiceStateForUser(userId);
if (!voiceState?.channelId) return;
const channel = ChannelStore.getChannel(voiceState.channelId);
if (!userId || !voiceState || !channel) return;
if (!channel) return;
FluxDispatcher.dispatch({
type: "STREAM_STOP",
@ -188,8 +189,14 @@ const createWebsocket = () => {
ws?.send(JSON.stringify({ cmd: "REGISTER_CONFIG", ...config }));
if (!config.userId) return;
const userVoiceState = VoiceStateStore.getVoiceStateForUser(config.userId);
const guildId = ChannelStore.getChannel(userVoiceState.channelId).guild_id;
if (!userVoiceState || !userVoiceState.channelId) return;
const channel = ChannelStore.getChannel(userVoiceState.channelId);
if (!channel) return;
const guildId = channel.guild_id;
const channelState = VoiceStateStore.getVoiceStatesForChannel(userVoiceState.channelId);
if (!guildId || !channelState) return;
@ -197,7 +204,7 @@ const createWebsocket = () => {
ws?.send(
JSON.stringify({
cmd: "CHANNEL_JOINED",
states: Object.values(channelState).map(s => stateToPayload(guildId, s as ChannelState)),
states: Object.values(channelState).map(s => stateToPayload(guildId, s)),
})
);
@ -236,7 +243,7 @@ export default definePlugin({
ws?.send(
JSON.stringify({
cmd: "CHANNEL_JOINED",
states: Object.values(voiceStates).map(s => stateToPayload(guildId, s as ChannelState)),
states: Object.values(voiceStates).map(s => stateToPayload(guildId, s as VoiceState)),
})
);
@ -258,7 +265,7 @@ export default definePlugin({
ws?.send(
JSON.stringify({
cmd: "VOICE_STATE_UPDATE",
state: stateToPayload(guildId, state as ChannelState),
state: stateToPayload(guildId, state),
})
);
}

View file

@ -6,16 +6,37 @@
import { getCurrentChannel } from "@utils/discord";
import { isObjectEmpty } from "@utils/misc";
import { SelectedChannelStore, Tooltip, useEffect, useStateFromStores } from "@webpack/common";
import { ChannelStore, PermissionsBits, PermissionStore, SelectedChannelStore, Tooltip, useEffect, useStateFromStores, VoiceStateStore } from "@webpack/common";
import { ChannelMemberStore, cl, GuildMemberCountStore, numberFormat, ThreadMemberListStore } from ".";
import { ChannelMemberStore, cl, GuildMemberCountStore, numberFormat, settings, ThreadMemberListStore } from ".";
import { OnlineMemberCountStore } from "./OnlineMemberCountStore";
import { VoiceIcon } from "./VoiceIcon";
export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; tooltipGuildId?: string; }) {
const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel());
const { voiceActivity } = settings.use(["voiceActivity"]);
const includeVoice = voiceActivity && !isTooltip;
const currentChannel = useStateFromStores([SelectedChannelStore], () => getCurrentChannel());
const guildId = isTooltip ? tooltipGuildId! : currentChannel?.guild_id;
const voiceActivityCount = useStateFromStores(
[VoiceStateStore],
() => {
if (!includeVoice) return 0;
const voiceStates = VoiceStateStore.getVoiceStates(guildId);
if (!voiceStates) return 0;
return Object.values(voiceStates)
.filter(({ channelId }) => {
if (!channelId) return false;
const channel = ChannelStore.getChannel(channelId);
return channel && PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel);
})
.length;
}
);
const totalCount = useStateFromStores(
[GuildMemberCountStore],
() => GuildMemberCountStore.getMemberCount(guildId)
@ -51,13 +72,14 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
if (totalCount == null)
return null;
const formattedVoiceCount = numberFormat(voiceActivityCount ?? 0);
const formattedOnlineCount = onlineCount != null ? numberFormat(onlineCount) : "?";
return (
<div className={cl("widget", { tooltip: isTooltip, "member-list": !isTooltip })}>
<Tooltip text={`${formattedOnlineCount} online in this channel`} position="bottom">
{props => (
<div {...props}>
<div {...props} className={cl("container")}>
<span className={cl("online-dot")} />
<span className={cl("online")}>{formattedOnlineCount}</span>
</div>
@ -65,12 +87,22 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t
</Tooltip>
<Tooltip text={`${numberFormat(totalCount)} total server members`} position="bottom">
{props => (
<div {...props}>
<div {...props} className={cl("container")}>
<span className={cl("total-dot")} />
<span className={cl("total")}>{numberFormat(totalCount)}</span>
</div>
)}
</Tooltip>
{includeVoice && voiceActivityCount > 0 &&
<Tooltip text={`${formattedVoiceCount} members in voice`} position="bottom">
{props => (
<div {...props} className={cl("container")}>
<VoiceIcon className={cl("voice-icon")} />
<span className={cl("voice")}>{formattedVoiceCount}</span>
</div>
)}
</Tooltip>
}
</div>
);
}

View file

@ -0,0 +1,14 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export function VoiceIcon({ className }: { className?: string; }) {
return (
<svg viewBox="0 0 32 32" fill="currentColor" className={className}>
<path d="M15.6668 3C14.2523 3 12.8958 3.5619 11.8956 4.5621C10.8954 5.56229 10.3335 6.91884 10.3335 8.33333V13.6666C10.3335 15.0811 10.8954 16.4378 11.8956 17.438C12.8958 18.4381 14.2523 19 15.6668 19C17.0813 19 18.4378 18.4381 19.438 17.438C20.4382 16.4378 21.0001 15.0811 21.0001 13.6666V8.33333C21.0001 6.91884 20.4382 5.56229 19.438 4.5621C18.4378 3.5619 17.0813 3 15.6668 3Z" />
<path d="M7.66667 13.6666C7.66667 13.313 7.52619 12.9739 7.27614 12.7238C7.02609 12.4738 6.68695 12.3333 6.33333 12.3333C5.97971 12.3333 5.64057 12.4738 5.39052 12.7238C5.14047 12.9739 5 13.313 5 13.6666C4.99911 16.2653 5.94692 18.7749 7.66545 20.7243C9.38399 22.6736 11.7551 23.9285 14.3334 24.2533V27H11.6667C11.3131 27 10.9739 27.1404 10.7239 27.3905C10.4738 27.6405 10.3334 27.9797 10.3334 28.3333C10.3334 28.6869 10.4738 29.0261 10.7239 29.2761C10.9739 29.5262 11.3131 29.6666 11.6667 29.6666H19.6667C20.0203 29.6666 20.3595 29.5262 20.6095 29.2761C20.8596 29.0261 21 28.6869 21 28.3333C21 27.9797 20.8596 27.6405 20.6095 27.3905C20.3595 27.1404 20.0203 27 19.6667 27H17V24.2533C19.5783 23.9285 21.9494 22.6736 23.6679 20.7243C25.3864 18.7749 26.3343 16.2653 26.3334 13.6666C26.3334 13.313 26.1929 12.9739 25.9428 12.7238C25.6928 12.4738 25.3536 12.3333 25 12.3333C24.6464 12.3333 24.3073 12.4738 24.0572 12.7238C23.8072 12.9739 23.6667 13.313 23.6667 13.6666C23.6667 15.7884 22.8238 17.8232 21.3235 19.3235C19.8233 20.8238 17.7884 21.6666 15.6667 21.6666C13.545 21.6666 11.5101 20.8238 10.0098 19.3235C8.50952 17.8232 7.66667 15.7884 7.66667 13.6666Z" />
</svg>
);
}

View file

@ -37,18 +37,23 @@ export const ThreadMemberListStore = findStoreLazy("ThreadMemberListStore") as F
};
const settings = definePluginSettings({
export const settings = definePluginSettings({
toolTip: {
type: OptionType.BOOLEAN,
description: "If the member count should be displayed on the server tooltip",
description: "Show member count on the server tooltip",
default: true,
restartNeeded: true
},
memberList: {
type: OptionType.BOOLEAN,
description: "If the member count should be displayed on the member list",
description: "Show member count in the member list",
default: true,
restartNeeded: true
},
voiceActivity: {
type: OptionType.BOOLEAN,
description: "Show voice activity with member count in the member list",
default: true
}
});
@ -58,8 +63,8 @@ export const cl = classNameFactory("vc-membercount-");
export default definePlugin({
name: "MemberCount",
description: "Shows the amount of online & total members in the server member list and tooltip",
authors: [Devs.Ven, Devs.Commandtechno],
description: "Shows the number of online members, total members, and users in voice channels on the server — in the member list and tooltip.",
authors: [Devs.Ven, Devs.Commandtechno, Devs.Apexo],
settings,
patches: [
@ -82,6 +87,6 @@ export default definePlugin({
predicate: () => settings.store.toolTip
}
],
render: ErrorBoundary.wrap(MemberCount, { noop: true }),
render: ErrorBoundary.wrap(() => <MemberCount />, { noop: true }),
renderTooltip: ErrorBoundary.wrap(guild => <MemberCount isTooltip tooltipGuildId={guild.id} />, { noop: true })
});

View file

@ -1,9 +1,11 @@
.vc-membercount-widget {
gap: 0.85em;
display: flex;
align-content: center;
--color-online: var(--green-360);
--color-total: var(--primary-400);
--color-voice: var(--primary-400);
}
.vc-membercount-tooltip {
@ -13,10 +15,17 @@
.vc-membercount-member-list {
justify-content: center;
flex-wrap: wrap;
margin-top: 1em;
padding-inline: 1em;
}
.vc-membercount-container {
display: flex;
align-items: center;
gap: 0.5em;
}
.vc-membercount-online {
color: var(--color-online);
}
@ -25,13 +34,16 @@
color: var(--color-total);
}
.vc-membercount-voice {
color: var(--color-voice);
}
.vc-membercount-online-dot {
background-color: var(--color-online);
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 0.5em;
}
.vc-membercount-total-dot {
@ -40,5 +52,10 @@
height: 6px;
border-radius: 50%;
border: 3px solid var(--color-total);
margin: 0 0.5em 0 1em;
}
.vc-membercount-voice-icon {
color: var(--color-voice);
width: 15px;
height: 15px;
}

View file

@ -98,7 +98,7 @@ export default definePlugin({
if (!isNonNullish(nonce)) return null;
// Bots basically never send a nonce, and if someone does do it then it's usually not a snowflake
if (message.bot) return null;
if (message.author.bot) return null;
let isDiscordKotlin = false;
let delta = SnowflakeUtils.extractTimestamp(id) - SnowflakeUtils.extractTimestamp(nonce); // milliseconds

View file

@ -83,7 +83,7 @@ function VoiceChannelTooltip({ channel, isLocked }: VoiceChannelTooltipProps) {
const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id));
const users = useMemo(
() => Object.values<any>(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null),
() => Object.values(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null),
[voiceStates]
);
@ -167,7 +167,7 @@ export type VoiceChannelIndicatorProps = {
const clickTimers = {} as Record<string, any>;
export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId, isProfile, isActionButton, shouldHighlight }: VoiceChannelIndicatorProps) => {
const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId as string | undefined);
const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId);
const channel = channelId == null ? undefined : ChannelStore.getChannel(channelId);
if (channel == null) return null;

View file

@ -27,7 +27,7 @@ import { ReactElement } from "react";
import { getCurrentVoice, settings } from "./settings";
interface VoiceState {
interface VoiceStateChangeEvent {
userId: string;
channelId?: string;
oldChannelId?: string;
@ -78,7 +78,7 @@ function formatText(str: string, user: string, channel: string, displayName: str
// for some ungodly reason
let myLastChannelId: string | undefined;
function getTypeAndChannelId({ channelId, oldChannelId }: VoiceState, isMe: boolean) {
function getTypeAndChannelId({ channelId, oldChannelId }: VoiceStateChangeEvent, isMe: boolean) {
if (isMe && channelId !== myLastChannelId) {
oldChannelId = myLastChannelId;
myLastChannelId = channelId;
@ -145,7 +145,7 @@ export default definePlugin({
settings,
flux: {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceState[]; }) {
VOICE_STATE_UPDATES({ voiceStates }: { voiceStates: VoiceStateChangeEvent[]; }) {
const myGuildId = SelectedGuildStore.getGuildId();
const myChanId = SelectedChannelStore.getVoiceChannelId();
const myId = UserStore.getCurrentUser().id;
@ -177,7 +177,7 @@ export default definePlugin({
AUDIO_TOGGLE_SELF_MUTE() {
const chanId = SelectedChannelStore.getVoiceChannelId()!;
const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState;
const s = VoiceStateStore.getVoiceStateForChannel(chanId);
if (!s) return;
const event = s.mute || s.selfMute ? "unmute" : "mute";
@ -186,7 +186,7 @@ export default definePlugin({
AUDIO_TOGGLE_SELF_DEAF() {
const chanId = SelectedChannelStore.getVoiceChannelId()!;
const s = VoiceStateStore.getVoiceStateForChannel(chanId) as VoiceState;
const s = VoiceStateStore.getVoiceStateForChannel(chanId);
if (!s) return;
const event = s.deaf || s.selfDeaf ? "undeafen" : "deafen";

View file

@ -67,6 +67,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "V",
id: 343383572805058560n
},
Apexo: {
name: "Apexo",
id: 228548952687902720n
},
Arjix: {
name: "ArjixWasTaken",
id: 674710789138939916n,

View file

@ -35,7 +35,6 @@ export let PermissionStore: GenericStore;
export let GuildChannelStore: GenericStore;
export let ReadStateStore: GenericStore;
export let PresenceStore: GenericStore;
export let VoiceStateStore: GenericStore;
export let GuildStore: t.GuildStore;
export let GuildRoleStore: t.GuildRoleStore;
@ -49,14 +48,15 @@ export let ChannelStore: t.ChannelStore;
export let TypingStore: t.TypingStore;
export let RelationshipStore: t.RelationshipStore;
export let MediaEngineStore: t.MediaEngineStore;
export let StreamerModeStore: t.StreamerModeStore;
export let SpellCheckStore: t.SpellCheckStore;
export let VoiceStateStore: t.VoiceStateStore;
export let EmojiStore: t.EmojiStore;
export let StickersStore: t.StickersStore;
export let ThemeStore: t.ThemeStore;
export let WindowStore: t.WindowStore;
export let DraftStore: t.DraftStore;
export let StreamerModeStore: t.StreamerModeStore;
/**
* @see jsdoc of {@link t.useStateFromStores}
@ -75,7 +75,6 @@ waitForStore("GuildRoleStore", m => GuildRoleStore = m);
waitForStore("GuildMemberStore", m => GuildMemberStore = m);
waitForStore("RelationshipStore", m => RelationshipStore = m);
waitForStore("MediaEngineStore", m => MediaEngineStore = m);
waitForStore("StreamerModeStore", m => StreamerModeStore = m);
waitForStore("SpellcheckStore", m => SpellCheckStore = m);
waitForStore("PermissionStore", m => PermissionStore = m);
waitForStore("PresenceStore", m => PresenceStore = m);
@ -87,9 +86,10 @@ waitForStore("WindowStore", m => WindowStore = m);
waitForStore("EmojiStore", m => EmojiStore = m);
waitForStore("StickersStore", m => StickersStore = m);
waitForStore("TypingStore", m => TypingStore = m);
waitForStore("VoiceStateStore", m => VoiceStateStore = m);
waitForStore("StreamerModeStore", m => StreamerModeStore = m);
waitForStore("ThemeStore", m => {
ThemeStore = m;
// Importing this directly can easily cause circular imports. For this reason, use a non import access here.
Vencord.QuickCss.initQuickCssThemeStore();
});
waitForStore("VoiceStateStore", m => VoiceStateStore = m);