aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVen <vendicated@riseup.net>2022-11-07 22:28:29 +0100
committerGitHub <noreply@github.com>2022-11-07 22:28:29 +0100
commit6a8564089bea162d9c4d52925eb1239b6b270fa4 (patch)
treeb411e223ffab2752175e2e883908cd814f73455d
parent7d5ade21fc9b56d21e2eb9e5b0d35502432adaa2 (diff)
downloadVencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.tar.gz
Vencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.tar.bz2
Vencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.zip
SpotifyControls plugin (#190)
-rw-r--r--src/components/ErrorBoundary.tsx128
-rw-r--r--src/components/Flex.tsx6
-rw-r--r--src/plugins/reverseImageSearch.tsx5
-rw-r--r--src/plugins/spotifyControls/PlayerComponent.tsx329
-rw-r--r--src/plugins/spotifyControls/SpotifyStore.ts203
-rw-r--r--src/plugins/spotifyControls/index.tsx56
-rw-r--r--src/plugins/spotifyControls/styles.css115
-rw-r--r--src/utils/misc.tsx4
-rw-r--r--src/webpack/common.tsx80
9 files changed, 857 insertions, 69 deletions
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index ed565c5..aa1e889 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+import { LazyComponent } from "../utils";
import Logger from "../utils/logger";
import { Margins, React } from "../webpack/common";
import { ErrorCard } from "./ErrorCard";
@@ -32,68 +33,75 @@ const logger = new Logger("React ErrorBoundary", color);
const NO_ERROR = {};
-export default class ErrorBoundary extends React.Component<React.PropsWithChildren<Props>> {
- static wrap<T = any>(Component: React.ComponentType<T>): (props: T) => React.ReactElement {
- return props => (
- <ErrorBoundary>
- <Component {...props as any/* I hate react typings ??? */} />
- </ErrorBoundary>
- );
- }
-
- state = {
- error: NO_ERROR as any,
- stack: "",
- message: ""
- };
+// We might want to import this in a place where React isn't ready yet.
+// Thus, wrap in a LazyComponent
+const ErrorBoundary = LazyComponent(() => {
+ return class ErrorBoundary extends React.PureComponent<React.PropsWithChildren<Props>> {
+ state = {
+ error: NO_ERROR as any,
+ stack: "",
+ message: ""
+ };
- static getDerivedStateFromError(error: any) {
- let stack = error?.stack ?? "";
- let message = error?.message || String(error);
+ static getDerivedStateFromError(error: any) {
+ let stack = error?.stack ?? "";
+ let message = error?.message || String(error);
- if (error instanceof Error && stack) {
- const eolIdx = stack.indexOf("\n");
- if (eolIdx !== -1) {
- message = stack.slice(0, eolIdx);
- stack = stack.slice(eolIdx + 1).replace(/https:\/\/\S+\/assets\//g, "");
+ if (error instanceof Error && stack) {
+ const eolIdx = stack.indexOf("\n");
+ if (eolIdx !== -1) {
+ message = stack.slice(0, eolIdx);
+ stack = stack.slice(eolIdx + 1).replace(/https:\/\/\S+\/assets\//g, "");
+ }
}
+
+ return { error, stack, message };
}
- return { error, stack, message };
- }
-
- componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- this.props.onError?.(error, errorInfo);
- logger.error("A component threw an Error\n", error);
- logger.error("Component Stack", errorInfo.componentStack);
- }
-
- render() {
- if (this.state.error === NO_ERROR) return this.props.children;
-
- if (this.props.fallback)
- return <this.props.fallback
- children={this.props.children}
- {...this.state}
- />;
-
- const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console.";
-
- return (
- <ErrorCard style={{
- overflow: "hidden",
- }}>
- <h1>Oh no!</h1>
- <p>{msg}</p>
- <code>
- {this.state.message}
- {!!this.state.stack && (
- <pre className={Margins.marginTop8}>
- {this.state.stack}
- </pre>
- )}
- </code>
- </ErrorCard>
- );
- }
-}
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ this.props.onError?.(error, errorInfo);
+ logger.error("A component threw an Error\n", error);
+ logger.error("Component Stack", errorInfo.componentStack);
+ }
+
+ render() {
+ if (this.state.error === NO_ERROR) return this.props.children;
+
+ if (this.props.fallback)
+ return <this.props.fallback
+ children={this.props.children}
+ {...this.state}
+ />;
+
+ const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console.";
+
+ return (
+ <ErrorCard style={{
+ overflow: "hidden",
+ }}>
+ <h1>Oh no!</h1>
+ <p>{msg}</p>
+ <code>
+ {this.state.message}
+ {!!this.state.stack && (
+ <pre className={Margins.marginTop8}>
+ {this.state.stack}
+ </pre>
+ )}
+ </code>
+ </ErrorCard>
+ );
+ }
+ };
+}) as
+ React.ComponentType<React.PropsWithChildren<Props>> & {
+ wrap<T extends JSX.IntrinsicAttributes = any>(Component: React.ComponentType<T>): React.ComponentType<T>;
+ };
+
+ErrorBoundary.wrap = Component => props => (
+ <ErrorBoundary>
+ <Component {...props} />
+ </ErrorBoundary>
+);
+
+export default ErrorBoundary;
diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx
index c371cb3..1987fab 100644
--- a/src/components/Flex.tsx
+++ b/src/components/Flex.tsx
@@ -24,9 +24,11 @@ export function Flex(props: React.PropsWithChildren<{
className?: string;
} & React.HTMLProps<HTMLDivElement>>) {
props.style ??= {};
- props.style.flexDirection ||= props.flexDirection;
- props.style.gap ??= "1em";
props.style.display = "flex";
+ // TODO(ven): Remove me, what was I thinking??
+ props.style.gap ??= "1em";
+ props.style.flexDirection ||= props.flexDirection;
+ delete props.flexDirection;
return (
<div {...props}>
{props.children}
diff --git a/src/plugins/reverseImageSearch.tsx b/src/plugins/reverseImageSearch.tsx
index 79c2488..3bcefba 100644
--- a/src/plugins/reverseImageSearch.tsx
+++ b/src/plugins/reverseImageSearch.tsx
@@ -17,9 +17,8 @@
*/
import { Devs } from "../utils/constants";
-import { lazyWebpack } from "../utils/misc";
import definePlugin from "../utils/types";
-import { filters } from "../webpack";
+import { Menu } from "../webpack/common";
const Engines = {
Google: "https://www.google.com/searchbyimage?image_url=",
@@ -29,8 +28,6 @@ const Engines = {
TinEye: "https://www.tineye.com/search?url="
};
-const Menu = lazyWebpack(filters.byProps("MenuItem"));
-
export default definePlugin({
name: "ReverseImageSearch",
description: "yes",
diff --git a/src/plugins/spotifyControls/PlayerComponent.tsx b/src/plugins/spotifyControls/PlayerComponent.tsx
new file mode 100644
index 0000000..0e03121
--- /dev/null
+++ b/src/plugins/spotifyControls/PlayerComponent.tsx
@@ -0,0 +1,329 @@
+/*
+ * 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 ErrorBoundary from "../../components/ErrorBoundary";
+import { Flex } from "../../components/Flex";
+import { classes, debounce, LazyComponent, lazyWebpack } from "../../utils";
+import { ContextMenu, FluxDispatcher, Forms, Menu, React, Tooltip } from "../../webpack/common";
+import { filters, find } from "../../webpack/webpack";
+import { SpotifyStore, Track } from "./SpotifyStore";
+
+const cl = (className: string) => `vc-spotify-${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")}`;
+}
+
+const useStateFromStores: <T>(
+ stores: typeof SpotifyStore[],
+ mapper: () => T,
+ idk?: null,
+ compare?: (old: T, newer: T) => boolean
+) => T
+ = lazyWebpack(filters.byCode("useStateFromStores"));
+
+function Svg(path: string, label: string) {
+ return () => (
+ <svg
+ className={classes(cl("button-icon"), cl(label))}
+ height="24"
+ width="24"
+ viewBox="0 0 48 48"
+ fill="currentColor"
+ aria-label={label}
+ focusable={false}
+ >
+ <path d={path} />
+ </svg>
+ );
+}
+
+// https://fonts.google.com/icons
+const PlayButton = Svg("M16 37.85v-28l22 14Zm3-14Zm0 8.55 13.45-8.55L19 15.3Z", "play");
+const PauseButton = Svg("M28.25 38V10H36v28ZM12 38V10h7.75v28Z", "pause");
+const SkipPrev = Svg("M11 36V12h3v24Zm26 0L19.7 24 37 12Zm-3-12Zm0 6.25v-12.5L24.95 24Z", "previous");
+const SkipNext = Svg("M34 36V12h3v24Zm-23 0V12l17.3 12Zm3-12Zm0 6.25L23.05 24 14 17.75Z", "next");
+// const Like = Svg("m24 41.95-2.05-1.85q-5.3-4.85-8.75-8.375-3.45-3.525-5.5-6.3T4.825 20.4Q4 18.15 4 15.85q0-4.5 3.025-7.525Q10.05 5.3 14.5 5.3q2.85 0 5.275 1.35Q22.2 8 24 10.55q2.1-2.7 4.45-3.975T33.5 5.3q4.45 0 7.475 3.025Q44 11.35 44 15.85q0 2.3-.825 4.55T40.3 25.425q-2.05 2.775-5.5 6.3T26.05 40.1ZM24 38q5.05-4.65 8.325-7.975 3.275-3.325 5.2-5.825 1.925-2.5 2.7-4.45.775-1.95.775-3.9 0-3.3-2.1-5.425T33.5 8.3q-2.55 0-4.75 1.575T25.2 14.3h-2.45q-1.3-2.8-3.5-4.4-2.2-1.6-4.75-1.6-3.3 0-5.4 2.125Q7 12.55 7 15.85q0 1.95.775 3.925.775 1.975 2.7 4.5Q12.4 26.8 15.7 30.1 19 33.4 24 38Zm0-14.85Z", "like");
+// const LikeOn = Svg("m24 41.95-2.05-1.85q-5.3-4.85-8.75-8.375-3.45-3.525-5.5-6.3T4.825 20.4Q4 18.15 4 15.85q0-4.5 3.025-7.525Q10.05 5.3 14.5 5.3q2.85 0 5.275 1.35Q22.2 8 24 10.55q2.1-2.7 4.45-3.975T33.5 5.3q4.45 0 7.475 3.025Q44 11.35 44 15.85q0 2.3-.825 4.55T40.3 25.425q-2.05 2.775-5.5 6.3T26.05 40.1ZM24 38q5.05-4.65 8.325-7.975 3.275-3.325 5.2-5.825 1.925-2.5 2.7-4.45.775-1.95.775-3.9 0-3.3-2.1-5.425T33.5 8.3q-2.55 0-4.75 1.575T25.2 14.3h-2.45q-1.3-2.8-3.5-4.4-2.2-1.6-4.75-1.6-3.3 0-5.4 2.125Q7 12.55 7 15.85q0 1.95.775 3.925.775 1.975 2.7 4.5Q12.4 26.8 15.7 30.1 19 33.4 24 38Zm0-14.85Z", "liked");
+const Repeat = Svg("m14 44-8-8 8-8 2.1 2.2-4.3 4.3H35v-8h3v11H11.8l4.3 4.3Zm-4-22.5v-11h26.2l-4.3-4.3L34 4l8 8-8 8-2.1-2.2 4.3-4.3H13v8Z", "repeat");
+const Shuffle = Svg("M29.05 40.5v-3h6.25l-9.2-9.15 2.1-2.15 9.3 9.2v-6.35h3V40.5Zm-19.45 0-2.1-2.15 27.9-27.9h-6.35v-3H40.5V18.9h-3v-6.3Zm10.15-18.7L7.5 9.6l2.15-2.15 12.25 12.2Z", "shuffle");
+
+function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
+ return (
+ <button
+ className={cl("button")}
+ {...props}
+ >
+ {props.children}
+ </button>
+ );
+}
+
+function TooltipText(props: React.HtmlHTMLAttributes<HTMLParagraphElement>) {
+ return (
+ <Tooltip text={props.children}>
+ {({ onMouseLeave, onMouseEnter }) => (
+ <p
+ className={cl("tooltip-text")}
+ {...props}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ >
+ {props.children}
+ </p >
+ )}
+ </Tooltip>
+ );
+}
+
+function Controls() {
+ const [isPlaying, shuffle, repeat] = useStateFromStores(
+ [SpotifyStore],
+ () => [SpotifyStore.isPlaying, SpotifyStore.shuffle, SpotifyStore.repeat]
+ );
+
+ const [nextRepeat, repeatClassName] = (() => {
+ switch (repeat) {
+ case "off": return ["context", "repeat-off"] as const;
+ case "context": return ["track", "repeat-context"] as const;
+ case "track": return ["off", "repeat-track"] as const;
+ default: throw new Error(`Invalid repeat state ${repeat}`);
+ }
+ })();
+
+ return (
+ <Flex className={cl("button-row")} style={{ gap: 0 }}>
+ <Button
+ className={classes(cl("button"), cl(shuffle ? "shuffle-on" : "shuffle-off"))}
+ onClick={() => SpotifyStore.setShuffle(!shuffle)}
+ >
+ <Shuffle />
+ </Button>
+ <Button onClick={() => SpotifyStore.prev()}>
+ <SkipPrev />
+ </Button>
+ <Button onClick={() => SpotifyStore.setPlaying(!isPlaying)}>
+ {isPlaying ? <PauseButton /> : <PlayButton />}
+ </Button>
+ <Button onClick={() => SpotifyStore.next()}>
+ <SkipNext />
+ </Button>
+ <Button
+ className={classes(cl("button"), cl(repeatClassName))}
+ onClick={() => SpotifyStore.setRepeat(nextRepeat)}
+ >
+ {repeat === "track" && <span style={{ fontSize: "70%" }}>1</span>}
+ <Repeat />
+ </Button>
+ </Flex>
+ );
+}
+
+const seek = debounce((v: number) => {
+ SpotifyStore.seek(v);
+});
+
+const Slider = LazyComponent(() => {
+ const filter = filters.byCode("sliderContainer");
+ return find(m => m.render && filter(m.render));
+});
+
+function SeekBar() {
+ const { duration } = SpotifyStore.track!;
+
+ const [storePosition, isSettingPosition, isPlaying] = useStateFromStores(
+ [SpotifyStore],
+ () => [SpotifyStore.mPosition, SpotifyStore.isSettingPosition, SpotifyStore.isPlaying]
+ );
+
+ const [position, setPosition] = React.useState(storePosition);
+
+ // eslint-disable-next-line consistent-return
+ React.useEffect(() => {
+ if (isPlaying && !isSettingPosition) {
+ setPosition(SpotifyStore.position);
+ const interval = setInterval(() => {
+ setPosition(p => p + 1000);
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }
+ }, [storePosition, isSettingPosition, isPlaying]);
+
+ return (
+ <div id={cl("progress-bar")}>
+ <span className={cl("progress-time")} aria-label="Progress">{msToHuman(position)}</span>
+ <Slider
+ minValue={0}
+ maxValue={duration}
+ value={position}
+ onChange={(v: number) => {
+ if (isSettingPosition) return;
+ setPosition(v);
+ seek(v);
+ }}
+ renderValue={msToHuman}
+ />
+ <span className={cl("progress-time")} aria-label="Total Duration">{msToHuman(duration)}</span>
+ </div>
+ );
+}
+
+
+function AlbumContextMenu({ track }: { track: Track; }) {
+ const volume = useStateFromStores([SpotifyStore], () => SpotifyStore.volume);
+
+ return (
+ <Menu.ContextMenu
+ navId="spotify-album-menu"
+ onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
+ aria-label="Spotify Album Menu"
+ >
+ <Menu.MenuItem
+ key="open-album"
+ id="open-album"
+ label="Open Album"
+ action={() => SpotifyStore.openExternal(`/album/${track.album.id}`)}
+ />
+ <Menu.MenuItem
+ key="view-cover"
+ id="view-cover"
+ label="View Album Cover"
+ // trolley
+ action={() => (Vencord.Plugins.plugins.ViewIcons as any).openImage(track.album.image.url)}
+ />
+ <Menu.MenuControlItem
+ id="spotify-volume"
+ key="spotify-volume"
+ label="Volume"
+ control={(props, ref) => (
+ <Slider
+ {...props}
+ ref={ref}
+ value={volume}
+ minValue={0}
+ maxValue={100}
+ onChange={debounce((v: number) => SpotifyStore.setVolume(v))}
+ />
+ )}
+ />
+ </Menu.ContextMenu>
+ );
+}
+
+function Info({ track }: { track: Track; }) {
+ const img = track?.album?.image;
+
+ const [coverExpanded, setCoverExpanded] = React.useState(false);
+
+ const i = (
+ <img
+ id={cl("album-image")}
+ src={img?.url}
+ alt="Album Image"
+ onClick={() => setCoverExpanded(!coverExpanded)}
+ onContextMenu={e => {
+ ContextMenu.open(e, () => <AlbumContextMenu track={track} />);
+ }}
+ />
+ );
+
+ if (coverExpanded) return (
+ <div id={cl("album-expanded-wrapper")}>
+ {i}
+ </div>
+ );
+
+ return (
+ <div id={cl("info-wrapper")}>
+ {i}
+ <div id={cl("titles")}>
+ <TooltipText
+ id={cl("song-title")}
+ onClick={() => SpotifyStore.openExternal(`/track/${track.id}`)}
+ >
+ {track.name}
+ </TooltipText>
+ <TooltipText>
+ {track.artists.map((a, i) => (
+ <React.Fragment key={a.id}>
+ <a
+ className={cl("artist")}
+ href={`https://open.spotify.com/artist/${a.id}`}
+ target="_blank"
+ >
+ {a.name}
+ </a>
+ {i !== track.artists.length - 1 && <span className={cl("comma")}>{", "}</span>}
+ </React.Fragment>
+ ))}
+ </TooltipText>
+ </div>
+ </div>
+ );
+}
+
+export function Player() {
+ const track = useStateFromStores(
+ [SpotifyStore],
+ () => SpotifyStore.track,
+ null,
+ (prev, next) => prev?.id === next?.id
+ );
+
+ const device = useStateFromStores(
+ [SpotifyStore],
+ () => SpotifyStore.device,
+ null,
+ (prev, next) => prev?.id === next?.id
+ );
+
+ const isPlaying = useStateFromStores([SpotifyStore], () => SpotifyStore.isPlaying);
+ const [shouldHide, setShouldHide] = React.useState(false);
+
+ // Hide player after 5 minutes of inactivity
+ // eslint-disable-next-line consistent-return
+ React.useEffect(() => {
+ setShouldHide(false);
+ if (!isPlaying) {
+ const timeout = setTimeout(() => setShouldHide(true), 1000 * 60 * 5);
+ return () => clearTimeout(timeout);
+ }
+ }, [isPlaying]);
+
+ if (!track || !device?.is_active || shouldHide)
+ return null;
+
+ return (
+ <ErrorBoundary fallback={() => (
+ <>
+ <Forms.FormText>Failed to render Spotify Modal :(</Forms.FormText>
+ <Forms.FormText>Check the console for errors</Forms.FormText>
+ </>
+ )}>
+ <div id={cl("player")}>
+ <Info track={track} />
+ <SeekBar />
+ <Controls />
+ </div>
+ </ErrorBoundary>
+ );
+}
diff --git a/src/plugins/spotifyControls/SpotifyStore.ts b/src/plugins/spotifyControls/SpotifyStore.ts
new file mode 100644
index 0000000..d7d52bc
--- /dev/null
+++ b/src/plugins/spotifyControls/SpotifyStore.ts
@@ -0,0 +1,203 @@
+/*
+ * 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 cssText from "~fileContent/styles.css";
+
+import { IpcEvents, lazyWebpack, proxyLazy } from "../../utils";
+import { filters } from "../../webpack";
+import { Flux, FluxDispatcher } from "../../webpack/common";
+
+export interface Track {
+ id: string;
+ name: string;
+ duration: number;
+ isLocal: boolean;
+ album: {
+ id: string;
+ name: string;
+ image: {
+ height: number;
+ width: number;
+ url: string;
+ };
+ };
+ artists: {
+ id: string;
+ href: string;
+ name: string;
+ type: string;
+ uri: string;
+ }[];
+}
+
+interface PlayerState {
+ accountId: string;
+ track: Track | null;
+ volumePercent: number,
+ isPlaying: boolean,
+ repeat: boolean,
+ position: number,
+ context?: any;
+ device?: Device;
+
+ // added by patch
+ actual_repeat: Repeat;
+}
+
+interface Device {
+ id: string;
+ is_active: boolean;
+}
+
+type Repeat = "off" | "track" | "context";
+
+// Don't wanna run before Flux and Dispatcher are ready!
+export const SpotifyStore = proxyLazy(() => {
+ // TODO: Move this elsewhere
+ const style = document.createElement("style");
+ style.innerText = cssText;
+ document.head.appendChild(style);
+
+ // For some reason ts hates extends Flux.Store
+ const { Store } = Flux;
+
+ const SpotifySocket = lazyWebpack(filters.byProps("getActiveSocketAndDevice"));
+ const SpotifyAPI = lazyWebpack(filters.byProps("SpotifyAPIMarker"));
+
+ const API_BASE = "https://api.spotify.com/v1/me/player";
+
+ class SpotifyStore extends Store {
+ constructor(dispatcher: any, handlers: any) {
+ super(dispatcher, handlers);
+ }
+
+ public mPosition = 0;
+ private start = 0;
+
+ public track: Track | null = null;
+ public device: Device | null = null;
+ public isPlaying = false;
+ public repeat: Repeat = "off";
+ public shuffle = false;
+ public volume = 0;
+
+ public isSettingPosition = false;
+
+ public openExternal(path: string) {
+ VencordNative.ipc.invoke(IpcEvents.OPEN_EXTERNAL, "https://open.spotify.com" + path);
+ }
+
+ // Need to keep track of this manually
+ public get position(): number {
+ let pos = this.mPosition;
+ if (this.isPlaying) {
+ pos += Date.now() - this.start;
+ }
+ return pos;
+ }
+
+ public set position(p: number) {
+ this.mPosition = p;
+ this.start = Date.now();
+ }
+
+ prev() {
+ this.req("post", "/previous");
+ }
+
+ next() {
+ this.req("post", "/next");
+ }
+
+ setVolume(percent: number) {
+ this.req("put", "/volume", {
+ query: {
+ volume_percent: Math.round(percent)
+ }
+
+ }).then(() => {
+ this.volume = percent;
+ this.emitChange();
+ });
+ }
+
+ setPlaying(playing: boolean) {
+ this.req("put", playing ? "/play" : "/pause");
+ }
+
+ setRepeat(state: Repeat) {
+ this.req("put", "/repeat", {
+ query: { state }
+ });
+ }
+
+ setShuffle(state: boolean) {
+ this.req("put", "/shuffle", {
+ query: { state }
+ }).then(() => {
+ this.shuffle = state;
+ this.emitChange();
+ });
+ }
+
+ seek(ms: number) {
+ if (this.isSettingPosition) return Promise.resolve();
+
+ this.isSettingPosition = true;
+
+ return this.req("put", "/seek", {
+ query: {
+ position_ms: Math.round(ms)
+ }
+ }).catch((e: any) => {
+ console.error("[VencordSpotifyControls] Failed to seek", e);
+ this.isSettingPosition = false;
+ });
+ }
+
+ private req(method: "post" | "get" | "put", route: string, data: any = {}) {
+ if (this.device?.is_active)
+ (data.query ??= {}).device_id = this.device.id;
+
+ const { socket } = SpotifySocket.getActiveSocketAndDevice();
+ return SpotifyAPI[method](socket.accountId, socket.accessToken, {
+ url: API_BASE + route,
+ ...data
+ });
+ }
+ }
+
+ const store = new SpotifyStore(FluxDispatcher, {
+ SPOTIFY_PLAYER_STATE(e: PlayerState) {
+ store.track = e.track;
+ store.device = e.device ?? null;
+ store.isPlaying = e.isPlaying ?? false;
+ store.volume = e.volumePercent ?? 0;
+ store.repeat = e.actual_repeat || "off";
+ store.position = e.position ?? 0;
+ store.isSettingPosition = false;
+ store.emitChange();
+ },
+ SPOTIFY_SET_DEVICES({ devices }: { devices: Device[]; }) {
+ store.device = devices.find(d => d.is_active) ?? devices[0] ?? null;
+ store.emitChange();
+ }
+ });
+
+ return store;
+});
diff --git a/src/plugins/spotifyControls/index.tsx b/src/plugins/spotifyControls/index.tsx
new file mode 100644
index 0000000..170aa9b
--- /dev/null
+++ b/src/plugins/spotifyControls/index.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 { Devs } from "../../utils/constants";
+import definePlugin from "../../utils/types";
+import { Player } from "./PlayerComponent";
+
+export default definePlugin({
+ name: "SpotifyControls",
+ description: "Spotify Controls",
+ authors: [Devs.Ven],
+ patches: [
+ {
+ find: "showTaglessAccountPanel:",
+ replacement: {
+ // return React.createElement(AccountPanel, { ..., showTaglessAccountPanel: blah })
+ match: /return (.{0,30}\(.{1,3},\{[^}]+?,showTaglessAccountPanel:.+?\}\))/,
+ // return [Player, Panel]
+ replace: "return [Vencord.Plugins.plugins.SpotifyControls.renderPlayer(),$1]"
+ }
+ },
+ // Adds POST and a Marker to the SpotifyAPI (so we can easily find it)
+ {
+ find: ".PLAYER_DEVICES",
+ replacement: {
+ match: /get:(.{1,3})\.bind\(null,(.{1,6})\.get\)/,
+ replace: "SpotifyAPIMarker:1,post:$1.bind(null,$2.post),$&"
+ }
+ },
+ // Discord doesn't give you the repeat kind, only a boolean
+ {
+ find: 'repeat:"off"!==',
+ replacement: {
+ match: /repeat:"off"!==(.{1,3}),/,
+ replace: "actual_repeat:$1,$&"
+ }
+ }
+ ],
+
+ renderPlayer: () => <Player />
+});
diff --git a/src/plugins/spotifyControls/styles.css b/src/plugins/spotifyControls/styles.css
new file mode 100644
index 0000000..b6e1aa2
--- /dev/null
+++ b/src/plugins/spotifyControls/styles.css
@@ -0,0 +1,115 @@
+.vc-spotify-button {
+ height: 100%;
+ background: none;
+ color: var(--interactive-normal);
+}
+
+.vc-spotify-button:hover {
+ filter: brightness(1.3);
+}
+
+.vc-spotify-shuffle-on,
+.vc-spotify-repeat-context,
+.vc-spotify-repeat-track {
+ color: #1db954;
+}
+
+/* Hack to stack icon and bullet */
+.vc-spotify-repeat-track {
+ display: grid;
+ justify-content: center;
+ align-items: center;
+}
+.vc-spotify-repeat-track * {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.vc-spotify-tooltip-text {
+ overflow: hidden;
+ white-space: nowrap;
+ padding-right: 0.2em;
+ max-width: 100%;
+}
+
+.vc-spotify-button-row {
+ justify-content: center;
+}
+
+#vc-spotify-info-wrapper {
+ display: flex;
+ flex-direction: row;
+ height: 3em;
+ flex-direction: row;
+ gap: 0.2em;
+}
+
+#vc-spotify-info-wrapper img {
+ height: 100%;
+ object-fit: contain;
+}
+
+#vc-spotify-album-expanded-wrapper img {
+ width: 100%;
+ object-fit: contain;
+}
+
+#vc-spotify-titles {
+ display: flex;
+ flex-direction: column;
+ padding: 0.2em;
+ justify-content: center;
+ align-items: flex-start;
+ align-content: flex-start;
+ overflow: hidden;
+}
+
+.vc-spotify-tooltip-text {
+ margin: unset;
+}
+
+#vc-spotify-song-title {
+ color: var(--header-primary);
+}
+
+.vc-spotify-artist {
+ text-decoration: none;
+ color: var(--header-secondary);
+}
+
+.vc-spotify-comma {
+ color: var(--header-secondary);
+}
+
+.vc-spotify-artist:hover,
+#vc-spotify-song-title:hover {
+ text-decoration: underline;
+ color: var(--interactive-active);
+ cursor: pointer;
+}
+
+#vc-spotify-album-image:hover {
+ filter: brightness(1.2);
+ cursor: pointer;
+}
+
+#vc-spotify-progress-bar {
+ display: flex;
+ flex-direction: row;
+ color: var(--text-normal);
+
+ width: calc(100% - 1em);
+ margin: 0.5em;
+ margin-bottom: 0;
+}
+
+#vc-spotify-progress-bar > [class^="slider"] {
+ flex-grow: 1;
+}
+
+#vc-spotify-progress-text {
+ margin: 0;
+ font-size: 80%;
+ /* need to fix or it will constantly grow/shrink due to character width differences */
+ width: 20%;
+}
diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx
index e52a57f..55e0c22 100644
--- a/src/utils/misc.tsx
+++ b/src/utils/misc.tsx
@@ -74,9 +74,9 @@ export function useAwaiter<T>(factory: () => Promise<T>, fallbackValue: T | null
* @param factory Function returning a Component
* @returns Result of factory function
*/
-export function LazyComponent<T extends JSX.IntrinsicAttributes = any>(factory: () => React.ComponentType<T>) {
+export function LazyComponent<T = any>(factory: () => React.ComponentType<T>) {
const get = makeLazy(factory);
- return (props: T) => {
+ return (props: T & JSX.IntrinsicAttributes) => {
const Component = get();
return <Component {...props} />;
};
diff --git a/src/webpack/common.tsx b/src/webpack/common.tsx
index 99f94f3..73ead1c 100644
--- a/src/webpack/common.tsx
+++ b/src/webpack/common.tsx
@@ -22,10 +22,13 @@ import type Other from "discord-types/other";
import type Stores from "discord-types/stores";
import { LazyComponent, lazyWebpack } from "../utils/misc";
-import { _resolveReady, filters, findByCode, mapMangledModuleLazy, waitFor } from "./webpack";
+import { proxyLazy } from "../utils/proxyLazy";
+import { _resolveReady, filters, findByCode, mapMangledModule, mapMangledModuleLazy, waitFor } from "./webpack";
+
export const Margins = lazyWebpack(filters.byProps("marginTop20"));
export let FluxDispatcher: Other.FluxDispatcher;
+export const Flux = lazyWebpack(filters.byProps("connectStores"));
export let React: typeof import("react");
export const ReactDOM: typeof import("react-dom") = lazyWebpack(filters.byProps("createPortal", "render"));
@@ -175,3 +178,78 @@ export type TextProps = React.PropsWithChildren & {
};
export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/bold" | "heading-md/normal" | "heading-md/medium" | "heading-md/bold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/bold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/bold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/bold" | "eyebrow" | "heading-deprecated-14/normal" | "heading-deprecated-14/medium" | "heading-deprecated-14/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "display-md" | "display-lg" | "code";
+
+type RC<C> = React.ComponentType<React.PropsWithChildren<C & Record<string, any>>>;
+interface Menu {
+ ContextMenu: RC<{
+ navId: string;
+ onClose(): void;
+ className?: string;
+ style?: React.CSSProperties;
+ hideScroller?: boolean;
+ onSelect?(): void;
+ }>;
+ MenuSeparator: React.ComponentType;
+ MenuGroup: RC<any>;
+ MenuItem: RC<{
+ id: string;
+ label: string;
+ render?: React.ComponentType;
+ onChildrenScroll?: Function;
+ childRowHeight?: number;
+ listClassName?: string;
+ }>;
+ MenuCheckboxItem: RC<{
+ id: string;
+ }>;
+ MenuRadioItem: RC<{
+ id: string;
+ }>;
+ MenuControlItem: RC<{
+ id: string;
+ interactive?: boolean;
+ }>;
+}
+
+/**
+ * Discord's Context menu items.
+ * To use anything but Menu.ContextMenu, your plugin HAS TO
+ * depend on MenuItemDeobfuscatorApi. Otherwise they will throw
+ */
+export const Menu = proxyLazy(() => {
+ const hasDeobfuscator = Vencord.Settings.plugins.MenuItemDeobfuscatorApi.enabled;
+ const menuItems = ["MenuSeparator", "MenuGroup", "MenuItem", "MenuCheckboxItem", "MenuRadioItem", "MenuControlItem"];
+
+ const map = mapMangledModule("♫ ⊂(。◕‿‿◕。⊂) ♪", {
+ ContextMenu: filters.byCode("getContainerProps"),
+ ...Object.fromEntries((hasDeobfuscator ? menuItems : []).map(s => [s, (m: any) => m.name === s]))
+ }) as Menu;
+
+ if (!hasDeobfuscator) {
+ for (const m of menuItems)
+ map[m] = () => {
+ throw new Error(`Your plugin needs to depend on MenuItemDeobfuscatorApi to use ${m}`);
+ };
+ }
+
+ return map;
+});
+
+export const ContextMenu = mapMangledModuleLazy('type:"CONTEXT_MENU_OPEN"', {
+ open: filters.byCode("stopPropagation"),
+ openLazy: m => m.toString().length < 50,
+ close: filters.byCode("CONTEXT_MENU_CLOSE")
+}) as {
+ close(): void;
+ open(
+ event: React.UIEvent,
+ render?: Menu["ContextMenu"],
+ options?: { enableSpellCheck?: boolean; },
+ renderLazy?: () => Promise<Menu["ContextMenu"]>
+ ): void;
+ openLazy(
+ event: React.UIEvent,
+ renderLazy?: () => Promise<Menu["ContextMenu"]>,
+ options?: { enableSpellCheck?: boolean; }
+ ): void;
+};