MusicControls: Remove YTM

This commit is contained in:
thororen1234 2026-03-20 13:02:53 -04:00
parent e1ec9bae6a
commit d180234a57
No known key found for this signature in database
15 changed files with 42 additions and 964 deletions

View file

@ -7,6 +7,7 @@
import "./style.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
@ -33,7 +34,7 @@ export default definePlugin({
replacement: [
{
match: /(textValue:.{0,50}channelId:\i\.id\}\),)\i/,
replace: "$1$self.getCharCounter(arguments[0].textValue)"
replace: "$1$self.renderCharCounter(arguments[0].textValue)"
}
]
},
@ -45,8 +46,8 @@ export default definePlugin({
}
}
],
getCharCounter(text: string) {
const premiumType = (UserStore.getCurrentUser().premiumType ?? 0);
renderCharCounter: ErrorBoundary.wrap(text => {
const premiumType = (UserStore.getCurrentUser()?.premiumType ?? 0);
const charMax = premiumType === 2 ? 4000 : 2000;
const { length } = text;
@ -65,5 +66,5 @@ export default definePlugin({
<span className={cl("max")} >{charMax}</span>
</div>
);
}
}, { noop: true })
});

View file

@ -14,17 +14,8 @@
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-ytm-player:hover .vc-ytm-button-row {
#vc-spotify-player:hover .vc-spotify-button-row {
opacity: 1;
height: 32px;
pointer-events: auto;

View file

@ -28,7 +28,6 @@ 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",
@ -44,10 +43,6 @@ export default definePlugin({
"Tidal",
"TidalControls",
"TidalLyrics",
// Youtube
"Youtube",
"YoutubeMusic",
"YoutubeMusicControls"
],
patches: [
@ -91,7 +86,7 @@ export default definePlugin({
],
PanelWrapper({ VencordOriginal, ...props }) {
const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition, showYoutubeMusicControls } = settings.store;
const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, lyricsPosition } = settings.store;
return (
<>
<ErrorBoundary
@ -102,13 +97,12 @@ export default definePlugin({
</div>
)}
>
{showTidalLyrics && LyricsPosition === "above" && <TidalLyrics />}
{showTidalLyrics && lyricsPosition === "above" && <TidalLyrics />}
{showTidalControls && <TidalPlayer />}
{showTidalLyrics && LyricsPosition === "below" && <TidalLyrics />}
{showSpotifyLyrics && LyricsPosition === "above" && <SpotifyLyrics />}
{showTidalLyrics && lyricsPosition === "below" && <TidalLyrics />}
{showSpotifyLyrics && lyricsPosition === "above" && <SpotifyLyrics />}
{showSpotifyControls && <SpotifyPlayer />}
{showSpotifyLyrics && LyricsPosition === "below" && <SpotifyLyrics />}
{showYoutubeMusicControls && <YtmPlayer />}
{showSpotifyLyrics && lyricsPosition === "below" && <SpotifyLyrics />}
</ErrorBoundary>
<VencordOriginal {...props} />

View file

@ -43,12 +43,12 @@ export const settings = definePluginSettings({
default: false,
onChange: v => toggleHoverControls(v)
},
ShowMusicNoteOnNoLyrics: {
showMusicNoteOnNoLyrics: {
description: "Show a music note icon when no lyrics are found",
type: OptionType.BOOLEAN,
default: true,
},
LyricsPosition: {
lyricsPosition: {
description: "Position of the lyrics",
type: OptionType.SELECT,
options: [
@ -56,7 +56,7 @@ export const settings = definePluginSettings({
{ value: "below", label: "Below Player(s)", default: true },
],
},
LyricsProvider: {
lyricsProvider: {
description: "Where lyrics are fetched from",
type: OptionType.SELECT,
options: [
@ -64,7 +64,7 @@ export const settings = definePluginSettings({
{ value: Provider.Lrclib, label: "LRCLIB" },
],
},
TranslateTo: {
translateTo: {
description: "Translate lyrics to - Changing this will clear existing translations",
type: OptionType.SELECT,
options: languages,
@ -73,7 +73,7 @@ export const settings = definePluginSettings({
showToast("Translations cleared", Toasts.Type.SUCCESS);
}
},
LyricsConversion: {
lyricsConversion: {
description: "Automatically translate or romanize lyrics",
type: OptionType.SELECT,
options: [
@ -82,23 +82,23 @@ export const settings = definePluginSettings({
{ value: Provider.Romanized, label: "Romanize" },
]
},
FallbackProvider: {
fallbackProvider: {
description: "When a lyrics provider fails, try other providers",
type: OptionType.BOOLEAN,
default: true,
},
ShowFailedToasts: {
showFailedToasts: {
description: "Hide toasts when lyrics fail to fetch",
type: OptionType.BOOLEAN,
default: true,
},
LyricDelay: {
lyricDelay: {
description: "",
type: OptionType.SLIDER,
default: 0,
...sliderOptions
},
PurgeLyricsCache: {
purgeLyricsCache: {
description: "Purge the lyrics cache",
type: OptionType.COMPONENT,
component: () => (
@ -113,7 +113,7 @@ export const settings = definePluginSettings({
</Button>
),
},
SpotifySectionTitle: {
spotifySectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<section>
@ -142,7 +142,7 @@ export const settings = definePluginSettings({
default: true
},
TidalSectionTitle: {
tdalSectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<section>
@ -164,36 +164,4 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: false,
},
YtmSectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<section>
<HeadingSecondary>Youtube Music</HeadingSecondary>
<Paragraph>
Install <MaskedLink href="https://github.com/pear-devs/pear-desktop">Pear Desktop</MaskedLink> from here, then go to Plugins &rarr; API Server &rarr; Enabled
</Paragraph>
</section>
)
},
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

@ -41,8 +41,8 @@ export async function getLyrics(track: Track | null): Promise<LyricsData | null>
const nullCacheEntry = nullLyricCache.get(cacheKey);
if (nullCacheEntry) {
const provider = settings.store.LyricsProvider;
if (!settings.store.FallbackProvider && nullCacheEntry[provider]) {
const provider = settings.store.lyricsProvider;
if (!settings.store.fallbackProvider && nullCacheEntry[provider]) {
return null;
}
@ -51,7 +51,7 @@ export async function getLyrics(track: Track | null): Promise<LyricsData | null>
}
}
const providersToTry = [settings.store.LyricsProvider, ...providers.filter(p => p !== settings.store.LyricsProvider)];
const providersToTry = [settings.store.lyricsProvider, ...providers.filter(p => p !== settings.store.lyricsProvider)];
for (const provider of providersToTry) {
const lyricsInfo = await lyricFetchers[provider](track);

View file

@ -21,7 +21,7 @@ const nextCl = cl("next");
const currentCl = cl("current");
function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { showMusicNoteOnNoLyrics } = settings.use(["showMusicNoteOnNoLyrics"]);
const { lyricsInfo, lyricRefs, currLrcIndex } = useLyrics({ scroll });
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
@ -50,7 +50,7 @@ function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
{line.text || NoteSvg()}
</BaseText>
</div>
)) : ShowMusicNoteOnNoLyrics ? (
)) : showMusicNoteOnNoLyrics ? (
<TooltipContainer text="No synced lyrics found">
<NoteSvg />
</TooltipContainer>

View file

@ -66,7 +66,7 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
]);
const lyricsInfo = useStateFromStores([SpotifyLrcStore], () => SpotifyLrcStore.lyricsInfo);
const { LyricDelay } = settings.use(["LyricDelay"]);
const { lyricDelay } = settings.use(["lyricDelay"]);
const [currLrcIndex, setCurrLrcIndex] = useState<number | null>(null);
const [nextLyric, setNextLyric] = useState<number | null>(null);
@ -83,14 +83,14 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
useEffect(() => {
if (currentLyrics && position != null) {
const [currentIndex, nextLyricIndex] = getIndexes(currentLyrics, position, LyricDelay);
const [currentIndex, nextLyricIndex] = getIndexes(currentLyrics, position, lyricDelay);
setCurrLrcIndex(currentIndex);
setNextLyric(nextLyricIndex);
} else {
setCurrLrcIndex(null);
setNextLyric(null);
}
}, [currentLyrics, position, LyricDelay]);
}, [currentLyrics, position, lyricDelay]);
useEffect(() => {
if (scroll && currLrcIndex !== null) {

View file

@ -17,7 +17,7 @@ import { LyricsData, Provider } from "./types";
export const lyricsAlternative = [Provider.Translated, Provider.Romanized];
function showNotif(title: string, body: string) {
if (settings.store.ShowFailedToasts) {
if (settings.store.showFailedToasts) {
showNotification({
color: "#ee2902",
title,
@ -44,12 +44,12 @@ export const SpotifyLrcStore = proxyLazyWebpack(() => {
fetchingsTracks.push(e.track?.id ?? "");
lyricsInfo = await getLyrics(e.track);
const { LyricsConversion } = settings.store;
if (LyricsConversion !== Provider.None) {
const { lyricsConversion } = settings.store;
if (lyricsConversion !== Provider.None) {
FluxDispatcher.dispatch({
// @ts-ignore
type: "SPOTIFY_LYRICS_PROVIDER_CHANGE",
provider: LyricsConversion
provider: lyricsConversion
});
}
@ -74,7 +74,7 @@ export const SpotifyLrcStore = proxyLazyWebpack(() => {
}
if (provider === Provider.Translated || provider === Provider.Romanized) {
const originalLyrics = currentInfo?.lyricsVersions[settings.store.LyricsProvider] ||
const originalLyrics = currentInfo?.lyricsVersions[settings.store.lyricsProvider] ||
providers.map(p => currentInfo?.lyricsVersions[p]).find(Boolean);
if (!originalLyrics || !currentInfo) {
@ -87,7 +87,7 @@ export const SpotifyLrcStore = proxyLazyWebpack(() => {
if (provider === Provider.Romanized && !/[^\u0000-\u007F]/.test(lyricsCheckText)) {
lyricsInfo = {
...currentInfo,
useLyric: settings.store.LyricsProvider,
useLyric: settings.store.lyricsProvider,
lyricsVersions: {
...currentInfo.lyricsVersions,
},

View file

@ -17,7 +17,7 @@ import { LyricsModal } from "./modal";
import { cl, NoteSvg, useLyrics } from "./util";
function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { showMusicNoteOnNoLyrics } = settings.use(["showMusicNoteOnNoLyrics"]);
const { lyrics, lyricRefs } = useLyrics({ scroll });
const currentLyrics = lyrics || null;
const NoteElement = NoteSvg(cl("music-note"));
@ -37,7 +37,7 @@ function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
};
if (!currentLyrics) {
return ShowMusicNoteOnNoLyrics ? (
return showMusicNoteOnNoLyrics ? (
<div className="eq-tidal-lyrics"
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>

View file

@ -40,7 +40,7 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
]);
const lyrics = useStateFromStores([TidalLrcStore], () => TidalLrcStore.lyrics);
const { LyricDelay } = settings.use(["LyricDelay"]);
const { lyricDelay } = settings.use(["lyricDelay"]);
const [currLrcIndex, setCurrLrcIndex] = useState<number | null>(null);
const [nextLyric, setNextLyric] = useState<number | null>(null);
@ -57,7 +57,7 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
useEffect(() => {
if (currentLyrics && position) {
const [currentIndex, nextLyric] = calculateIndexes(currentLyrics, position, LyricDelay);
const [currentIndex, nextLyric] = calculateIndexes(currentLyrics, position, lyricDelay);
setCurrLrcIndex(currentIndex);
setNextLyric(nextLyric);
}

View file

@ -13,7 +13,7 @@ import { proxyLazyWebpack } from "@webpack";
import { Flux, FluxDispatcher } from "@webpack/common";
function showNotif(title: string, body: string) {
if (settings.store.ShowFailedToasts) {
if (settings.store.showFailedToasts) {
showNotification({
color: "#ee2902",
title,

View file

@ -1,371 +0,0 @@
/*
* 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 { BaseText } from "@components/BaseText";
import { Flex } from "@components/Flex";
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
import { SeekBar } from "@equicordplugins/musicControls/spotify/SeekBar";
import { debounce } from "@shared/debounce";
import { copyWithToast, openImageModal } from "@utils/discord";
import { classes } from "@utils/misc";
import { ContextMenuApi, FluxDispatcher, 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"), cl("shuffle"), 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")}>
<BaseText
size="xs"
weight="medium"
className={`${cl("progress-time")} ${cl("time-left")}`}
aria-label="Progress"
>
{msToHuman(position)}
</BaseText>
<SeekBar
initialValue={position}
minValue={0}
maxValue={songDuration * 1000}
onValueChange={onChange}
asValueChanges={onChange}
onValueRender={msToHuman}
/>
<BaseText
size="xs"
weight="medium"
className={`${cl("progress-time")} ${cl("time-right")}`}
aria-label="Total Duration"
>
{msToHuman(songDuration * 1000)}
</BaseText>
</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")}>
<BaseText
size="sm"
weight="semibold"
id={cl("song-title")}
className={cl("ellipoverflow")}
title={track?.title}
{...makeLinkProps("Song", track?.videoId, `/watch?v=${track?.videoId}`)}
>
{track?.title}
</BaseText>
{track.artist && (
<BaseText size="sm" className={cl("ellipoverflow")}>
by&nbsp;
<span className={cl("artist")} style={{ fontSize: "inherit" }} title={track.artist}>
{track.artist}
</span>
</BaseText>
)}
{track.album && (
<BaseText size="sm" className={cl("ellipoverflow")}>
on&nbsp;
<span
id={cl("album-title")}
className={cl("album")}
style={{ fontSize: "inherit" }}
title={track.album}
>
{track.album}
</span>
</BaseText>
)}
</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

@ -1,297 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { isPluginEnabled } from "@api/PluginManager";
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;
public 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 = 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");
}
public 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

@ -1,208 +0,0 @@
#vc-ytm-player {
padding: 12px;
background: var(--background-base-low);
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(--text-strong);
}
.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-icon-default);
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-icon-default);
padding: 0;
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.vc-ytm-button:hover {
color: var(--interactive-icon-hover);
background-color: var(--background-mod-strong);
}
.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(--text-strong);
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,7 +19,7 @@ const canSplit: () => boolean = () => {
};
const autoMaxLength = () => {
const hasNitro = UserStore.getCurrentUser().premiumType === 2;
const hasNitro = UserStore.getCurrentUser()?.premiumType === 2;
return hasNitro ? 4000 : 2000;
};