11import React from 'react' ;
22import { 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
78const 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 < > </ >
0 commit comments