Skip to content

Commit f7c49fb

Browse files
committed
feat: seekbar n other insignificant changes
1 parent d3c841c commit f7c49fb

5 files changed

Lines changed: 248 additions & 61 deletions

File tree

plugins/SpotifyModal/src/Modal.tsx

Lines changed: 182 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,107 @@
11
import React from 'react';
22
import { Logger } from 'replugged';
3-
import { ErrorBoundary } from 'replugged/components';
3+
import { ErrorBoundary, SliderItem, Tooltip } from 'replugged/components';
44

5-
import { SpotifyStore } from './types';
5+
import { ConnectedAccount, SpotifyStore } from './types';
6+
import * as utils from './utils';
67

78
const log = Logger.plugin('SpotifyModal', '#1DB954');
89

9-
export const ModalFallback = (): React.ReactElement => (
10-
<>uh oh. something went wrong while rendering the modal.</>
11-
);
10+
function formatTimestamp(timestamp: number): string {
11+
let seconds = Math.floor(timestamp / 1000);
12+
const hours = Math.floor(seconds / 3600);
13+
seconds -= hours * 3600;
14+
const minutes = Math.floor(seconds / 60);
15+
seconds -= minutes * 60;
1216

13-
export const Modal = (props: {
14-
store: SpotifyStore;
15-
fluxHooks: typeof import('replugged/common').fluxHooks;
16-
}): React.ReactElement => {
17-
const { store, fluxHooks } = props;
17+
return `${hours ? `${hours}:` : ''}${String(minutes).padStart(hours ? 2 : 1, '0')}:${String(seconds).padStart(2, '0')}`;
18+
}
1819

19-
const [state, setState] = React.useState<ReturnType<typeof store.getPlayerState>>();
20-
const [active, setActive] = React.useState(false);
21-
const [paused, setPaused] = React.useState(true);
20+
function handleOverflow(element: HTMLElement, parentLevel = 1): void {
21+
if (!element) return;
2222

23-
const socket = fluxHooks.useStateFromStores([store], () => {
24-
const socket = store.getActiveSocketAndDevice()?.socket;
25-
const _state = store.getPlayerState(socket?.accountId);
26-
const _active = Boolean(socket);
23+
let parent = element;
24+
for (let i = 0; i < parentLevel; i++) parent = parent?.parentElement;
2725

28-
if (active !== _active) {
29-
log.log('active state update', _active);
30-
setActive(_active);
31-
}
26+
if (!parent || parent === element) return;
3227

33-
if ((!socket || _active) && state !== _state) {
34-
if (_state) {
35-
log.log('player state update', _state);
36-
setState(_state);
37-
}
28+
if (element.scrollWidth > parent.clientWidth) {
29+
// 60px/s
30+
element.style.animationDuration = `${(element.scrollWidth / 45) * 1.1}s`;
31+
element.style.animationDelay = `-${(element.scrollWidth / 45) * 1.1 * 0.449}s`;
32+
element.classList.add('overflow');
33+
} else element.classList.remove('overflow');
34+
}
35+
36+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37+
const useInterval = (callback: (...args: any[]) => void, delay: number): void => {
38+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39+
const savedCallback = React.useRef<(...args: any[]) => void>();
3840

39-
if (paused !== Boolean(_state)) setPaused(Boolean(_state));
41+
React.useEffect(() => {
42+
savedCallback.current = callback;
43+
}, [callback]);
44+
45+
React.useEffect(() => {
46+
if (delay !== null) {
47+
let id = setInterval(() => savedCallback.current(), delay);
48+
return () => clearInterval(id);
4049
}
50+
}, [delay]);
51+
};
4152

42-
return socket;
43-
});
53+
export const ModalFallback = (): React.ReactElement => (
54+
<>uh oh. something went wrong while rendering the modal.</>
55+
);
56+
57+
export const TrackDetails = (props: {
58+
state: ReturnType<SpotifyStore['getPlayerState']>;
59+
}): React.ReactElement => {
60+
const { state } = props;
4461

4562
const trackNameElement = React.useRef<HTMLAnchorElement>();
4663
const artistsElement = React.useRef<HTMLDivElement>();
4764

48-
function handleOverflow(element: HTMLElement): void {
49-
if (!element?.parentElement) return;
50-
51-
if (element.scrollWidth > element.parentElement.clientWidth) {
52-
// 60px/s
53-
element.style.animationDuration = `${(element.scrollWidth / 45) * 1.1}s`;
54-
element.style.animationDelay = `-${(element.scrollWidth / 45) * 1.1 * 0.449}s`;
55-
element.classList.add('overflow');
56-
} else element.classList.remove('overflow');
57-
}
58-
5965
React.useEffect(() => {
60-
handleOverflow(trackNameElement.current);
61-
handleOverflow(artistsElement.current);
66+
handleOverflow(trackNameElement.current, 2);
67+
handleOverflow(artistsElement.current, 2);
6268
}, [state]);
6369

64-
return active && state ? (
65-
<>
66-
<div className='track-details details'>
70+
return (
71+
<div className='track-details details'>
72+
<Tooltip shouldShow={Boolean(state.track.album?.name)} text={state.track.album?.name || ''}>
6773
<span className='cover-art-container'>
6874
<img className='cover-art' src={state.track.album?.image?.url} />
6975
</span>
70-
<div className='container'>
71-
<div className='track-name-container'>
76+
</Tooltip>
77+
<div className='container'>
78+
<div className='track-name-container'>
79+
<Tooltip
80+
shouldShow={Boolean(state.track.name)}
81+
style={{ display: 'inline-block' }}
82+
text={state.track.name || ''}>
7283
<a
7384
ref={(e) => {
74-
handleOverflow(e);
85+
handleOverflow(e, 2);
7586
trackNameElement.current = e;
7687
}}
7788
className='track-name'
7889
href={state.track.id && `https://open.spotify.com/track/${state.track.id}`}>
7990
{state.track.name}
8091
</a>
81-
</div>
82-
<div className='artists-container'>
92+
</Tooltip>
93+
</div>
94+
95+
<div className='artists-container'>
96+
<Tooltip
97+
shouldShow={Boolean(state.track.artists?.length)}
98+
style={{ display: 'inline-block' }}
99+
text={Object.values(state.track.artists || [])
100+
.map((props) => props.name)
101+
.join(', ')}>
83102
<div
84103
ref={(e) => {
85-
handleOverflow(e);
104+
handleOverflow(e, 2);
86105
artistsElement.current = e;
87106
}}
88107
className='artists'>
@@ -93,9 +112,120 @@ export const Modal = (props: {
93112
</>
94113
))}
95114
</div>
96-
</div>
115+
</Tooltip>
97116
</div>
98117
</div>
118+
</div>
119+
);
120+
};
121+
122+
export const Seekbar = (props: {
123+
account: ConnectedAccount;
124+
start: number;
125+
end: number;
126+
paused: boolean;
127+
active: boolean;
128+
}): React.ReactNode => {
129+
const { account, start, end, paused, active } = props;
130+
131+
const [current, setCurrent] = React.useState(0);
132+
const ref = React.useRef<{ setState(props: { value: number }): void }>();
133+
134+
const isSeeking = React.useRef(false);
135+
136+
useInterval(() => {
137+
if (!active || paused || isSeeking.current) return;
138+
139+
setCurrent(Math.min(Date.now() - start, end));
140+
}, 500);
141+
142+
// discord's sliders are no longer dumb, which means they won't react to prop changes
143+
// after first render so we need to set its state manually
144+
React.useEffect(() => {
145+
ref.current?.setState?.({ value: current });
146+
}, [current]);
147+
148+
return (
149+
<div className='seekbar-container'>
150+
<div className='timestamps'>
151+
<span>{formatTimestamp(current)}</span>
152+
<span>{formatTimestamp(end)}</span>
153+
</div>
154+
<SliderItem
155+
// @ts-expect-error - ref can be used here
156+
ref={ref}
157+
className='seekbar'
158+
barClassName='bar'
159+
style={{ margin: 0 }}
160+
mini
161+
maxValue={end}
162+
value={current}
163+
minValue={0}
164+
onValueRender={formatTimestamp}
165+
asValueChanges={(v) => {
166+
if (!isSeeking.current) isSeeking.current = true;
167+
168+
setCurrent(v);
169+
}}
170+
onChange={(v) => {
171+
void utils.spotify.seekTo(account?.accessToken, v).then((res) => {
172+
isSeeking.current = false;
173+
});
174+
}}
175+
/>
176+
</div>
177+
);
178+
};
179+
180+
export const Modal = (props: {
181+
store: SpotifyStore;
182+
fluxHooks: typeof import('replugged/common').fluxHooks;
183+
}): React.ReactElement => {
184+
const { store, fluxHooks } = props;
185+
186+
const [state, setState] = React.useState<ReturnType<typeof store.getPlayerState>>();
187+
const [activity, setActivity] = React.useState<ReturnType<typeof store.getActivity>>();
188+
const [active, setActive] = React.useState(false);
189+
const [paused, setPaused] = React.useState(true);
190+
191+
const _socket = fluxHooks.useStateFromStores([store], () => {
192+
const socket = store.getActiveSocketAndDevice()?.socket;
193+
const _state = store.getPlayerState(socket?.accountId);
194+
const _active = Boolean(socket);
195+
const _activity = store.getActivity();
196+
197+
if (active !== _active) {
198+
log.log('active state update', _active);
199+
setActive(_active);
200+
}
201+
202+
if ((!socket || _active) && state !== _state) {
203+
if (_state) {
204+
log.log('player state update', _state);
205+
setState(_state);
206+
}
207+
208+
if (_activity) {
209+
log.log('activity update', _activity);
210+
setActivity(_activity);
211+
}
212+
213+
if (paused !== !_state) setPaused(!_state);
214+
}
215+
216+
return socket;
217+
});
218+
219+
return active && state ? (
220+
<>
221+
<TrackDetails state={state} />
222+
<Seekbar
223+
account={state.account}
224+
start={activity?.timestamps?.start || 0}
225+
end={state?.track?.duration || 1}
226+
paused={paused}
227+
active={active}
228+
/>
99229
</>
100230
) : (
101231
<></>

plugins/SpotifyModal/src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let modalInstance = <Main store={store} fluxHooks={fluxHooks} />;
1919

2020
export function start(): void {
2121
void (async () => {
22-
store = webpack.getByStoreName('SpotifyStore');
22+
store ??= webpack.getByStoreName('SpotifyStore');
2323

2424
userAreaElement = await waitFor('[class^=panels_] > [class^=container_]');
2525

plugins/SpotifyModal/src/style.css

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,23 @@
1717
}
1818

1919
#spotify-modal {
20+
display: flex;
21+
22+
flex-direction: column;
23+
gap: 10px;
24+
2025
font-size: 14px;
2126

2227
color: var(--text-normal);
2328

29+
margin: 8px;
2430
border-bottom: 1px solid var(--background-modifier-accent);
2531
}
2632

2733
#spotify-modal:empty {
2834
border-bottom: none;
2935
}
3036

31-
#spotify-modal > :not(.divider) {
32-
margin: 8px;
33-
}
34-
3537
#spotify-modal .details {
3638
display: flex;
3739

@@ -43,6 +45,7 @@
4345
display: flex;
4446

4547
flex-direction: column;
48+
flex-grow: 1;
4649
gap: 4px;
4750

4851
min-width: 0;
@@ -78,7 +81,7 @@ html.reduce-motion #spotify-modal .details .artists {
7881
}
7982

8083
#spotify-modal .details .track-name {
81-
display: block;
84+
display: inline-block;
8285

8386
font-weight: 500;
8487

@@ -105,3 +108,23 @@ html.reduce-motion #spotify-modal .details .artists {
105108
aspect-ratio: 1 / 1;
106109
width: 56px;
107110
}
111+
112+
#spotify-modal .seekbar-container .timestamps {
113+
display: flex;
114+
justify-content: space-between;
115+
116+
margin-bottom: -2px;
117+
}
118+
119+
#spotify-modal .seekbar {
120+
--grabber-size: 12px !important;
121+
--bar-size: 6px !important;
122+
}
123+
124+
#spotify-modal .seekbar + [class^='divider'] {
125+
display: none;
126+
}
127+
128+
#spotify-modal .seekbar .bar [class^='barFill'] {
129+
background-color: var(--spotify);
130+
}

plugins/SpotifyModal/src/types.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import { getByStoreName } from 'replugged/webpack';
22

3-
export interface SpotifyStore extends ReturnType<typeof getByStoreName> {
3+
type FluxStore = ReturnType<typeof getByStoreName>;
4+
5+
export interface SpotifyStore extends FluxStore {
46
getActiveSocketAndDevice(): { socket: { accountId: string } };
57
getPlayerState(id: string):
68
| (Record<string, unknown> & {
9+
account: ConnectedAccount;
710
track: {
8-
album: { image: { url: string } };
11+
album: { image: { url: string }; name: string };
12+
// eslint-disable-next-line @typescript-eslint/naming-convention
913
artists: Array<{ name: string; external_urls: { spotify: string } }>;
1014
name: string;
1115
id: string;
16+
duration: number;
1217
};
18+
startTime: number;
1319
})
1420
| null;
15-
getActivity(): Record<string, unknown> | null;
21+
getActivity(): {
22+
timestamps: { start: number; end: number };
23+
} | null;
24+
}
25+
26+
export interface ConnectedAccount {
27+
name: string;
28+
id: string;
29+
type: string;
30+
revoked: boolean;
31+
accessToken?: string;
1632
}

0 commit comments

Comments
 (0)