SpotifyControls plugin (#190)
This commit is contained in:
parent
7d5ade21fc
commit
6a8564089b
@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { LazyComponent } from "../utils";
|
||||||
import Logger from "../utils/logger";
|
import Logger from "../utils/logger";
|
||||||
import { Margins, React } from "../webpack/common";
|
import { Margins, React } from "../webpack/common";
|
||||||
import { ErrorCard } from "./ErrorCard";
|
import { ErrorCard } from "./ErrorCard";
|
||||||
@ -32,15 +33,10 @@ const logger = new Logger("React ErrorBoundary", color);
|
|||||||
|
|
||||||
const NO_ERROR = {};
|
const NO_ERROR = {};
|
||||||
|
|
||||||
export default class ErrorBoundary extends React.Component<React.PropsWithChildren<Props>> {
|
// We might want to import this in a place where React isn't ready yet.
|
||||||
static wrap<T = any>(Component: React.ComponentType<T>): (props: T) => React.ReactElement {
|
// Thus, wrap in a LazyComponent
|
||||||
return props => (
|
const ErrorBoundary = LazyComponent(() => {
|
||||||
<ErrorBoundary>
|
return class ErrorBoundary extends React.PureComponent<React.PropsWithChildren<Props>> {
|
||||||
<Component {...props as any/* I hate react typings ??? */} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
error: NO_ERROR as any,
|
error: NO_ERROR as any,
|
||||||
stack: "",
|
stack: "",
|
||||||
@ -96,4 +92,16 @@ export default class ErrorBoundary extends React.Component<React.PropsWithChildr
|
|||||||
</ErrorCard>
|
</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;
|
||||||
|
@ -24,9 +24,11 @@ export function Flex(props: React.PropsWithChildren<{
|
|||||||
className?: string;
|
className?: string;
|
||||||
} & React.HTMLProps<HTMLDivElement>>) {
|
} & React.HTMLProps<HTMLDivElement>>) {
|
||||||
props.style ??= {};
|
props.style ??= {};
|
||||||
props.style.flexDirection ||= props.flexDirection;
|
|
||||||
props.style.gap ??= "1em";
|
|
||||||
props.style.display = "flex";
|
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 (
|
return (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@ -17,9 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Devs } from "../utils/constants";
|
import { Devs } from "../utils/constants";
|
||||||
import { lazyWebpack } from "../utils/misc";
|
|
||||||
import definePlugin from "../utils/types";
|
import definePlugin from "../utils/types";
|
||||||
import { filters } from "../webpack";
|
import { Menu } from "../webpack/common";
|
||||||
|
|
||||||
const Engines = {
|
const Engines = {
|
||||||
Google: "https://www.google.com/searchbyimage?image_url=",
|
Google: "https://www.google.com/searchbyimage?image_url=",
|
||||||
@ -29,8 +28,6 @@ const Engines = {
|
|||||||
TinEye: "https://www.tineye.com/search?url="
|
TinEye: "https://www.tineye.com/search?url="
|
||||||
};
|
};
|
||||||
|
|
||||||
const Menu = lazyWebpack(filters.byProps("MenuItem"));
|
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
name: "ReverseImageSearch",
|
name: "ReverseImageSearch",
|
||||||
description: "yes",
|
description: "yes",
|
||||||
|
329
src/plugins/spotifyControls/PlayerComponent.tsx
Normal file
329
src/plugins/spotifyControls/PlayerComponent.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
203
src/plugins/spotifyControls/SpotifyStore.ts
Normal file
203
src/plugins/spotifyControls/SpotifyStore.ts
Normal file
@ -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;
|
||||||
|
});
|
56
src/plugins/spotifyControls/index.tsx
Normal file
56
src/plugins/spotifyControls/index.tsx
Normal file
@ -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 />
|
||||||
|
});
|
115
src/plugins/spotifyControls/styles.css
Normal file
115
src/plugins/spotifyControls/styles.css
Normal file
@ -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%;
|
||||||
|
}
|
@ -74,9 +74,9 @@ export function useAwaiter<T>(factory: () => Promise<T>, fallbackValue: T | null
|
|||||||
* @param factory Function returning a Component
|
* @param factory Function returning a Component
|
||||||
* @returns Result of factory function
|
* @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);
|
const get = makeLazy(factory);
|
||||||
return (props: T) => {
|
return (props: T & JSX.IntrinsicAttributes) => {
|
||||||
const Component = get();
|
const Component = get();
|
||||||
return <Component {...props} />;
|
return <Component {...props} />;
|
||||||
};
|
};
|
||||||
|
@ -22,10 +22,13 @@ import type Other from "discord-types/other";
|
|||||||
import type Stores from "discord-types/stores";
|
import type Stores from "discord-types/stores";
|
||||||
|
|
||||||
import { LazyComponent, lazyWebpack } from "../utils/misc";
|
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 const Margins = lazyWebpack(filters.byProps("marginTop20"));
|
||||||
|
|
||||||
export let FluxDispatcher: Other.FluxDispatcher;
|
export let FluxDispatcher: Other.FluxDispatcher;
|
||||||
|
export const Flux = lazyWebpack(filters.byProps("connectStores"));
|
||||||
export let React: typeof import("react");
|
export let React: typeof import("react");
|
||||||
export const ReactDOM: typeof import("react-dom") = lazyWebpack(filters.byProps("createPortal", "render"));
|
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";
|
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;
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user