diff --git a/LICENSE b/LICENSE index 42e6704..3e884ca 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2014, Matthew Dapena-Tretter / Gil Barbara +Copyright (c) 2019, Gil Barbara Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index c7f0b63..1bdfd99 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -37,9 +37,10 @@ const Wrapper = styled('div')( ({ styles }: StyledComponentProps) => ({ height: px(styles.height), }), + 'ActionsRSWP', ); -const Loader = ({ +const Actions = ({ currentDeviceId, isDevicesOpen, onClickDevice, @@ -62,4 +63,4 @@ const Loader = ({ ); }; -export default Loader; +export default Actions; diff --git a/src/components/Content.tsx b/src/components/Content.tsx index eb5ab69..cc445dc 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -26,6 +26,7 @@ const Wrapper = styled('div')( ({ styles }: StyledComponentProps) => ({ minHeight: px(styles.height), }), + 'ContentRSWP', ); const Content = ({ children, styles }: StyledComponentProps) => { diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 32e2699..c93490e 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -20,30 +20,34 @@ interface Props { styles: StylesOptions; } -const Wrapper = styled('div')({}, ({ styles }: StyledComponentProps) => ({ - alignItems: 'center', - display: 'flex', - height: px(styles.height), - justifyContent: 'center', +const Wrapper = styled('div')( + {}, + ({ styles }: StyledComponentProps) => ({ + alignItems: 'center', + display: 'flex', + height: px(styles.height), + justifyContent: 'center', - '@media (max-width: 767px)': { - padding: px(10), - }, + '@media (max-width: 767px)': { + padding: px(10), + }, - '> div': { - minWidth: px(styles.height), - textAlign: 'center', - }, + '> div': { + minWidth: px(styles.height), + textAlign: 'center', + }, - button: { - color: styles.color, - fontSize: px(16), + button: { + color: styles.color, + fontSize: px(16), - '&.rswp__toggle': { - fontSize: px(28), + '&.rswp__toggle': { + fontSize: px(28), + }, }, - }, -})); + }), + 'ControlsRSWP', +); export default class Controls extends PureComponent { public render() { diff --git a/src/components/Devices.tsx b/src/components/Devices.tsx index 51a347b..f4602e2 100644 --- a/src/components/Devices.tsx +++ b/src/components/Devices.tsx @@ -57,11 +57,10 @@ const Wrapper = styled('div')( color: styles.color, }, }), + 'DevicesRSWP', ); export default class Devices extends PureComponent { - private timeout: any; - constructor(props: Props) { super(props); @@ -71,12 +70,16 @@ export default class Devices extends PureComponent { }; } - public componentDidMount() { + public async componentDidMount() { const { token } = this.props; - getDevices(token).then(({ devices }) => { + try { + const { devices } = await getDevices(token); this.setState({ devices: devices || [] }); - }); + } catch (error) { + // tslint:disable-next-line:no-console + console.error('getDevices', error); + } } private handleClickSetDevice = (e: React.MouseEvent) => { @@ -93,11 +96,7 @@ export default class Devices extends PureComponent { private handleClickToggleDevices = () => { const { isOpen } = this.state; - clearTimeout(this.timeout); - - this.timeout = setTimeout(() => { - this.setState({ isOpen: !isOpen }); - }, 100); + this.setState({ isOpen: !isOpen }); }; public render() { diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 4685136..98fd300 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -14,6 +14,7 @@ const Wrapper = styled('p')( height: px(styles.height), lineHeight: px(styles.height), }), + 'ErrorRSWP', ); const Error = ({ children, styles }: StyledComponentProps) => { diff --git a/src/components/Info.tsx b/src/components/Info.tsx index 8d31dad..f77e2eb 100644 --- a/src/components/Info.tsx +++ b/src/components/Info.tsx @@ -20,49 +20,53 @@ interface State { isSaved: boolean; } -const Wrapper = styled('div')({}, ({ styles }: StyledComponentProps) => ({ - alignItems: 'center', - display: 'flex', - height: px(styles.height), - textAlign: 'left', - - '@media (max-width: 599px)': { - borderBottom: '1px solid #ccc', - display: 'none', - width: '100%', - }, +const Wrapper = styled('div')( + {}, + ({ styles }: StyledComponentProps) => ({ + alignItems: 'center', + display: 'flex', + height: px(styles.height), + textAlign: 'left', - '&.rswp__active': { '@media (max-width: 599px)': { - display: 'flex', + borderBottom: '1px solid #ccc', + display: 'none', + width: '100%', }, - }, - img: { - height: px(styles.height), - width: px(styles.height), - }, + '&.rswp__active': { + '@media (max-width: 599px)': { + display: 'flex', + }, + }, - p: { - '&:first-child': { - alignItems: 'center', - display: 'inline-flex', + img: { + height: px(styles.height), + width: px(styles.height), + }, - button: { - fontSize: '110%', - marginLeft: px(5), + p: { + '&:first-child': { + alignItems: 'center', + display: 'inline-flex', - '&:focus': { - outline: 'none', - }, + button: { + fontSize: '110%', + marginLeft: px(5), + + '&:focus': { + outline: 'none', + }, - '&.rswp__active': { - color: styles.savedColor, + '&.rswp__active': { + color: styles.savedColor, + }, }, }, }, - }, -})); + }), + 'InfoRSWP', +); const Title = styled('div')({}, ({ styles }: StyledComponentProps) => ({ marginLeft: px(10), @@ -84,6 +88,9 @@ const Title = styled('div')({}, ({ styles }: StyledComponentProps) => ({ })); export default class Info extends PureComponent { + // tslint:disable-next-line:variable-name + private _isMounted = false; + constructor(props: Props) { super(props); @@ -93,11 +100,13 @@ export default class Info extends PureComponent { } public async componentDidMount() { + this._isMounted = true; + const { token, track } = this.props; if (track.id) { await checkTracksStatus(track.id, token).then(d => { - const [isSaved] = d; + const [isSaved = false] = d || []; this.setState({ isSaved }); }); @@ -107,17 +116,28 @@ export default class Info extends PureComponent { public async componentDidUpdate(prevProps: Props) { const { token, track } = this.props; + if (!this._isMounted) { + return; + } + if (prevProps.track.id !== track.id && track.id) { this.setState({ isSaved: false }); - await checkTracksStatus(track.id, token).then(d => { - const [isSaved] = d; + const status = await checkTracksStatus(track.id, token); + const [isSaved] = status || [false]; - this.setState({ isSaved }); - }); + if (!this._isMounted) { + return; + } + + this.setState({ isSaved }); } } + public componentWillUnmount() { + this._isMounted = false; + } + private handleClickIcon = async () => { const { isSaved } = this.state; const { token, track } = this.props; diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 13ef07a..d3561d5 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -51,6 +51,7 @@ const Wrapper = styled('div')( }, }; }, + 'LoaderRSWP', ); const Loader = ({ styles }: StyledComponentProps) => { diff --git a/src/components/Player.tsx b/src/components/Player.tsx index b5405e7..75b67f4 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -37,6 +37,7 @@ const Wrapper = styled('div')( backgroundColor: styles.bgColor, minHeight: px(styles.height), }), + 'PlayerRSWP', ); const Player = ({ children, styles }: StyledComponentProps) => { diff --git a/src/components/Slider.tsx b/src/components/Slider.tsx index 9300d7b..3d08801 100644 --- a/src/components/Slider.tsx +++ b/src/components/Slider.tsx @@ -23,6 +23,7 @@ const Wrapper = styled('div')( ({ styles }: StyledComponentProps) => ({ height: px(styles.rangeHeight), }), + 'SliderRSWP', ); export default class Slider extends PureComponent { diff --git a/src/components/Volume.tsx b/src/components/Volume.tsx index e984213..fe94c17 100644 --- a/src/components/Volume.tsx +++ b/src/components/Volume.tsx @@ -50,11 +50,10 @@ const Wrapper = styled('div')( color: styles.color, }, }), + 'VolumeRSWP', ); export default class Volume extends PureComponent { - private timeout?: any; - constructor(props: Props) { super(props); @@ -66,11 +65,7 @@ export default class Volume extends PureComponent { private handleClick = () => { const { isOpen } = this.state; - clearTimeout(this.timeout); - - this.timeout = setTimeout(() => { - this.setState({ isOpen: !isOpen }); - }, 100); + this.setState({ isOpen: !isOpen }); }; private handleChangeSlider = ({ y }: RangeSliderPosition) => { @@ -90,7 +85,7 @@ export default class Volume extends PureComponent { if (volume === 0) { icon = ; - } else if (volume < 0.5) { + } else if (volume <= 0.5) { icon = ; } @@ -100,6 +95,7 @@ export default class Volume extends PureComponent { ( +const DevicesIcon = (props: object) => ( ( ); -export default Devices; +export default DevicesIcon; diff --git a/src/components/icons/Offline.tsx b/src/components/icons/Offline.tsx deleted file mode 100644 index 33a07a5..0000000 --- a/src/components/icons/Offline.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -const Offline = (props: object) => ( - - - -); - -export default Offline; diff --git a/src/components/icons/Repeat.tsx b/src/components/icons/Repeat.tsx deleted file mode 100644 index 232a0e0..0000000 --- a/src/components/icons/Repeat.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -const Repeat = (props: object) => ( - - - -); - -export default Repeat; diff --git a/src/components/icons/Times.tsx b/src/components/icons/Times.tsx deleted file mode 100644 index 18e07b9..0000000 --- a/src/components/icons/Times.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -const Times = (props: object) => ( - - - -); - -export default Times; diff --git a/src/index.tsx b/src/index.tsx index bc8951e..f86c0a2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,11 +2,10 @@ import React, { PureComponent } from 'react'; import { getPlayerStatus, next, pause, play, previous, seek, setVolume } from './spotify'; import { getMergedStyles } from './styles'; -import { isEqualArray, loadScript, STATUS, TYPE } from './utils'; +import { getSpotifyURIType, isEqualArray, loadScript, validateURI, STATUS, TYPE } from './utils'; -import { StylesOptions, StylesProps } from './types/common'; +import { PlayOptions, Props, State, StylesOptions } from './types/common'; import { - PlayerTrack, SpotifyPlayerStatus, WebPlaybackAlbum, WebPlaybackError, @@ -14,7 +13,6 @@ import { WebPlaybackPlayer, WebPlaybackReady, WebPlaybackState, - WebPlaybackTrack, } from './types/spotify'; import Actions from './components/Actions'; @@ -26,42 +24,6 @@ import Loader from './components/Loader'; import Player from './components/Player'; import Slider from './components/Slider'; -export interface Callback extends State { - type: string; -} - -export interface Props { - autoPlay?: boolean; - callback?: (state: Callback) => any; - list?: string; - name?: string; - offset?: number; - persistDeviceSelection?: boolean; - showSaveIcon?: boolean; - syncExternalDeviceInterval?: number; - token: string; - tracks?: string | string[]; - styles?: StylesProps; -} - -export interface State { - currentDeviceId: string; - deviceId: string; - error: string; - errorType: string; - isActive: boolean; - isMagnified: boolean; - isPlaying: boolean; - isUnsupported: boolean; - nextTracks: WebPlaybackTrack[]; - position: number; - previousTracks: WebPlaybackTrack[]; - progressMs?: number; - status: string; - track: PlayerTrack; - volume: number; -} - class SpotifyWebPlayer extends PureComponent { private static defaultProps = { callback: () => undefined, @@ -72,9 +34,18 @@ class SpotifyWebPlayer extends PureComponent { // tslint:disable-next-line:variable-name private _isMounted = false; + private emptyTrack = { + artists: '', + durationMs: 0, + id: '', + image: '', + name: '', + uri: '', + }; private player?: WebPlaybackPlayer; private playerProgressInterval?: number; private playerSyncInterval?: number; + private syncTimeout?: number; private seekUpdateInterval = 100; private readonly styles: StylesOptions; @@ -94,14 +65,7 @@ class SpotifyWebPlayer extends PureComponent { position: 0, previousTracks: [], status: STATUS.IDLE, - track: { - artists: '', - durationMs: 0, - id: '', - image: '', - name: '', - uri: '', - }, + track: this.emptyTrack, volume: 1, }; @@ -110,7 +74,7 @@ class SpotifyWebPlayer extends PureComponent { public async componentDidMount() { this._isMounted = true; - this.setState({ status: STATUS.INITIALIZING }); + this.updateState({ status: STATUS.INITIALIZING }); // @ts-ignore window.onSpotifyWebPlaybackSDKReady = this.initializePlayer; @@ -122,23 +86,28 @@ class SpotifyWebPlayer extends PureComponent { }); } - public componentDidUpdate(prevProps: Props, prevState: State) { + public async componentDidUpdate(prevProps: Props, prevState: State) { const { currentDeviceId, isPlaying, status, track } = this.state; - const { autoPlay, callback, list, offset, persistDeviceSelection, tracks, token } = this.props; + const { autoPlay, callback, offset, persistDeviceSelection, token, uris } = this.props; const isReady = prevState.status !== STATUS.READY && status === STATUS.READY; - const changedSource = - prevProps.list !== list || (Array.isArray(tracks) && !isEqualArray(prevProps.tracks, tracks)); - const canPlay = currentDeviceId && !!(list || this.tracks); - const shouldPlay = (changedSource && isPlaying) || (isReady && autoPlay); + const changedURIs = Array.isArray(uris) ? !isEqualArray(prevProps.uris, uris) : uris !== uris; + + const canPlay = currentDeviceId && !!(this.playOptions.context_uri || this.playOptions.uris); + const shouldPlay = (changedURIs && isPlaying) || (isReady && autoPlay); if (canPlay && shouldPlay) { - play({ context_uri: list, deviceId: currentDeviceId, uris: this.tracks, offset }, token).then( - () => { - if (!this.state.isPlaying) { - this.setState({ isPlaying: true }); - } - }, - ); + await play({ deviceId: currentDeviceId, offset, ...this.playOptions }, token); + + /* istanbul ignore else */ + if (!this.state.isPlaying) { + this.updateState({ isPlaying: true }); + } + + if (this.isExternalPlayer) { + this.syncTimeout = window.setTimeout(() => { + this.syncDevice(); + }, 600); + } } if (prevState.status !== status) { @@ -156,7 +125,8 @@ class SpotifyWebPlayer extends PureComponent { } if (prevState.currentDeviceId !== currentDeviceId && currentDeviceId) { - this.handleDeviceChange(); + await this.handleDeviceChange(); + if (persistDeviceSelection) { sessionStorage.setItem('rswpDeviceId', currentDeviceId); } @@ -171,7 +141,7 @@ class SpotifyWebPlayer extends PureComponent { if (prevState.isPlaying !== isPlaying) { this.handlePlaybackStatus(); - this.handleDeviceChange(); + await this.handleDeviceChange(); callback!({ ...this.state, @@ -183,183 +153,165 @@ class SpotifyWebPlayer extends PureComponent { public componentWillUnmount() { this._isMounted = false; + /* istanbul ignore else */ if (this.player) { this.player.disconnect(); } clearInterval(this.playerSyncInterval); clearInterval(this.playerProgressInterval); + clearTimeout(this.syncTimeout); } - private initializePlayer = () => { - const { name, token } = this.props; + private get isExternalPlayer(): boolean { + const { currentDeviceId, deviceId, status } = this.state; - // @ts-ignore - this.player = new window.Spotify.Player({ - getOAuthToken: (cb: (token: string) => void) => { - cb(token); - }, - name, - }) as WebPlaybackPlayer; + return (currentDeviceId && currentDeviceId !== deviceId) || status === STATUS.UNSUPPORTED; + } - this.player.addListener('ready', this.handlePlayerStatus); - this.player.addListener('not_ready', this.handlePlayerStatus); - this.player.addListener('player_state_changed', this.handlePlayerStateChanges); - this.player.addListener('initialization_error', (error: WebPlaybackError) => - this.handlePlayerErrors('initialization_error', error.message), - ); - this.player.addListener('authentication_error', (error: WebPlaybackError) => - this.handlePlayerErrors('authentication_error', error.message), - ); - this.player.addListener('account_error', (error: WebPlaybackError) => - this.handlePlayerErrors('account_error', error.message), - ); - this.player.addListener('playback_error', (error: WebPlaybackError) => - this.handlePlayerErrors('playback_error', error.message), - ); + private get playOptions(): PlayOptions { + const { uris } = this.props; - this.player.connect(); - }; + const response: PlayOptions = { + context_uri: undefined, + uris: undefined, + }; - private updateSeekBar = async () => { - const { isPlaying, progressMs, track } = this.state; + /* istanbul ignore else */ + if (uris) { + const ids = Array.isArray(uris) ? uris : [uris]; + + if (ids.length > 1 && getSpotifyURIType(ids[0]) === 'track') { + response.uris = ids.filter(d => validateURI(d) && getSpotifyURIType(d) === 'track'); + } else { + response.context_uri = ids[0]; + } + } + + return response; + } + + private handleChangeRange = async (position: number) => { + const { track } = this.state; + const { token } = this.props; + + try { + const percentage = position / 100; - if (isPlaying) { if (this.isExternalPlayer) { - const position = progressMs! / track.durationMs; + await seek(Math.round(track.durationMs * percentage), token); - this.setState({ - position: Number((position * 100).toFixed(1)), - progressMs: progressMs! + this.seekUpdateInterval, + this.updateState({ + position, + progressMs: Math.round(track.durationMs * percentage), }); } else if (this.player) { const state = (await this.player.getCurrentState()) as WebPlaybackState; if (state) { - const position = state.position / state.track_window.current_track.duration_ms; - - this.setState({ position: Number((position * 100).toFixed(1)) }); + this.player.seek(Math.round(state.track_window.current_track.duration_ms * percentage)); + } else { + this.updateState({ position: 0 }); } } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } }; - private setVolume = (volume: number) => { - const { token } = this.props; - - if (this.isExternalPlayer) { - setVolume(Math.round(volume * 100), token); - } else if (this.player) { - this.player.setVolume(volume); + private handleClickTogglePlay = async () => { + try { + await this.togglePlay(); + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } - - this.setState({ volume }); }; - private togglePlay = async (init?: boolean) => { - const { currentDeviceId, isPlaying } = this.state; - const { list, offset, token } = this.props; - - if (this.isExternalPlayer) { - if (!isPlaying) { - this.setState({ isPlaying: true }); - - return play( - { - context_uri: list, - deviceId: currentDeviceId, - offset, - uris: init ? this.tracks : undefined, - }, - token, - ); - } else { - this.setState({ isPlaying: false }); - return pause(token); - } - } else if (this.player) { - const playerState = await this.player.getCurrentState(); + private handleClickPrevious = async () => { + try { + /* istanbul ignore else */ + if (this.isExternalPlayer) { + const { token } = this.props; - if (!playerState && this.tracks.length) { - return play( - { context_uri: list, deviceId: currentDeviceId, uris: this.tracks, offset }, - token, - ); - } else { - this.player.togglePlay(); + await previous(token); + this.syncTimeout = window.setTimeout(() => { + this.syncDevice(); + }, 300); + } else if (this.player) { + await this.player.previousTrack(); } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } }; - private syncDevice = async () => { - if (!this._isMounted) { - return; - } - - const { token } = this.props; - + private handleClickNext = async () => { try { - const player: SpotifyPlayerStatus = await getPlayerStatus(token); + /* istanbul ignore else */ + if (this.isExternalPlayer) { + const { token } = this.props; - this.setState({ - error: '', - errorType: '', - isActive: true, - isPlaying: player.is_playing, - nextTracks: [], - previousTracks: [], - progressMs: player.progress_ms, - track: { - artists: player.item.artists.map(d => d.name).join(' / '), - durationMs: player.item.duration_ms, - id: player.item.id, - image: this.getAlbumImage(player.item.album), - name: player.item.name, - uri: player.item.uri, - }, - volume: player.device.volume_percent, - }); + await next(token); + this.syncTimeout = window.setTimeout(() => { + this.syncDevice(); + }, 300); + } else if (this.player) { + await this.player.nextTrack(); + } } catch (error) { - this.setState({ - error: error.message, - errorType: 'player_status', - status: STATUS.ERROR, - }); + // tslint:disable-next-line:no-console + console.error(error); } }; - private get isExternalPlayer(): boolean { - const { currentDeviceId, deviceId, status } = this.state; - - return (currentDeviceId && currentDeviceId !== deviceId) || status === STATUS.UNSUPPORTED; - } + private handleClickDevice = async (deviceId: string) => { + const { isUnsupported } = this.state; - private get tracks(): string[] { - const { tracks } = this.props; + this.updateState({ currentDeviceId: deviceId }); - if (!tracks) { - return []; + try { + if (isUnsupported) { + await this.togglePlay(true); + await this.syncDevice(); + } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } + }; - const uris: string[] = Array.isArray(tracks) ? tracks : [tracks]; + private async handleDeviceChange() { + const { isPlaying } = this.state; + const { syncExternalDeviceInterval } = this.props; - return uris.map( - (d: string): string => (d.indexOf('spotify:track') < 0 ? `spotify:track:${d}` : d), - ); - } + try { + if (this.isExternalPlayer && isPlaying && !this.playerSyncInterval) { + await this.syncDevice(); - private getAlbumImage(album: WebPlaybackAlbum): string { - const width = Math.min(...album.images.map(d => d.width)); - const thumb: WebPlaybackImage = - album.images.find(d => d.width === width) || ({} as WebPlaybackImage); + this.playerSyncInterval = window.setInterval( + this.syncDevice, + syncExternalDeviceInterval! * 1000, + ); + } - return thumb.url; + if ((!isPlaying || !this.isExternalPlayer) && this.playerSyncInterval) { + clearInterval(this.playerSyncInterval); + this.playerSyncInterval = undefined; + } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); + } } private handlePlaybackStatus() { const { isPlaying } = this.state; if (isPlaying) { + /* istanbul ignore else */ if (!this.playerProgressInterval) { this.playerProgressInterval = window.setInterval( this.updateSeekBar, @@ -367,6 +319,7 @@ class SpotifyWebPlayer extends PureComponent { ); } } else { + /* istanbul ignore else */ if (this.playerProgressInterval) { clearInterval(this.playerProgressInterval); this.playerProgressInterval = undefined; @@ -374,23 +327,6 @@ class SpotifyWebPlayer extends PureComponent { } } - private handleDeviceChange() { - const { isPlaying } = this.state; - const { syncExternalDeviceInterval } = this.props; - - if (this.isExternalPlayer && isPlaying && !this.playerSyncInterval) { - this.playerSyncInterval = window.setInterval( - this.syncDevice, - syncExternalDeviceInterval! * 1000, - ); - } - - if ((!isPlaying || !this.isExternalPlayer) && this.playerSyncInterval) { - clearInterval(this.playerSyncInterval); - this.playerSyncInterval = undefined; - } - } - private handlePlayerErrors = (type: string, message: string) => { const { status } = this.state; const isPlaybackError = type === 'playback_error'; @@ -409,7 +345,7 @@ class SpotifyWebPlayer extends PureComponent { nextStatus = STATUS.ERROR; } - this.setState({ + this.updateState({ error: message, errorType: type, isUnsupported: isInitializationError, @@ -418,47 +354,53 @@ class SpotifyWebPlayer extends PureComponent { }; private handlePlayerStateChanges = async (state: WebPlaybackState | null) => { - if (state) { - const isPlaying = !state.paused; - const { album, artists, duration_ms, id, name, uri } = state.track_window.current_track; - const volume = await this.player!.getVolume(); - const track = { - artists: artists.map(d => d.name).join(' / '), - durationMs: duration_ms, - id, - image: this.getAlbumImage(album), - name, - uri, - }; - - this.setState({ - error: '', - errorType: '', - isActive: true, - isPlaying, - nextTracks: state.track_window.next_tracks, - previousTracks: state.track_window.previous_tracks, - track, - volume, - }); - } else if (this.isExternalPlayer) { - await this.syncDevice(); - } else { - this.setState({ - isActive: false, - isPlaying: false, - nextTracks: [], - position: 0, - previousTracks: [], - track: { - artists: '', - durationMs: 0, - id: '', - image: '', - name: '', - uri: '', - }, - }); + try { + /* istanbul ignore else */ + if (state) { + const isPlaying = !state.paused; + const { album, artists, duration_ms, id, name, uri } = state.track_window.current_track; + const volume = await this.player!.getVolume(); + const track = { + artists: artists.map(d => d.name).join(' / '), + durationMs: duration_ms, + id, + image: this.setAlbumImage(album), + name, + uri, + }; + + this.updateState({ + error: '', + errorType: '', + isActive: true, + isPlaying, + nextTracks: state.track_window.next_tracks, + previousTracks: state.track_window.previous_tracks, + track, + volume, + }); + } else if (this.isExternalPlayer) { + await this.syncDevice(); + } else { + this.updateState({ + isActive: false, + isPlaying: false, + nextTracks: [], + position: 0, + previousTracks: [], + track: { + artists: '', + durationMs: 0, + id: '', + image: '', + name: '', + uri: '', + }, + }); + } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } }; @@ -470,85 +412,208 @@ class SpotifyWebPlayer extends PureComponent { currentDeviceId = sessionStorage.getItem('rswpDeviceId') as string; } - this.setState({ + // TODO: remove this hack after it is fixed in the Web Playback SDK + const iframe = document.querySelector( + 'iframe[src="https://sdk.scdn.co/embedded/index.html"]', + ) as HTMLElement; + + if (iframe) { + iframe.style.display = 'block'; + iframe.style.position = 'absolute'; + iframe.style.top = '-1000px'; + iframe.style.left = '-1000px'; + } + + this.updateState({ currentDeviceId, deviceId: device_id, status: device_id ? STATUS.READY : STATUS.IDLE, }); }; - private handleChangeRange = async (position: number) => { - const { track } = this.state; + private handleToggleMagnify = () => { + this.updateState((prevState: State) => { + return { isMagnified: !prevState.isMagnified }; + }); + }; + + private initializePlayer = () => { + const { name, token } = this.props; + + // @ts-ignore + this.player = new window.Spotify.Player({ + getOAuthToken: (cb: (token: string) => void) => { + cb(token); + }, + name, + }) as WebPlaybackPlayer; + + this.player.addListener('ready', this.handlePlayerStatus); + this.player.addListener('not_ready', this.handlePlayerStatus); + this.player.addListener('player_state_changed', this.handlePlayerStateChanges); + this.player.addListener('initialization_error', (error: WebPlaybackError) => + this.handlePlayerErrors('initialization_error', error.message), + ); + this.player.addListener('authentication_error', (error: WebPlaybackError) => + this.handlePlayerErrors('authentication_error', error.message), + ); + this.player.addListener('account_error', (error: WebPlaybackError) => + this.handlePlayerErrors('account_error', error.message), + ); + this.player.addListener('playback_error', (error: WebPlaybackError) => + this.handlePlayerErrors('playback_error', error.message), + ); + + this.player.connect(); + }; + + private setAlbumImage(album: WebPlaybackAlbum): string { + const width = Math.min(...album.images.map(d => d.width)); + const thumb: WebPlaybackImage = + album.images.find(d => d.width === width) || ({} as WebPlaybackImage); + + return thumb.url; + } + + private setVolume = (volume: number) => { const { token } = this.props; - const percentage = position / 100; + /* istanbul ignore else */ if (this.isExternalPlayer) { - try { - await seek(Math.round(track.durationMs * percentage), token); - - this.setState({ - position, - progressMs: Math.round(track.durationMs * percentage), - }); - } catch (error) { - // nothing here - } + setVolume(Math.round(volume * 100), token); } else if (this.player) { - const state = (await this.player.getCurrentState()) as WebPlaybackState; - - if (state) { - this.player.seek(Math.round(state.track_window.current_track.duration_ms * percentage)); - } else { - this.setState({ position: 0 }); - } + this.player.setVolume(volume); } - }; - private handleClickTogglePlay = async () => { - this.togglePlay(); + this.updateState({ volume }); }; - private handleClickPrevious = async () => { - if (this.isExternalPlayer) { - const { token } = this.props; + private syncDevice = async () => { + if (!this._isMounted) { + return; + } - await previous(token); - setTimeout(() => { - this.syncDevice(); - }, 300); - } else if (this.player) { - await this.player.previousTrack(); + const { token } = this.props; + + try { + const player: SpotifyPlayerStatus = await getPlayerStatus(token); + let track = this.emptyTrack; + + /* istanbul ignore else */ + if (player.item) { + track = { + artists: player.item.artists.map(d => d.name).join(' / '), + durationMs: player.item.duration_ms, + id: player.item.id, + image: this.setAlbumImage(player.item.album), + name: player.item.name, + uri: player.item.uri, + }; + } + + this.updateState({ + error: '', + errorType: '', + isActive: true, + isPlaying: player.is_playing, + nextTracks: [], + previousTracks: [], + progressMs: player.item ? player.progress_ms : 0, + status: STATUS.READY, + track, + volume: player.device.volume_percent, + }); + } catch (error) { + this.updateState({ + error: error.message, + errorType: 'player_status', + status: STATUS.ERROR, + }); } }; - private handleClickNext = async () => { - if (this.isExternalPlayer) { - const { token } = this.props; + private togglePlay = async (init?: boolean) => { + const { currentDeviceId, isPlaying } = this.state; + const { offset, token } = this.props; - await next(token); - setTimeout(() => { - this.syncDevice(); - }, 300); - } else if (this.player) { - await this.player.nextTrack(); + try { + /* istanbul ignore else */ + if (this.isExternalPlayer) { + if (!isPlaying) { + this.updateState({ isPlaying: true }); + + return play( + { + deviceId: currentDeviceId, + offset, + ...(init ? this.playOptions : undefined), + }, + token, + ); + } else { + this.updateState({ isPlaying: false }); + return pause(token); + } + } else if (this.player) { + const playerState = await this.player.getCurrentState(); + + if (!playerState && !!(this.playOptions.context_uri || this.playOptions.uris)) { + return play( + { deviceId: currentDeviceId, offset, ...(init ? this.playOptions : undefined) }, + token, + ); + } else { + this.player.togglePlay(); + } + } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } }; - private handleClickDevice = async (deviceId: string) => { - const { isUnsupported } = this.state; + private updateSeekBar = async () => { + if (!this._isMounted) { + return; + } - this.setState({ currentDeviceId: deviceId }); + const { isPlaying, progressMs, track } = this.state; - if (isUnsupported) { - await this.togglePlay(true); - await this.syncDevice(); + try { + /* istanbul ignore else */ + if (isPlaying) { + /* istanbul ignore else */ + if (this.isExternalPlayer) { + let position = progressMs! / track.durationMs; + position = Number.isFinite(position) ? position : 0; + + this.updateState({ + position: Number((position * 100).toFixed(1)), + progressMs: progressMs! + this.seekUpdateInterval, + }); + } else if (this.player) { + const state = (await this.player.getCurrentState()) as WebPlaybackState; + + /* istanbul ignore else */ + if (state) { + const position = state.position / state.track_window.current_track.duration_ms; + + this.updateState({ position: Number((position * 100).toFixed(1)) }); + } + } + } + } catch (error) { + // tslint:disable-next-line:no-console + console.error(error); } }; - private handleToggleMagnify = () => { - this.setState((prevState: State) => { - return { isMagnified: !prevState.isMagnified }; - }); + private updateState = (state: {}) => { + if (!this._isMounted) { + return; + } + + this.setState(state); }; public render() { @@ -571,9 +636,7 @@ class SpotifyWebPlayer extends PureComponent { const { name, showSaveIcon, token } = this.props; const isReady = [STATUS.READY, STATUS.UNSUPPORTED].indexOf(status) >= 0; const isPlaybackError = errorType === 'playback_error'; - let output = ; - let info; if (isPlaybackError) { @@ -581,6 +644,7 @@ class SpotifyWebPlayer extends PureComponent { } if (isReady) { + /* istanbul ignore else */ if (!info) { info = ( d.json()); +} + +export async function getDevices(token: string) { + return fetch(`https://api.spotify.com/v1/me/player/devices`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'GET', + }).then(d => d.json()); +} + +export async function getPlayerStatus(token: string) { + return fetch(`https://api.spotify.com/v1/me/player`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'GET', + }).then(d => d.json()); +} + +export async function pause(token: string) { + return fetch(`https://api.spotify.com/v1/me/player/pause`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'PUT', + }); +} + export async function play( { context_uri, deviceId, offset = 0, uris }: PlayOptions, token: string, @@ -29,16 +71,6 @@ export async function play( }); } -export async function pause(token: string) { - return fetch(`https://api.spotify.com/v1/me/player/pause`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - method: 'PUT', - }); -} - export async function previous(token: string) { return fetch(`https://api.spotify.com/v1/me/player/previous`, { headers: { @@ -59,26 +91,17 @@ export async function next(token: string) { }); } -export async function getPlayerStatus(token: string) { - return fetch(`https://api.spotify.com/v1/me/player`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - method: 'GET', - }).then(d => d.json()); -} - -export async function checkTracksStatus(tracks: string | string[], token: string) { +export async function removeTracks(tracks: string | string[], token: string) { const ids = Array.isArray(tracks) ? tracks : [tracks]; - return fetch(`https://api.spotify.com/v1/me/tracks/contains?ids=${ids}`, { + return fetch(`https://api.spotify.com/v1/me/tracks`, { + body: JSON.stringify(ids), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - method: 'GET', - }).then(d => d.json()); + method: 'DELETE', + }); } export async function saveTracks(tracks: string | string[], token: string) { @@ -94,19 +117,6 @@ export async function saveTracks(tracks: string | string[], token: string) { }); } -export async function removeTracks(tracks: string | string[], token: string) { - const ids = Array.isArray(tracks) ? tracks : [tracks]; - - return fetch(`https://api.spotify.com/v1/me/tracks`, { - body: JSON.stringify(ids), - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - method: 'DELETE', - }); -} - export async function seek(position: number, token: string) { return fetch(`https://api.spotify.com/v1/me/player/seek?position_ms=${position}`, { headers: { @@ -117,8 +127,9 @@ export async function seek(position: number, token: string) { }); } -export async function setVolume(volume: number, token: string) { - return fetch(`https://api.spotify.com/v1/me/player/volume?volume_percent=${volume}`, { +export async function setDevice(deviceId: string, token: string) { + return fetch(`https://api.spotify.com/v1/me/player`, { + body: JSON.stringify({ device_ids: [deviceId], play: true }), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', @@ -127,19 +138,8 @@ export async function setVolume(volume: number, token: string) { }); } -export async function getDevices(token: string) { - return fetch(`https://api.spotify.com/v1/me/player/devices`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - method: 'GET', - }).then(d => d.json()); -} - -export async function setDevice(deviceId: string, token: string) { - return fetch(`https://api.spotify.com/v1/me/player`, { - body: JSON.stringify({ device_ids: [deviceId], play: true }), +export async function setVolume(volume: number, token: string) { + return fetch(`https://api.spotify.com/v1/me/player/volume?volume_percent=${volume}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', diff --git a/src/styles.tsx b/src/styles.tsx index e8618b8..7dab882 100644 --- a/src/styles.tsx +++ b/src/styles.tsx @@ -45,7 +45,7 @@ export function getMergedStyles(styles: StylesProps | undefined): StylesOptions return { bgColor: '#fff', color: '#333', - errorColor: '#d7d300', + errorColor: '#a60000', height: 48, loaderSize: 32, rangeColor: '#666', diff --git a/src/types/common.d.ts b/src/types/common.d.ts deleted file mode 100644 index 09ccba5..0000000 --- a/src/types/common.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -export interface StylesOptions { - bgColor: string; - color: string; - errorColor: string; - height: number | string; - loaderSize: number | string; - rangeHandleColor: string; - rangeHandleBorderRadius: number | string; - rangeHeight: number; - rangeColor: string; - rangeTrackBorderRadius: number | string; - rangeTrackColor: string; - savedColor: string; - trackArtistColor: string; - trackNameColor: string; -} - -export interface StylesProps extends Partial {} - -export interface StyledComponentProps { - children?: React.ReactNode; - styles: StylesOptions; - [key: string]: any; -} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..702a672 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { PlayerTrack, WebPlaybackTrack } from './spotify'; + +export interface CallbackState extends State { + type: string; +} + +export interface Props { + autoPlay?: boolean; + callback?: (state: CallbackState) => any; + name?: string; + offset?: number; + persistDeviceSelection?: boolean; + showSaveIcon?: boolean; + syncExternalDeviceInterval?: number; + token: string; + styles?: StylesProps; + uris?: string | string[]; +} + +export interface State { + currentDeviceId: string; + deviceId: string; + error: string; + errorType: string; + isActive: boolean; + isMagnified: boolean; + isPlaying: boolean; + isUnsupported: boolean; + nextTracks: WebPlaybackTrack[]; + position: number; + previousTracks: WebPlaybackTrack[]; + progressMs?: number; + status: string; + track: PlayerTrack; + volume: number; +} + +export interface PlayOptions { + context_uri?: string; + uris?: string[]; +} + +export interface StylesOptions { + bgColor: string; + color: string; + errorColor: string; + height: number | string; + loaderSize: number | string; + rangeHandleColor: string; + rangeHandleBorderRadius: number | string; + rangeHeight: number; + rangeColor: string; + rangeTrackBorderRadius: number | string; + rangeTrackColor: string; + savedColor: string; + trackArtistColor: string; + trackNameColor: string; +} + +export interface StylesProps extends Partial {} + +export interface StyledComponentProps { + children?: React.ReactNode; + styles: StylesOptions; + [key: string]: any; +} diff --git a/src/types/spotify.d.ts b/src/types/spotify.ts similarity index 100% rename from src/types/spotify.d.ts rename to src/types/spotify.ts diff --git a/src/utils.ts b/src/utils.ts index c69b60f..b8a8d2d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,6 +25,12 @@ export const TYPE = { TRACK: 'track_update', }; +export function getSpotifyURIType(uri: string): string { + const [, type = ''] = uri.split(':'); + + return type; +} + export function isEqualArray(A?: any, B?: any) { if (!Array.isArray(A) || !Array.isArray(B) || A.length !== B.length) { return false; @@ -67,3 +73,19 @@ export function loadScript(attributes: ScriptAttributes): Promise { document.head.appendChild(script); }); } + +export function validateURI(input: string): boolean { + let isValid = false; + + /* istanbul ignore else */ + if (input && input.indexOf(':') > -1) { + const [key, type, id] = input.split(':'); + + /* istanbul ignore else */ + if (key && type && type !== 'user' && id && id.length === 22) { + isValid = true; + } + } + + return isValid; +} diff --git a/tsconfig.json b/tsconfig.json index 7f3aa2a..85c221f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,12 @@ "module": "commonjs", "moduleResolution": "node", "outDir": "./lib", + "removeComments": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, - "target": "es5" + "target": "es5", + "types": ["node", "react"] }, "include": ["src/**/*"] }