From b6e96a4d3b631c5a2d236ae28179ee0879ae00fe Mon Sep 17 00:00:00 2001 From: Gleb P <63789651+ultard@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:37:13 +0300 Subject: [PATCH 1/7] MemberCount: also show members in voice (#2937) Co-authored-by: Vendicated --- .../src/stores/VoiceStateStore.d.ts | 42 +++++++++++++++++++ packages/discord-types/src/stores/index.d.ts | 1 + src/plugins/memberCount/MemberCount.tsx | 42 ++++++++++++++++--- src/plugins/memberCount/VoiceIcon.tsx | 14 +++++++ src/plugins/memberCount/index.tsx | 17 +++++--- src/plugins/memberCount/style.css | 21 +++++++++- src/plugins/userVoiceShow/components.tsx | 9 ++-- src/plugins/vcNarrator/index.tsx | 15 +++---- src/utils/constants.ts | 4 ++ src/webpack/common/stores.ts | 2 + 10 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 packages/discord-types/src/stores/VoiceStateStore.d.ts create mode 100644 src/plugins/memberCount/VoiceIcon.tsx diff --git a/packages/discord-types/src/stores/VoiceStateStore.d.ts b/packages/discord-types/src/stores/VoiceStateStore.d.ts new file mode 100644 index 000000000..84540d996 --- /dev/null +++ b/packages/discord-types/src/stores/VoiceStateStore.d.ts @@ -0,0 +1,42 @@ +import { DiscordRecord } from "../common"; +import { FluxStore } from "./FluxStore"; + +export type UserVoiceStateRecords = Record; +export type VoiceStates = Record; + +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; +} + +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; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 7a435ff5d..5906262ab 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -16,6 +16,7 @@ export * from "./ThemeStore"; export * from "./TypingStore"; export * from "./UserProfileStore"; export * from "./UserStore"; +export * from "./VoiceStateStore"; export * from "./WindowStore"; /** diff --git a/src/plugins/memberCount/MemberCount.tsx b/src/plugins/memberCount/MemberCount.tsx index 0a3f5e620..020338861 100644 --- a/src/plugins/memberCount/MemberCount.tsx +++ b/src/plugins/memberCount/MemberCount.tsx @@ -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 (
{props => ( -
+
{formattedOnlineCount}
@@ -65,12 +87,22 @@ export function MemberCount({ isTooltip, tooltipGuildId }: { isTooltip?: true; t {props => ( -
+
{numberFormat(totalCount)}
)} + {includeVoice && voiceActivityCount > 0 && + + {props => ( +
+ + {formattedVoiceCount} +
+ )} +
+ }
); } diff --git a/src/plugins/memberCount/VoiceIcon.tsx b/src/plugins/memberCount/VoiceIcon.tsx new file mode 100644 index 000000000..e76f7151b --- /dev/null +++ b/src/plugins/memberCount/VoiceIcon.tsx @@ -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 ( + + + + + ); +} diff --git a/src/plugins/memberCount/index.tsx b/src/plugins/memberCount/index.tsx index 7fa6fbcde..bb48eedbf 100644 --- a/src/plugins/memberCount/index.tsx +++ b/src/plugins/memberCount/index.tsx @@ -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(() => , { noop: true }), renderTooltip: ErrorBoundary.wrap(guild => , { noop: true }) }); diff --git a/src/plugins/memberCount/style.css b/src/plugins/memberCount/style.css index f43bff830..2b0e4400f 100644 --- a/src/plugins/memberCount/style.css +++ b/src/plugins/memberCount/style.css @@ -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; +} \ No newline at end of file diff --git a/src/plugins/userVoiceShow/components.tsx b/src/plugins/userVoiceShow/components.tsx index 9d1c7211b..b7907978d 100644 --- a/src/plugins/userVoiceShow/components.tsx +++ b/src/plugins/userVoiceShow/components.tsx @@ -8,8 +8,8 @@ import { classNameFactory } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; import { classes } from "@utils/misc"; import { Channel } from "@vencord/discord-types"; -import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack"; -import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common"; +import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, mapMangledModuleLazy } from "@webpack"; +import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores, VoiceStateStore } from "@webpack/common"; const cl = classNameFactory("vc-uvs-"); @@ -18,7 +18,6 @@ const { useChannelName } = mapMangledModuleLazy("#{intl::GROUP_DM_ALONE}", { useChannelName: filters.byCode("()=>null==") }); const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({"); -const VoiceStateStore = findStoreLazy("VoiceStateStore"); const Avatar = findComponentByCodeLazy(".status)/2):0"); const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL"); @@ -84,7 +83,7 @@ function VoiceChannelTooltip({ channel, isLocked }: VoiceChannelTooltipProps) { const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id)); const users = useMemo( - () => Object.values(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] ); @@ -139,7 +138,7 @@ export interface VoiceChannelIndicatorProps { const clickTimers = {} as Record; 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; diff --git a/src/plugins/vcNarrator/index.tsx b/src/plugins/vcNarrator/index.tsx index 8931efe3d..5b7b3f82a 100644 --- a/src/plugins/vcNarrator/index.tsx +++ b/src/plugins/vcNarrator/index.tsx @@ -22,13 +22,12 @@ import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { wordsToTitle } from "@utils/text"; import definePlugin, { ReporterTestable } from "@utils/types"; -import { findByPropsLazy } from "@webpack"; -import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore } from "@webpack/common"; +import { Button, ChannelStore, Forms, GuildMemberStore, SelectedChannelStore, SelectedGuildStore, useMemo, UserStore, VoiceStateStore } from "@webpack/common"; import { ReactElement } from "react"; import { getCurrentVoice, settings } from "./settings"; -interface VoiceState { +interface VoiceStateChangeEvent { userId: string; channelId?: string; oldChannelId?: string; @@ -38,8 +37,6 @@ interface VoiceState { selfMute: boolean; } -const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentClientVoiceChannelId"); - // Mute/Deaf for other people than you is commented out, because otherwise someone can spam it and it will be annoying // Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would // not say the second mute, which would lead you to believe they're unmuted @@ -88,7 +85,7 @@ let StatusMap = {} as Record WindowStore = m); waitForStore("EmojiStore", m => EmojiStore = m); waitForStore("StickersStore", m => StickersStore = m); waitForStore("TypingStore", m => TypingStore = m); +waitForStore("VoiceStateStore", m => VoiceStateStore = m); waitForStore("ThemeStore", m => { ThemeStore = m; // Importing this directly can easily cause circular imports. For this reason, use a non import access here. From e857f6806fa181596c160ce867b6b90e8a3895eb Mon Sep 17 00:00:00 2001 From: Vendicated Date: Fri, 5 Sep 2025 03:55:38 +0200 Subject: [PATCH 2/7] ShowMeYourName: respect streamer mode --- .../discord-types/src/stores/StreamerModeStore.d.ts | 11 +++++++++++ packages/discord-types/src/stores/index.d.ts | 1 + src/plugins/showMeYourName/index.tsx | 6 ++++-- src/webpack/common/stores.ts | 2 ++ 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/discord-types/src/stores/StreamerModeStore.d.ts diff --git a/packages/discord-types/src/stores/StreamerModeStore.d.ts b/packages/discord-types/src/stores/StreamerModeStore.d.ts new file mode 100644 index 000000000..efa6cfc9e --- /dev/null +++ b/packages/discord-types/src/stores/StreamerModeStore.d.ts @@ -0,0 +1,11 @@ +import { FluxStore } from "@vencord/discord-types"; + +export class StreamerModeStore extends FluxStore { + get autoToggle(): boolean; + get disableNotifications(): boolean; + get disableSounds(): boolean; + get enableContentProtection(): boolean; + get enabled(): boolean; + get hideInstantInvites(): boolean; + get hidePersonalInformation(): boolean; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index 5906262ab..a843445bc 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -12,6 +12,7 @@ export * from "./RelationshipStore"; export * from "./SelectedChannelStore"; export * from "./SelectedGuildStore"; export * from "./StickersStore"; +export * from "./StreamerModeStore"; export * from "./ThemeStore"; export * from "./TypingStore"; export * from "./UserProfileStore"; diff --git a/src/plugins/showMeYourName/index.tsx b/src/plugins/showMeYourName/index.tsx index 623c70ca0..24a1121dc 100644 --- a/src/plugins/showMeYourName/index.tsx +++ b/src/plugins/showMeYourName/index.tsx @@ -11,7 +11,7 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { Channel, Message, User } from "@vencord/discord-types"; -import { RelationshipStore } from "@webpack/common"; +import { RelationshipStore, StreamerModeStore } from "@webpack/common"; interface UsernameProps { author: { nick: string; authorId: string; }; @@ -74,7 +74,9 @@ export default definePlugin({ const { mode, friendNicknames, displayNames, inReplies } = settings.store; const user = userOverride ?? message.author; - let { username } = user; + let username = StreamerModeStore.enabled + ? user.username[0] + "…" + : user.username; if (displayNames) username = user.globalName || username; diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index b1877b083..1e3e5797e 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -54,6 +54,7 @@ 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} @@ -81,6 +82,7 @@ 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. From 26074b7f185013745da0656adb500b3fbe95fa4f Mon Sep 17 00:00:00 2001 From: u32 Date: Fri, 5 Sep 2025 03:04:08 +0100 Subject: [PATCH 3/7] MessageLatency: fix bot check (#3523) --- src/plugins/messageLatency/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/messageLatency/index.tsx b/src/plugins/messageLatency/index.tsx index cd331b9d7..b81c55cf7 100644 --- a/src/plugins/messageLatency/index.tsx +++ b/src/plugins/messageLatency/index.tsx @@ -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 From 1cfc3fb8f808c5770c0175a2471c7eb5525800fd Mon Sep 17 00:00:00 2001 From: thororen <78185467+thororen1234@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:27:29 -0400 Subject: [PATCH 4/7] fix OnePingPerDM (#3648) also removes a now obsolete patch from FakeNitro --- src/plugins/fakeNitro/index.tsx | 3 +-- src/plugins/onePingPerDM/index.ts | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/plugins/fakeNitro/index.tsx b/src/plugins/fakeNitro/index.tsx index 8dcb2300e..2988f8fb6 100644 --- a/src/plugins/fakeNitro/index.tsx +++ b/src/plugins/fakeNitro/index.tsx @@ -159,7 +159,6 @@ function makeBypassPatches(): Omit { { func: "canUseHighVideoUploadQuality", predicate: () => settings.store.enableStreamQualityBypass }, { func: "canStreamQuality", predicate: () => settings.store.enableStreamQualityBypass }, { func: "canUseClientThemes" }, - { func: "canUseCustomNotificationSounds" }, { func: "canUsePremiumAppIcons" } ]; @@ -176,7 +175,7 @@ function makeBypassPatches(): Omit { export default definePlugin({ name: "FakeNitro", authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.fawn, Devs.captain, Devs.Nuckyz, Devs.AutumnVN], - description: "Allows you to stream in nitro quality, send fake emojis/stickers, use client themes and custom Discord notifications.", + description: "Allows you to stream in nitro quality, send fake emojis/stickers, and use client themes.", dependencies: ["MessageEventsAPI"], settings, diff --git a/src/plugins/onePingPerDM/index.ts b/src/plugins/onePingPerDM/index.ts index b6192fb64..fae787a84 100644 --- a/src/plugins/onePingPerDM/index.ts +++ b/src/plugins/onePingPerDM/index.ts @@ -38,17 +38,21 @@ export default definePlugin({ description: "If unread messages are sent by a user in DMs multiple times, you'll only receive one audio ping. Read the messages to reset the limit", authors: [Devs.ProffDea], settings, - patches: [{ - find: ".getDesktopType()===", - replacement: [{ - match: /(\i\.\i\.getDesktopType\(\)===\i\.\i\.NEVER)\)/, - replace: "$&if(!$self.isPrivateChannelRead(arguments[0]?.message))return;else " - }, + patches: [ { - match: /sound:(\i\?\i:void 0,soundpack:\i,volume:\i,onClick)/, - replace: "sound:!$self.isPrivateChannelRead(arguments[0]?.message)?undefined:$1" - }] - }], + find: ".getDesktopType()===", + replacement: [ + { + match: /(\i\.\i\.getDesktopType\(\)===\i\.\i\.NEVER)\)/, + replace: "$&if(!$self.isPrivateChannelRead(arguments[0]?.message))return;else " + }, + { + match: /sound:(\i\?\i:void 0,volume:\i,onClick)/, + replace: "sound:!$self.isPrivateChannelRead(arguments[0]?.message)?undefined:$1" + } + ] + } + ], isPrivateChannelRead(message: MessageJSON) { const channelType = ChannelStore.getChannel(message.channel_id)?.type; if ( From 1a3f14d5294e3dc3218c6c91eeb0f876f8fb9949 Mon Sep 17 00:00:00 2001 From: Johannes7k75 <57235955+Johannes7k75@users.noreply.github.com> Date: Sun, 7 Sep 2025 01:56:15 +0200 Subject: [PATCH 5/7] MusicControls: Add ytm (#375) * Add ytm controls * Update settings for url * remove logs * Remove logs * uncomment ytm tags * Replace console.log with logger * Log url open with logger * Remove old setting * Use utility functions for PlayerState * Add Shuffle mode * Change Shuffle button background color based on shuffle mode --- .../musicControls/hoverOnly.css | 11 +- src/equicordplugins/musicControls/index.tsx | 6 +- .../musicControls/settings.tsx | 29 ++ .../youtubeMusic/PlayerComponent.tsx | 368 ++++++++++++++++++ .../musicControls/youtubeMusic/YtmStore.ts | 299 ++++++++++++++ .../musicControls/youtubeMusic/ytmStyles.css | 208 ++++++++++ 6 files changed, 917 insertions(+), 4 deletions(-) create mode 100644 src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx create mode 100644 src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts create mode 100644 src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css diff --git a/src/equicordplugins/musicControls/hoverOnly.css b/src/equicordplugins/musicControls/hoverOnly.css index 9df7594ae..14f7402f7 100644 --- a/src/equicordplugins/musicControls/hoverOnly.css +++ b/src/equicordplugins/musicControls/hoverOnly.css @@ -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; diff --git a/src/equicordplugins/musicControls/index.tsx b/src/equicordplugins/musicControls/index.tsx index db1801eb2..d76237c33 100644 --- a/src/equicordplugins/musicControls/index.tsx +++ b/src/equicordplugins/musicControls/index.tsx @@ -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 ( <> } {showSpotifyControls && } {showSpotifyLyrics && LyricsPosition === "below" && } + {showYoutubeMusicControls && } diff --git a/src/equicordplugins/musicControls/settings.tsx b/src/equicordplugins/musicControls/settings.tsx index a3b94de87..f14155ec1 100644 --- a/src/equicordplugins/musicControls/settings.tsx +++ b/src/equicordplugins/musicControls/settings.tsx @@ -189,4 +189,33 @@ export const settings = definePluginSettings({ ], default: "line", }, + + YtmSectionTitle: { + type: OptionType.COMPONENT, + component: () => ( + + Youtube Music + + ) + }, + 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; + } + } + } + }); diff --git a/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx new file mode 100644 index 000000000..543509273 --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx @@ -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 . +*/ + +import "./ytmStyles.css"; + +import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons"; +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 Song, type Repeat, YoutubeMusicStore } from "./YtmStore"; +import { SeekBar } from "@equicordplugins/musicControls/spotify/SeekBar"; + +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 () => ( + + {label} + + + ); +} + +// 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) { + return ( + + ); +} + +function CopyContextMenu({ name, path }: { name: string; path: string; }) { + const copyId = `ytm-copy-${name}`; + const openId = `ytm-open-${name}`; + + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={`YouTube Music ${name} Menu`} + > + copyWithToast(`https://music.youtube.com${path}`)} + icon={LinkIcon} + /> + YoutubeMusicStore.openExternal(path)} + icon={OpenExternalIcon} + /> + + ); +} + +function makeContextMenu(name: string, path: string) { + return (e: React.MouseEvent) => + ContextMenuApi.openContextMenu(e, () => ); +} + +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 ( + + + + + + + + ); +} + +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(storePosition); + + useEffect(() => { + setPosition(YoutubeMusicStore.position); + }, [storePosition]); + + const onChange = (v: number) => { + if (isSettingPosition) return; + setPosition(v); + seek(v); + }; + + return ( +
+ + {msToHuman(position)} + + + + {msToHuman(songDuration * 1000)} + +
+ ); +} + +function AlbumContextMenu({ track }: { track: Song; }) { + const [volume, muted] = useStateFromStores([YoutubeMusicStore], () => [YoutubeMusicStore.volume, YoutubeMusicStore.muted]); + + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label="YouTube Music Album Menu" + > + {/* YoutubeMusicStore.openExternal(`/album/${track?.album}`)} + icon={OpenExternalIcon} + /> */} + track?.imageSrc && openImageModal({ url: track.imageSrc })} + icon={ImageIcon} + /> + ( + YoutubeMusicStore.setVolume(v))} + /> + )} + /> + YoutubeMusicStore.toggleMute()} + /> + + ); +} + +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; +} + +function Info({ track }: { track: NonNullable; }) { + const img = track?.imageSrc; + + const [coverExpanded, setCoverExpanded] = useState(false); + + const i = ( + <> + {img && ( + Album Image setCoverExpanded(!coverExpanded)} + onContextMenu={e => { + ContextMenuApi.openContextMenu(e, () => ); + }} + /> + )} + + ); + + if (coverExpanded && img) return ( +
+ {i} +
+ ); + + return ( +
+ {i} +
+ + {track?.title} + + {track.artist && ( + + by  + + {track.artist} + + + )} + {track.album && ( + + on  + + {track.album} + + + )} +
+
+ ); +} + +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 ( +
+ + + +
+ ); +} + diff --git a/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts new file mode 100644 index 000000000..39572267c --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts @@ -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>; + +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; +}); diff --git a/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css b/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css new file mode 100644 index 000000000..652f90bf3 --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css @@ -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; +} \ No newline at end of file From 0eaaa9c122336d8047b947ca6a0c03a11e3e063e Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 7 Sep 2025 11:47:24 -0400 Subject: [PATCH 6/7] Lint Fixes --- packages/discord-types/src/index.d.ts | 1 - .../discord-types/src/passiveupdatestate.d.ts | 2 +- .../src/stores/VoiceStateStore.d.ts | 4 ++++ packages/discord-types/src/voicestate.d.ts | 21 ----------------- src/equicordplugins/allCallTimers/index.tsx | 2 +- .../youtubeMusic/PlayerComponent.tsx | 4 ++-- .../musicControls/youtubeMusic/YtmStore.ts | 18 +++++++-------- src/equicordplugins/orbolayBridge/index.tsx | 23 ++++++++++++------- 8 files changed, 32 insertions(+), 43 deletions(-) delete mode 100644 packages/discord-types/src/voicestate.d.ts diff --git a/packages/discord-types/src/index.d.ts b/packages/discord-types/src/index.d.ts index 78c980c9f..d302e326f 100644 --- a/packages/discord-types/src/index.d.ts +++ b/packages/discord-types/src/index.d.ts @@ -7,5 +7,4 @@ export * from "./menu"; export * from "./stores"; export * from "./passiveupdatestate"; export * from "./utils"; -export * from "./voicestate"; export * as Webpack from "../webpack"; diff --git a/packages/discord-types/src/passiveupdatestate.d.ts b/packages/discord-types/src/passiveupdatestate.d.ts index 468dd9641..31bed9ffa 100644 --- a/packages/discord-types/src/passiveupdatestate.d.ts +++ b/packages/discord-types/src/passiveupdatestate.d.ts @@ -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; diff --git a/packages/discord-types/src/stores/VoiceStateStore.d.ts b/packages/discord-types/src/stores/VoiceStateStore.d.ts index 84540d996..0f9f9f799 100644 --- a/packages/discord-types/src/stores/VoiceStateStore.d.ts +++ b/packages/discord-types/src/stores/VoiceStateStore.d.ts @@ -14,12 +14,16 @@ export interface VoiceState extends DiscordRecord { selfDeaf: boolean; selfVideo: boolean; selfStream: boolean | undefined; + sessionId: string; suppress: boolean; requestToSpeakTimestamp: string | null | undefined; discoverable: boolean; isVoiceMuted(): boolean; isVoiceDeafened(): boolean; + + oldChannelId?: string; + guildId?: string; } export class VoiceStateStore extends FluxStore { diff --git a/packages/discord-types/src/voicestate.d.ts b/packages/discord-types/src/voicestate.d.ts deleted file mode 100644 index aa7ddcce2..000000000 --- a/packages/discord-types/src/voicestate.d.ts +++ /dev/null @@ -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; -} diff --git a/src/equicordplugins/allCallTimers/index.tsx b/src/equicordplugins/allCallTimers/index.tsx index b4fb0e852..c1c1486f2 100644 --- a/src/equicordplugins/allCallTimers/index.tsx +++ b/src/equicordplugins/allCallTimers/index.tsx @@ -149,7 +149,7 @@ export default definePlugin({ let { oldChannelId } = state; if (isMe && channelId !== myLastChannelId) { oldChannelId = myLastChannelId; - myLastChannelId = channelId; + myLastChannelId = channelId ?? undefined; } if (channelId !== oldChannelId) { diff --git a/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx index 543509273..4529e7d32 100644 --- a/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx +++ b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx @@ -19,13 +19,13 @@ 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 Song, type Repeat, YoutubeMusicStore } from "./YtmStore"; -import { SeekBar } from "@equicordplugins/musicControls/spotify/SeekBar"; +import { type Repeat, type Song, YoutubeMusicStore } from "./YtmStore"; const cl = (className: string) => `vc-ytm-${className}`; diff --git a/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts index 39572267c..e3e9e908b 100644 --- a/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts +++ b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts @@ -29,7 +29,7 @@ enum MediaType { OtherVideo = "OTHER_VIDEO", } -export type RepeatMode = 'NONE' | 'ONE' | 'ALL'; +export type RepeatMode = "NONE" | "ONE" | "ALL"; export interface Song { @@ -49,13 +49,13 @@ export interface Song { } enum DataTypes { - PlayerInfo = 'PLAYER_INFO', - VideoChanged = 'VIDEO_CHANGED', - PlayerStateChanged = 'PLAYER_STATE_CHANGED', - PositionChanged = 'POSITION_CHANGED', - VolumeChanged = 'VOLUME_CHANGED', - RepeatChanged = 'REPEAT_CHANGED', - ShuffleChanged = 'SHUFFLE_CHANGED', + PlayerInfo = "PLAYER_INFO", + VideoChanged = "VIDEO_CHANGED", + PlayerStateChanged = "PLAYER_STATE_CHANGED", + PositionChanged = "POSITION_CHANGED", + VolumeChanged = "VOLUME_CHANGED", + RepeatChanged = "REPEAT_CHANGED", + ShuffleChanged = "SHUFFLE_CHANGED", } type PlayerInfo = { @@ -200,7 +200,7 @@ export const YoutubeMusicStore = proxyLazyWebpack(() => { 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; diff --git a/src/equicordplugins/orbolayBridge/index.tsx b/src/equicordplugins/orbolayBridge/index.tsx index 59519f595..e94b6e95b 100644 --- a/src/equicordplugins/orbolayBridge/index.tsx +++ b/src/equicordplugins/orbolayBridge/index.tsx @@ -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), }) ); } From 330788ed25d46026b46121b9e0b1f315d8c679f8 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Sun, 7 Sep 2025 11:48:44 -0400 Subject: [PATCH 7/7] Remove Duped sessionId --- packages/discord-types/src/stores/VoiceStateStore.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/discord-types/src/stores/VoiceStateStore.d.ts b/packages/discord-types/src/stores/VoiceStateStore.d.ts index 0f9f9f799..2678e693b 100644 --- a/packages/discord-types/src/stores/VoiceStateStore.d.ts +++ b/packages/discord-types/src/stores/VoiceStateStore.d.ts @@ -14,7 +14,6 @@ export interface VoiceState extends DiscordRecord { selfDeaf: boolean; selfVideo: boolean; selfStream: boolean | undefined; - sessionId: string; suppress: boolean; requestToSpeakTimestamp: string | null | undefined; discoverable: boolean;