diff --git a/plugins/SpotifyModal/src/components/Buttons.tsx b/plugins/SpotifyModal/.src/components/Buttons.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Buttons.tsx rename to plugins/SpotifyModal/.src/components/Buttons.tsx diff --git a/plugins/SpotifyModal/src/components/ControlContextMenu.tsx b/plugins/SpotifyModal/.src/components/ControlContextMenu.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/ControlContextMenu.tsx rename to plugins/SpotifyModal/.src/components/ControlContextMenu.tsx diff --git a/plugins/SpotifyModal/src/components/Controls.tsx b/plugins/SpotifyModal/.src/components/Controls.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Controls.tsx rename to plugins/SpotifyModal/.src/components/Controls.tsx diff --git a/plugins/SpotifyModal/src/components/Icons.tsx b/plugins/SpotifyModal/.src/components/Icons.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Icons.tsx rename to plugins/SpotifyModal/.src/components/Icons.tsx diff --git a/plugins/SpotifyModal/src/components/Modal.tsx b/plugins/SpotifyModal/.src/components/Modal.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Modal.tsx rename to plugins/SpotifyModal/.src/components/Modal.tsx diff --git a/plugins/SpotifyModal/src/components/Popouts/AuthLinkGenerator.tsx b/plugins/SpotifyModal/.src/components/Popouts/AuthLinkGenerator.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Popouts/AuthLinkGenerator.tsx rename to plugins/SpotifyModal/.src/components/Popouts/AuthLinkGenerator.tsx diff --git a/plugins/SpotifyModal/src/components/Popouts/index.ts b/plugins/SpotifyModal/.src/components/Popouts/index.ts similarity index 100% rename from plugins/SpotifyModal/src/components/Popouts/index.ts rename to plugins/SpotifyModal/.src/components/Popouts/index.ts diff --git a/plugins/SpotifyModal/src/components/Seekbar.tsx b/plugins/SpotifyModal/.src/components/Seekbar.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Seekbar.tsx rename to plugins/SpotifyModal/.src/components/Seekbar.tsx diff --git a/plugins/SpotifyModal/src/components/Settings.tsx b/plugins/SpotifyModal/.src/components/Settings.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/Settings.tsx rename to plugins/SpotifyModal/.src/components/Settings.tsx diff --git a/plugins/SpotifyModal/src/components/TrackDetails.tsx b/plugins/SpotifyModal/.src/components/TrackDetails.tsx similarity index 100% rename from plugins/SpotifyModal/src/components/TrackDetails.tsx rename to plugins/SpotifyModal/.src/components/TrackDetails.tsx diff --git a/plugins/SpotifyModal/src/components/index.ts b/plugins/SpotifyModal/.src/components/index.ts similarity index 100% rename from plugins/SpotifyModal/src/components/index.ts rename to plugins/SpotifyModal/.src/components/index.ts diff --git a/plugins/SpotifyModal/.src/config.ts b/plugins/SpotifyModal/.src/config.ts new file mode 100644 index 0000000..5847fee --- /dev/null +++ b/plugins/SpotifyModal/.src/config.ts @@ -0,0 +1,36 @@ +import { settings } from 'replugged'; + +export type ControlButtonKind = + | 'shuffle' + | 'skip-prev' + | 'play-pause' + | 'skip-next' + | 'repeat' + | 'blank'; + +export type VisibilityState = 'always' | 'hidden' | 'auto'; + +export const defaultConfig = { + controlsLayout: ['shuffle', 'skip-prev', 'play-pause', 'skip-next', 'repeat'] as [ + ControlButtonKind, + ControlButtonKind, + ControlButtonKind, + ControlButtonKind, + ControlButtonKind, + ], + controlsVisibilityState: 'auto' as VisibilityState, + debugging: false, + hyperlinkURI: true, + pluginStopBehavior: 'ask' as 'ask' | 'restart' | 'ignore', + seekbarEnabled: true, + seekbarVisibilityState: 'always' as VisibilityState, + spotifyAppClientId: '', + spotifyAppRedirectURI: '', + spotifyAppOauthTokens: {} as Record, + skipPreviousShouldResetProgress: true, + skipPreviousProgressResetThreshold: 0.15, +}; + +export type DefaultConfig = typeof defaultConfig; + +export const config = await settings.init('lib.evelyn.SpotifyModal', defaultConfig); diff --git a/plugins/SpotifyModal/.src/index.tsx b/plugins/SpotifyModal/.src/index.tsx new file mode 100644 index 0000000..c020a9b --- /dev/null +++ b/plugins/SpotifyModal/.src/index.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { fluxDispatcher } from 'replugged/common'; +import { ErrorBoundary } from 'replugged/components'; + +import { mergeClassNames } from '@shared/dom'; +import { hackyCSSFix, restartDiscordDialog } from '@shared/misc'; + +import { config } from './config'; +import { ErrorPlaceholder, Modal } from './components'; +import { containerClasses, globalEvents, initMisc, initSpotify, logger } from './util'; +import { SpotifyStore } from './types'; + +import './style/index.css'; + +let styleElement: HTMLLinkElement; + +export const renderModal = (): React.ReactElement => ( +
+ +
+ +
+
+ } + onError={(error: Error, message: React.ErrorInfo) => + logger._.error('(modal)', `rendering failed\n`, error, '\n', message) + }> + + + +); + +export const emitEvent = ( + data: SpotifyStore.PayloadEvents, + account: SpotifyStore.Account, +): void => { + if (data.type === 'PLAYER_STATE_CHANGED' && typeof data.event.state?.timestamp === 'number') + data.event.state.timestamp = Date.now(); + + globalEvents.emit('event', { accountId: account.accountId, data }); +}; + +const postConnectionOpenListener = (): void => { + fluxDispatcher.unsubscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); + + logger.log('(start)', 'waited for POST_CONNECTION_OPEN'); + globalEvents.emit('ready'); + + // hacky fix for loading css after timing out + if (!document.querySelector('link[href*="lib.evelyn.SpotifyModal"]')) { + logger._.log( + '(start)', + 'manually loading CSS since Replugged timed us out (we still loaded successfully!)', + ); + + styleElement = hackyCSSFix('lib.evelyn.SpotifyModal')!; + } +}; + +// to detect account switches - we need to reset the modal +const loginSuccessListener = (): void => { + globalEvents.emit('accountSwitch'); + fluxDispatcher.subscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); +}; + +export const start = async (): Promise => { + await Promise.allSettled([initMisc(), initSpotify()]); + + if (!document.getElementById('spotify-modal-root')) + fluxDispatcher.subscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); + + fluxDispatcher.subscribe('LOGIN_SUCCESS', loginSuccessListener); +}; + +export const stop = async (): Promise => { + await restartDiscordDialog('SpotifyModal', config.get('pluginStopBehavior')); + + fluxDispatcher.unsubscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); + fluxDispatcher.unsubscribe('LOGIN_SUCCESS', loginSuccessListener); + + styleElement?.remove?.(); +}; + +export { Settings } from './components'; + +export * as util from './util'; +export * as components from './components'; diff --git a/plugins/SpotifyModal/src/patches.ts b/plugins/SpotifyModal/.src/patches.ts similarity index 100% rename from plugins/SpotifyModal/src/patches.ts rename to plugins/SpotifyModal/.src/patches.ts diff --git a/plugins/SpotifyModal/src/style/icon.css b/plugins/SpotifyModal/.src/style/icon.css similarity index 100% rename from plugins/SpotifyModal/src/style/icon.css rename to plugins/SpotifyModal/.src/style/icon.css diff --git a/plugins/SpotifyModal/src/style/index.css b/plugins/SpotifyModal/.src/style/index.css similarity index 100% rename from plugins/SpotifyModal/src/style/index.css rename to plugins/SpotifyModal/.src/style/index.css diff --git a/plugins/SpotifyModal/src/style/misc.css b/plugins/SpotifyModal/.src/style/misc.css similarity index 100% rename from plugins/SpotifyModal/src/style/misc.css rename to plugins/SpotifyModal/.src/style/misc.css diff --git a/plugins/SpotifyModal/src/style/modal/controls.css b/plugins/SpotifyModal/.src/style/modal/controls.css similarity index 100% rename from plugins/SpotifyModal/src/style/modal/controls.css rename to plugins/SpotifyModal/.src/style/modal/controls.css diff --git a/plugins/SpotifyModal/src/style/modal/index.css b/plugins/SpotifyModal/.src/style/modal/index.css similarity index 100% rename from plugins/SpotifyModal/src/style/modal/index.css rename to plugins/SpotifyModal/.src/style/modal/index.css diff --git a/plugins/SpotifyModal/src/style/modal/main-view.css b/plugins/SpotifyModal/.src/style/modal/main-view.css similarity index 100% rename from plugins/SpotifyModal/src/style/modal/main-view.css rename to plugins/SpotifyModal/.src/style/modal/main-view.css diff --git a/plugins/SpotifyModal/src/style/modal/seekbar.css b/plugins/SpotifyModal/.src/style/modal/seekbar.css similarity index 100% rename from plugins/SpotifyModal/src/style/modal/seekbar.css rename to plugins/SpotifyModal/.src/style/modal/seekbar.css diff --git a/plugins/SpotifyModal/src/style/modal/track-details.css b/plugins/SpotifyModal/.src/style/modal/track-details.css similarity index 100% rename from plugins/SpotifyModal/src/style/modal/track-details.css rename to plugins/SpotifyModal/.src/style/modal/track-details.css diff --git a/plugins/SpotifyModal/src/style/scrolling-anim.css b/plugins/SpotifyModal/.src/style/scrolling-anim.css similarity index 100% rename from plugins/SpotifyModal/src/style/scrolling-anim.css rename to plugins/SpotifyModal/.src/style/scrolling-anim.css diff --git a/plugins/SpotifyModal/src/types/events.ts b/plugins/SpotifyModal/.src/types/events.ts similarity index 100% rename from plugins/SpotifyModal/src/types/events.ts rename to plugins/SpotifyModal/.src/types/events.ts diff --git a/plugins/SpotifyModal/src/types/index.ts b/plugins/SpotifyModal/.src/types/index.ts similarity index 100% rename from plugins/SpotifyModal/src/types/index.ts rename to plugins/SpotifyModal/.src/types/index.ts diff --git a/plugins/SpotifyModal/src/types/stores.ts b/plugins/SpotifyModal/.src/types/stores.ts similarity index 100% rename from plugins/SpotifyModal/src/types/stores.ts rename to plugins/SpotifyModal/.src/types/stores.ts diff --git a/plugins/SpotifyModal/src/util/events.ts b/plugins/SpotifyModal/.src/util/events.ts similarity index 100% rename from plugins/SpotifyModal/src/util/events.ts rename to plugins/SpotifyModal/.src/util/events.ts diff --git a/plugins/SpotifyModal/src/util/index.ts b/plugins/SpotifyModal/.src/util/index.ts similarity index 100% rename from plugins/SpotifyModal/src/util/index.ts rename to plugins/SpotifyModal/.src/util/index.ts diff --git a/plugins/SpotifyModal/src/util/misc.ts b/plugins/SpotifyModal/.src/util/misc.ts similarity index 100% rename from plugins/SpotifyModal/src/util/misc.ts rename to plugins/SpotifyModal/.src/util/misc.ts diff --git a/plugins/SpotifyModal/src/util/spotify.ts b/plugins/SpotifyModal/.src/util/spotify.ts similarity index 100% rename from plugins/SpotifyModal/src/util/spotify.ts rename to plugins/SpotifyModal/.src/util/spotify.ts diff --git a/plugins/SpotifyModal/manifest.json b/plugins/SpotifyModal/manifest.json index deba8f6..9974ad3 100644 --- a/plugins/SpotifyModal/manifest.json +++ b/plugins/SpotifyModal/manifest.json @@ -16,8 +16,6 @@ "license": "MIT", "type": "replugged-plugin", "renderer": "src/index.tsx", - "reloadRequired": true, - "plaintextPatches": "src/patches.ts", "source": "https://github.com/Socketlike/replugged-plugins/blob/main/plugins/SpotifyModal", "image": "https://media.discordapp.net/attachments/907813363294277652/1140263816823844864/DiscordCanary_qrl9e1908P.gif" } diff --git a/plugins/SpotifyModal/src/Modal.tsx b/plugins/SpotifyModal/src/Modal.tsx new file mode 100644 index 0000000..7278b70 --- /dev/null +++ b/plugins/SpotifyModal/src/Modal.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import { Logger } from 'replugged'; +import { fluxHooks, toast } from 'replugged/common'; +import { ErrorBoundary, SliderItem, Tooltip } from 'replugged/components'; + +import { ConnectedAccount, ConnectedAccountsUtils, SpotifyStore } from './types'; +import { useConfig } from './config'; +import * as utils from './utils'; + +const log = Logger.plugin('SpotifyModal', '#1DB954'); + +function formatTimestamp(timestamp: number): string { + let seconds = Math.floor(timestamp / 1000); + const hours = Math.floor(seconds / 3600); + seconds -= hours * 3600; + const minutes = Math.floor(seconds / 60); + seconds -= minutes * 60; + + return `${hours ? `${hours}:` : ''}${String(minutes).padStart(hours ? 2 : 1, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function handleOverflow(element: HTMLElement, parentLevel = 1): void { + if (!element) return; + + let parent = element; + for (let i = 0; i < parentLevel; i++) parent = parent?.parentElement; + + if (!parent || parent === element) return; + + if (element.scrollWidth > parent.clientWidth) { + // 60px/s + element.style.animationDuration = `${(element.scrollWidth / 45) * 1.1}s`; + element.style.animationDelay = `-${(element.scrollWidth / 45) * 1.1 * 0.449}s`; + element.classList.add('overflow'); + } else element.classList.remove('overflow'); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const useInterval = (callback: (...args: any[]) => void, delay: number): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const savedCallback = React.useRef<(...args: any[]) => void>(); + + React.useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + React.useEffect(() => { + if (delay !== null) { + let id = setInterval(() => savedCallback.current(), delay); + return () => clearInterval(id); + } + }, [delay]); +}; + +export const ModalFallback = (): React.ReactElement => ( + <>uh oh. something went wrong while rendering the modal. +); + +export const TrackDetails = (props: { + state: ReturnType; +}): React.ReactElement => { + const { state } = props; + + const trackNameElement = React.useRef(); + const artistsElement = React.useRef(); + + React.useEffect(() => { + handleOverflow(trackNameElement.current, 2); + handleOverflow(artistsElement.current, 2); + }, [state]); + + return ( +
+ + + + + +
+ + +
+ props.name) + .join(', ')}> +
{ + handleOverflow(e, 2); + artistsElement.current = e; + }} + className='artists'> + {Object.values(state.track.artists || []).map((props, i, arr) => ( + <> + {props.name} + {arr.length - 1 !== i && ', '} + + ))} +
+
+
+
+
+ ); +}; + +export const Seekbar = (props: { + account: ConnectedAccount; + connectedAccountsUtils: ConnectedAccountsUtils; + start: number; + end: number; + paused: boolean; + active: boolean; +}): React.ReactNode => { + const { account, connectedAccountsUtils, start, end, paused, active } = props; + + const enabled = useConfig('seekbar.enabled', true); + const collapseOnBlur = useConfig('seekbar.collapseOnBlur', false); + + const [current, setCurrent] = React.useState(0); + const ref = React.useRef<{ setState(props: { value: number }): void }>(); + + const isSeeking = React.useRef(false); + + useInterval(() => { + if (!active || paused || isSeeking.current) return; + + setCurrent(Math.min(Date.now() - start, end)); + }, 500); + + // discord's sliders are no longer dumb, which means they won't react to prop changes + // after first render so we need to set its state manually + React.useEffect(() => { + ref.current?.setState?.({ value: current }); + }, [current]); + + return ( +
+
+ {formatTimestamp(current)} + {formatTimestamp(end)} +
+ { + if (!isSeeking.current) isSeeking.current = true; + + setCurrent(v); + }} + onChange={(v) => { + void utils.spotify.seekTo(account?.accessToken, v).then(async (res) => { + if (res === 0) + toast.toast( + '[SpotifyModal] Internal plugin error. Please check console.', + toast.Kind.FAILURE, + ); + else if (res === 401) { + const newToken = await connectedAccountsUtils + .refreshAccountToken('spotify', account.id) + .catch(() => {}); + + if (!newToken) + toast.toast( + '[SpotifyModal] Authentication error: Could not refresh expired token. Please perform this action in your Spotify player.', + toast.Kind.FAILURE, + ); + else { + res = await utils.spotify.seekTo(newToken, v); + + if (res === 401) + toast.toast( + '[SpotifyModal] Authentication error: Could not refresh expired token. Please perform this action in your Spotify player.', + toast.Kind.FAILURE, + ); + else if (res !== 200 && res !== 204) { + log.error("couldn't resume action after refreshing token; code", res); + + toast.toast( + '[SpotifyModal] Resuming action after reauthentication errored. Please check console.', + toast.Kind.FAILURE, + ); + } + } + } else if (res === 404) + toast.toast( + '[SpotifyModal] Player is idle and cannot be accessed. Please perform this action in your Spotify player.', + toast.Kind.FAILURE, + ); + else if (res === 403) + toast.toast( + '[SpotifyModal] Bad OAuth request. The Spotify account was probably unlinked from your Discord account.', + toast.Kind.FAILURE, + ); + else if (res === 429) + toast.toast( + '[SpotifyModal] Too many requests. Please slow down.', + toast.Kind.FAILURE, + ); + + isSeeking.current = false; + }); + }} + /> +
+ ); +}; + +export const Modal = (props: { + store: SpotifyStore; + connectedAccountsUtils: ConnectedAccountsUtils; +}): React.ReactElement => { + const { connectedAccountsUtils, store } = props; + + const [state, setState] = React.useState>(); + const [activity, setActivity] = React.useState>(); + const [active, setActive] = React.useState(false); + const [paused, setPaused] = React.useState(true); + + const _socket = fluxHooks.useStateFromStores([store], () => { + const socket = store.getActiveSocketAndDevice()?.socket; + const _state = store.getPlayerState(socket?.accountId); + const _active = Boolean(socket); + const _activity = store.getActivity(); + + if (active !== _active) { + log.log('active state update', _active); + setActive(_active); + } + + if ((!socket || _active) && state !== _state) { + if (_state) { + log.log('player state update', _state); + setState(_state); + } + + if (_activity) { + log.log('activity update', _activity); + setActivity(_activity); + } + + if (paused !== !_state) setPaused(!_state); + } + + return socket; + }); + + return active && state ? ( + <> + + + + ) : ( + <> + ); +}; + +export default (props: { + store: SpotifyStore; + connectedAccountsUtils: ConnectedAccountsUtils; +}): React.ReactElement => { + const [error, setError] = React.useState(); + const [info, setInfo] = React.useState(); + + const reduceMotion = useConfig('general.reduceMotion', 'discord'); + + return ( +
+ { + setError(e); + setInfo(i); + }} + fallback={() => }> + + +
+ ); +}; diff --git a/plugins/SpotifyModal/src/Settings.tsx b/plugins/SpotifyModal/src/Settings.tsx new file mode 100644 index 0000000..0255ae3 --- /dev/null +++ b/plugins/SpotifyModal/src/Settings.tsx @@ -0,0 +1,73 @@ +import { Category, SelectItem, SwitchItem, Text, FormItem } from 'replugged/components'; + +import { useSetting } from 'replugged/util'; + +import config from './config'; + +export default (): React.ReactElement => { + const version = useSetting(config, 'v'); + + const general = { + reduceMotion: useSetting(config, 'general.reduceMotion'), + }; + + const controls = { + enabled: useSetting(config, 'controls.enabled'), + collapseOnBlur: useSetting(config, 'controls.collapseOnBlur'), + }; + + const seekbar = { + enabled: useSetting(config, 'seekbar.enabled'), + collapseOnBlur: useSetting(config, 'seekbar.collapseOnBlur'), + }; + + return ( +
+ + { + let val: string | boolean = value; + + if (val !== 'discord') val = val !== 'false'; + + general.reduceMotion.onChange(val); + }}> + Reduce motion + + + + + Enable controls + + Collapse on blur + + + + + Enable seekbar + + Collapse on blur + + + + + settings v{version.value} + +
+ ); +}; diff --git a/plugins/SpotifyModal/src/config.ts b/plugins/SpotifyModal/src/config.ts index 5847fee..308ffd0 100644 --- a/plugins/SpotifyModal/src/config.ts +++ b/plugins/SpotifyModal/src/config.ts @@ -1,36 +1,100 @@ -import { settings } from 'replugged'; - -export type ControlButtonKind = - | 'shuffle' - | 'skip-prev' - | 'play-pause' - | 'skip-next' - | 'repeat' - | 'blank'; - -export type VisibilityState = 'always' | 'hidden' | 'auto'; - -export const defaultConfig = { - controlsLayout: ['shuffle', 'skip-prev', 'play-pause', 'skip-next', 'repeat'] as [ - ControlButtonKind, - ControlButtonKind, - ControlButtonKind, - ControlButtonKind, - ControlButtonKind, - ], - controlsVisibilityState: 'auto' as VisibilityState, - debugging: false, - hyperlinkURI: true, - pluginStopBehavior: 'ask' as 'ask' | 'restart' | 'ignore', - seekbarEnabled: true, - seekbarVisibilityState: 'always' as VisibilityState, - spotifyAppClientId: '', - spotifyAppRedirectURI: '', - spotifyAppOauthTokens: {} as Record, - skipPreviousShouldResetProgress: true, - skipPreviousProgressResetThreshold: 0.15, +import { React } from 'replugged/common'; + +import { init } from 'replugged/settings'; + +// can't use an interface for this +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type Config = { + v: number; + + 'general.reduceMotion': 'discord' | boolean; + + 'controls.enabled': boolean; + 'controls.collapseOnBlur': boolean; + + 'seekbar.enabled': boolean; + 'seekbar.collapseOnBlur': boolean; +}; + +const config = await init('lib.evelyn.SpotifyModal', { + v: 0, + + 'controls.enabled': true, + 'controls.collapseOnBlur': false, + + 'seekbar.enabled': true, + 'seekbar.collapseOnBlur': false, +}); + +const currentConfigVersion = config.get('v'); + +if (currentConfigVersion === 0) { + const controlsVisibilityState = + // @ts-expect-error - can't fix this + config.get('controlsVisibilityState') as 'always' | 'hidden' | 'auto'; + + // @ts-expect-error - can't fix this + const seekbarEnabled: boolean = config.get('seekbarEnabled'); + const seekbarVisibilityState = + // @ts-expect-error - can't fix this + config.get('controlsVisibilityState') as 'always' | 'hidden' | 'auto'; + + config.set('controls.enabled', controlsVisibilityState !== 'hidden'); + config.set('controls.collapseOnBlur', controlsVisibilityState === 'auto'); + + config.set('seekbar.enabled', seekbarEnabled); + config.set('seekbar.collapseOnBlur', seekbarVisibilityState === 'auto'); + + config.set('v', 1); +} + +const events = new EventTarget(); + +const origSet = config.set; +const origDelete = config.delete; + +config.set = function (key: T, value: D) { + origSet.call(this, key, value); + + events.dispatchEvent(new CustomEvent('set', { detail: { key } })); +}; + +config.delete = function (key: T): boolean { + const res = origDelete.call(this, key); + + if (res) events.dispatchEvent(new CustomEvent('delete', { detail: { key } })); + + return res; }; -export type DefaultConfig = typeof defaultConfig; +/* + this uses the 2 monkey patches above to work + + due to this, config modifications that does not use this settings instance specifically + will not update any React UI that is using this hook. +*/ +export const useConfig = (key: T, fallback?: D): D => { + const [state, setState] = React.useState(config.get(key, fallback)); + + React.useEffect(() => { + const listener = (e: Event): void => { + const { + detail: { key: k }, + } = e as CustomEvent<{ key: T }>; + + if (k === key) setState(config.get(k)); + }; + + events.addEventListener('set', listener); + events.addEventListener('delete', listener); + + return () => { + events.removeEventListener('set', listener); + events.removeEventListener('delete', listener); + }; + }, []); + + return state as D; +}; -export const config = await settings.init('lib.evelyn.SpotifyModal', defaultConfig); +export default config; diff --git a/plugins/SpotifyModal/src/index.tsx b/plugins/SpotifyModal/src/index.tsx index c020a9b..3460f94 100644 --- a/plugins/SpotifyModal/src/index.tsx +++ b/plugins/SpotifyModal/src/index.tsx @@ -1,95 +1,56 @@ -import React from 'react'; - -import { fluxDispatcher } from 'replugged/common'; -import { ErrorBoundary } from 'replugged/components'; - -import { mergeClassNames } from '@shared/dom'; -import { hackyCSSFix, restartDiscordDialog } from '@shared/misc'; - -import { config } from './config'; -import { ErrorPlaceholder, Modal } from './components'; -import { containerClasses, globalEvents, initMisc, initSpotify, logger } from './util'; -import { SpotifyStore } from './types'; - -import './style/index.css'; - -let styleElement: HTMLLinkElement; - -export const renderModal = (): React.ReactElement => ( -
- -
- -
-
- } - onError={(error: Error, message: React.ErrorInfo) => - logger._.error('(modal)', `rendering failed\n`, error, '\n', message) - }> - - - -); - -export const emitEvent = ( - data: SpotifyStore.PayloadEvents, - account: SpotifyStore.Account, -): void => { - if (data.type === 'PLAYER_STATE_CHANGED' && typeof data.event.state?.timestamp === 'number') - data.event.state.timestamp = Date.now(); - - globalEvents.emit('event', { accountId: account.accountId, data }); -}; - -const postConnectionOpenListener = (): void => { - fluxDispatcher.unsubscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); - - logger.log('(start)', 'waited for POST_CONNECTION_OPEN'); - globalEvents.emit('ready'); - - // hacky fix for loading css after timing out - if (!document.querySelector('link[href*="lib.evelyn.SpotifyModal"]')) { - logger._.log( - '(start)', - 'manually loading CSS since Replugged timed us out (we still loaded successfully!)', - ); - - styleElement = hackyCSSFix('lib.evelyn.SpotifyModal')!; - } -}; - -// to detect account switches - we need to reset the modal -const loginSuccessListener = (): void => { - globalEvents.emit('accountSwitch'); - fluxDispatcher.subscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); -}; - -export const start = async (): Promise => { - await Promise.allSettled([initMisc(), initSpotify()]); - - if (!document.getElementById('spotify-modal-root')) - fluxDispatcher.subscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); - - fluxDispatcher.subscribe('LOGIN_SUCCESS', loginSuccessListener); -}; - -export const stop = async (): Promise => { - await restartDiscordDialog('SpotifyModal', config.get('pluginStopBehavior')); - - fluxDispatcher.unsubscribe('POST_CONNECTION_OPEN', postConnectionOpenListener); - fluxDispatcher.unsubscribe('LOGIN_SUCCESS', loginSuccessListener); - - styleElement?.remove?.(); -}; - -export { Settings } from './components'; - -export * as util from './util'; -export * as components from './components'; +import { Injector, Logger } from 'replugged'; +import { getOwnerInstance, waitFor } from 'replugged/util'; +import webpack from 'replugged/webpack'; + +import { ConnectedAccountsUtils, SpotifyStore } from './types'; +import { default as Main } from './Modal'; + +import './style.css'; + +const log = Logger.plugin('SpotifyModal', '#1DB954'); +const injector = new Injector(); + +let store = webpack.getByStoreName('SpotifyStore'); +let connectedAccountsUtils: ConnectedAccountsUtils = webpack.getByProps('refreshAccessToken'); +let userAreaElement: Element; +let forceUpdateUserArea: () => void; + +let modalInstance: React.ReactElement; + +export function start(): void { + void (async () => { + store ??= webpack.getByStoreName('SpotifyStore'); + connectedAccountsUtils ??= await webpack.waitForProps(['refreshAccountToken']); + + userAreaElement = await waitFor('[class^=panels_] > [class^=container_]'); + + if (!userAreaElement) { + log.error('unable to get user area element. maybe the selector broke?'); + return; + } + + const owner = getOwnerInstance(userAreaElement); + + if (!owner) { + log.error('unable to get user area React owner instance.'); + return; + } + + modalInstance =
; + + injector.after(owner, 'render', (_, res) => { + return [modalInstance, res]; + }); + + forceUpdateUserArea = () => owner.forceUpdate(); + + forceUpdateUserArea(); + })(); +} + +export function stop(): void { + injector.uninjectAll(); + forceUpdateUserArea?.(); +} + +export { default as Settings } from './Settings'; diff --git a/plugins/SpotifyModal/src/style.css b/plugins/SpotifyModal/src/style.css new file mode 100644 index 0000000..877196a --- /dev/null +++ b/plugins/SpotifyModal/src/style.css @@ -0,0 +1,161 @@ +@keyframes spotify-modal-marquee { + 0% { + transform: translateX(100%); + } + + 45% { + transform: translateX(0); + } + + 55% { + transform: translateX(0); + } + + 100% { + transform: translateX(-100%); + } +} + +#spotify-modal { + display: flex; + + flex-direction: column; + gap: 10px; + + font-size: 14px; + + color: var(--text-normal); + + margin: 8px; + border-bottom: 1px solid var(--background-modifier-accent); +} + +#spotify-modal:empty { + border-bottom: none; + + margin: 0; +} + +#spotify-modal .details { + display: flex; + + flex-direction: row; + gap: 8px; +} + +#spotify-modal .details .container { + display: flex; + + flex-direction: column; + flex-grow: 1; + gap: 4px; + + min-width: 0; + + justify-content: center; +} + +#spotify-modal .details .track-name, +#spotify-modal .details .artists { + text-wrap: nowrap; + + width: min-content; +} + +#spotify-modal .details .track-name-container, +#spotify-modal .details .artists-container { + overflow: hidden; +} + +#spotify-modal .details .track-name.overflow, +#spotify-modal .details .artists.overflow { + animation: 0s linear infinite spotify-modal-marquee; +} + +html.reduce-motion #spotify-modal.use-discord-reduce-motion .details .track-name, +html.reduce-motion #spotify-modal.use-discord-reduce-motion .details .artists, +#spotify-modal.reduce-motion .details .track-name, +#spotify-modal.reduce-motion .details .artists { + animation: none; + + text-overflow: ellipsis; + + overflow: hidden; + width: unset; +} + +#spotify-modal .details .track-name { + display: inline-block; + + font-weight: 500; + + color: var(--text-normal); +} + +#spotify-modal .details .artists a { + color: var(--text-secondary); +} + +#spotify-modal .details .track-name:hover, +#spotify-modal .details .artists:hover { + animation-play-state: paused; +} + +#spotify-modal .details .track-name:hover, +#spotify-modal .details .artists a:hover { + text-decoration: underline; +} + +#spotify-modal .details .cover-art { + border-radius: 8px; + + aspect-ratio: 1 / 1; + width: 56px; +} + +#spotify-modal .seekbar-container:not(.enabled) { + display: none; +} + +#spotify-modal .seekbar-container.collapse-on-blur { + transition: + 0.25s max-height 0.25s, + 0.25s opacity; + + opacity: 0; + max-height: 0; +} + +#spotify-modal:hover .seekbar-container.collapse-on-blur { + transition: + 0.25s max-height, + 0.25s opacity 0.25s; + + opacity: 1; + max-height: 200px; +} + +html.reduce-motion #spotify-modal.use-discord-reduce-motion .seekbar-container.collapse-on-blur, +#spotify-modal.reduce-motion .seekbar-container.collapse-on-blur { + transition: none; +} + +#spotify-modal .seekbar-container .timestamps { + display: flex; + justify-content: space-between; + + margin-bottom: -2px; +} + +#spotify-modal .seekbar { + --grabber-size: 12px !important; + --bar-size: 6px !important; +} + +#spotify-modal .seekbar + [class^='divider'] { + display: none; +} + +#spotify-modal .seekbar .bar [class^='barFill'] { + background-color: var(--spotify); +} diff --git a/plugins/SpotifyModal/src/types.ts b/plugins/SpotifyModal/src/types.ts new file mode 100644 index 0000000..d97bea0 --- /dev/null +++ b/plugins/SpotifyModal/src/types.ts @@ -0,0 +1,36 @@ +import { getByStoreName } from 'replugged/webpack'; + +type FluxStore = ReturnType; + +export interface SpotifyStore extends FluxStore { + getActiveSocketAndDevice(): { socket: { accountId: string } }; + getPlayerState(id: string): + | (Record & { + account: ConnectedAccount; + track: { + album: { image: { url: string }; name: string }; + // eslint-disable-next-line @typescript-eslint/naming-convention + artists: Array<{ name: string; external_urls: { spotify: string } }>; + name: string; + id: string; + duration: number; + }; + startTime: number; + }) + | null; + getActivity(): { + timestamps: { start: number; end: number }; + } | null; +} + +export interface ConnectedAccountsUtils { + refreshAccountToken(type: string, id: string): Promise; +} + +export interface ConnectedAccount { + name: string; + id: string; + type: string; + revoked: boolean; + accessToken?: string; +} diff --git a/plugins/SpotifyModal/src/utils.ts b/plugins/SpotifyModal/src/utils.ts new file mode 100644 index 0000000..160769c --- /dev/null +++ b/plugins/SpotifyModal/src/utils.ts @@ -0,0 +1,34 @@ +import { Logger } from 'replugged'; + +const log = Logger.plugin('SpotifyModal', '#1DB954'); + +const BASE_URL = 'https://api.spotify.com/v1/'; +export const spotify = { + async seekTo(token: string, position: number): Promise { + if (!token) { + log.error('missing token for request'); + return 0; + } + + const url = new URL('me/player/seek', BASE_URL); + url.searchParams.set('position_ms', Math.floor(position).toString()); + + return fetch(url, { + method: 'put', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.status) + .catch((e) => { + log.error("couldn't make request to Spotify", e); + return 0; + }); + }, +}; + +export const classNameFactory = (classNames: Record): string => + Object.entries(classNames) + .filter(([_, enabled]) => enabled) + .map(([className]) => className.trim()) + .join(' '); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..44ba719 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +onlyBuiltDependencies: + - '@parcel/watcher' + - esbuild + - replugged