diff options
author | Ven <vendicated@riseup.net> | 2022-11-07 22:28:29 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-11-07 22:28:29 +0100 |
commit | 6a8564089bea162d9c4d52925eb1239b6b270fa4 (patch) | |
tree | b411e223ffab2752175e2e883908cd814f73455d | |
parent | 7d5ade21fc9b56d21e2eb9e5b0d35502432adaa2 (diff) | |
download | Vencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.tar.gz Vencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.tar.bz2 Vencord-6a8564089bea162d9c4d52925eb1239b6b270fa4.zip |
SpotifyControls plugin (#190)
-rw-r--r-- | src/components/ErrorBoundary.tsx | 128 | ||||
-rw-r--r-- | src/components/Flex.tsx | 6 | ||||
-rw-r--r-- | src/plugins/reverseImageSearch.tsx | 5 | ||||
-rw-r--r-- | src/plugins/spotifyControls/PlayerComponent.tsx | 329 | ||||
-rw-r--r-- | src/plugins/spotifyControls/SpotifyStore.ts | 203 | ||||
-rw-r--r-- | src/plugins/spotifyControls/index.tsx | 56 | ||||
-rw-r--r-- | src/plugins/spotifyControls/styles.css | 115 | ||||
-rw-r--r-- | src/utils/misc.tsx | 4 | ||||
-rw-r--r-- | src/webpack/common.tsx | 80 |
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; +}; |