Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Playback controls footer #19

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions custom.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
declare module "*.svg" {
const content: string;
export default content;
declare module '*.svg' {
const content: any
export default content
}
126 changes: 86 additions & 40 deletions src/contexts/Spotify/PlaybackContext/PlaybackContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import React from 'react'
import useSWR from 'swr'
import { useSpotifyState } from '../ConfigContext/ConfigContext'
import log, { FLAVORS } from '../../../util/log'
import { SECOND } from '../../../util/time'
import { PLAYBACK_REFRESH_INTERVAL } from './constants'
import useOptimisticProgress, {
ProgressControls,
} from './useOptimisticProgress'

type PlaybackState = {
isPlaying: boolean
progressMs: number
selectedTrack?: SpotifyApi.TrackObjectFull
context: SpotifyApi.ContextObject
}

const PlaybackStateContext = React.createContext<PlaybackState | undefined>(
Expand All @@ -18,14 +19,26 @@ const PlaybackStateContext = React.createContext<PlaybackState | undefined>(
export const usePlaybackState = (): PlaybackState => {
const context = React.useContext(PlaybackStateContext)
if (!context) {
throw Error('Attempted to use SpotifyState without a provider!')
throw Error('Attempted to use PlaybackState without a provider!')
}
return context
}

const PlaybackProgressContext = React.createContext<
ProgressControls | undefined
>(undefined)

export const usePlaybackProgress = (): ProgressControls => {
const context = React.useContext(PlaybackProgressContext)
if (!context) {
throw Error('Attempted to use ProgressState without a provider!')
}
return context
}

type PlaybackActions = {
isSelectedTrack: (id: string) => boolean
playPauseTrack: (track: SpotifyApi.TrackObjectFull) => void
playPauseTrack: (track?: SpotifyApi.TrackObjectFull) => void
}

const PlaybackActionsContext = React.createContext<PlaybackActions | undefined>(
Expand All @@ -35,15 +48,11 @@ const PlaybackActionsContext = React.createContext<PlaybackActions | undefined>(
export const usePlaybackActions = (): PlaybackActions => {
const context = React.useContext(PlaybackActionsContext)
if (!context) {
throw Error('Attempted to use SpotifyActions without a provider!')
throw Error('Attempted to use PlaybackActions without a provider!')
}
return context
}

type Props = {
playlistUri: string
}

/**
* Polls a user's playback status. If none is found (e.g. user doesn't have app open elsewhere),
* the useEffect is triggered to use the current browser window as the active playback device.
Expand All @@ -57,9 +66,10 @@ const usePlayback = (): SpotifyApi.CurrentlyPlayingObject => {
playbackInstance: { device_id: thisDeviceId },
} = useSpotifyState()

const { data: playback } = useSWR('getMyCurrentPlaybackState', {
refreshInterval: 2 * SECOND,
})
const { data: playback } = useSWR<SpotifyApi.CurrentPlaybackResponse>(
'getMyCurrentPlaybackState',
{ refreshInterval: PLAYBACK_REFRESH_INTERVAL }
)

const initializePlaybackOnCurrentDevice = React.useCallback(async () => {
// https://doxdox.org/jmperez/spotify-web-api-js#src-spotify-web-api.js-constr.prototype.transfermyplayback
Expand All @@ -79,15 +89,18 @@ const usePlayback = (): SpotifyApi.CurrentlyPlayingObject => {
return playback
}

type Props = { playlistUri: string }

export const PlaybackProvider: React.FC<Props> = React.memo(
({ playlistUri, children }) => {
const playback = usePlayback()

const {
is_playing: serverIsPlaying,
progress_ms: progressMs,
progress_ms: serverProgressMs,
item: serverSelectedTrack,
context,
// TODO: there are more fields in here, such as `context`, which may
// be useful in the future
} = playback

const [selectedTrack, setSelectedTrack] = React.useState<
Expand All @@ -99,7 +112,10 @@ export const PlaybackProvider: React.FC<Props> = React.memo(
setOptimisticUpdateInProgress,
] = React.useState<boolean>(false)

const state = { isPlaying, progressMs, selectedTrack, context }
const playbackState = React.useMemo(() => ({ isPlaying, selectedTrack }), [
isPlaying,
selectedTrack,
])

const selectedTrackId = React.useMemo(() => selectedTrack?.id, [
selectedTrack,
Expand All @@ -108,13 +124,40 @@ export const PlaybackProvider: React.FC<Props> = React.memo(
(id: string) => selectedTrackId === id,
[selectedTrackId]
)
const isSelectedTrackInSync = serverSelectedTrack?.id === selectedTrackId

const { sdk } = useSpotifyState()

const optimisticProgressProps = React.useMemo(
() => ({
serverProgressMs,
serverSeekTo: sdk.seek,
isSelectedTrackInSync,
isPlaying,
}),
[isPlaying, isSelectedTrackInSync, serverProgressMs, sdk.seek]
)
const progressControls = useOptimisticProgress(optimisticProgressProps)
const {
setProgressMs,
setLastManuallyTriggeredClientUpdate,
} = progressControls

// since progress changes often, it could trigger a ton of unintended rerenders
// if placed in a dependency array; a ref is used instead.
const progressRef = React.useRef<number>(serverProgressMs)
React.useEffect(() => {
progressRef.current = serverProgressMs
}, [serverProgressMs])

// TODO: this needs to handle the followinng cases:
// 1) if no tracks are active at all, play first track in playlist
const playPauseTrack = React.useCallback(
(track: SpotifyApi.TrackObjectFull) => {
(track?: SpotifyApi.TrackObjectFull) => {
setOptimisticUpdateInProgress(true)

if (!track) track = selectedTrack

const trackIsSelected = isSelectedTrack(track.id)
const shouldPause = trackIsSelected && isPlaying
if (shouldPause) {
Expand All @@ -124,48 +167,48 @@ export const PlaybackProvider: React.FC<Props> = React.memo(
}

const shouldResume = trackIsSelected && !isPlaying
// either:
// 1. resume active track from current location, or
// 2. start from beginning (whether it's new track or current track)
const progressMs = shouldResume ? progressRef.current : 0
const playOptions: SpotifyApi.PlayParameterObject = {
// playlist URI
context_uri: playlistUri,
// track URI
offset: { uri: track.uri },
// either resume track or start from beginning
position_ms: shouldResume ? progressMs : 0,
position_ms: progressMs,
}
sdk.play(playOptions)
setIsPlaying(true)
setSelectedTrack(track)
setProgressMs(progressMs)
setLastManuallyTriggeredClientUpdate(Date.now())
},
[isPlaying, isSelectedTrack, playlistUri, progressMs, sdk]
[
selectedTrack,
isSelectedTrack,
isPlaying,
sdk,
playlistUri,
setProgressMs,
setLastManuallyTriggeredClientUpdate,
]
)

// console.log({
// server: serverSelectedTrack?.name,
// ui: selectedTrack?.name,
// optimisticUpdateInProgress,
// })

/**
* Handles race condition where cached server data is outdated
* compared to what user clicked
*/
React.useEffect(() => {
const selectedTrackInSync = serverSelectedTrack?.id === selectedTrackId
const playStateInSync = serverIsPlaying === isPlaying
const synced = selectedTrackInSync && playStateInSync

console.log({
serverIsPlaying,
isPlaying,
synced,
optimisticUpdateInProgress,
})
const synced = isSelectedTrackInSync && playStateInSync

// UI is source of truth; server will catch up
if (optimisticUpdateInProgress && !synced) return

// Server is source of truth; UI will catch up
if (!optimisticUpdateInProgress) {
// NOTE: having `&& !synced` here might mess things up
if (!optimisticUpdateInProgress && !synced) {
setSelectedTrack(serverSelectedTrack)
setIsPlaying(serverIsPlaying)
}
Expand All @@ -180,6 +223,7 @@ export const PlaybackProvider: React.FC<Props> = React.memo(
selectedTrackId,
serverIsPlaying,
isPlaying,
isSelectedTrackInSync,
])

const actions: PlaybackActions = React.useMemo(
Expand All @@ -188,10 +232,12 @@ export const PlaybackProvider: React.FC<Props> = React.memo(
)

return (
<PlaybackStateContext.Provider value={state}>
<PlaybackActionsContext.Provider value={actions}>
{children}
</PlaybackActionsContext.Provider>
<PlaybackStateContext.Provider value={playbackState}>
<PlaybackProgressContext.Provider value={progressControls}>
<PlaybackActionsContext.Provider value={actions}>
{children}
</PlaybackActionsContext.Provider>
</PlaybackProgressContext.Provider>
</PlaybackStateContext.Provider>
)
}
Expand Down
12 changes: 12 additions & 0 deletions src/contexts/Spotify/PlaybackContext/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SECOND } from '../../../util/time'

/**
* we'll receive the latest playback state from the server every 2 seconds.
* this means that a user taking actions directly from the Spotify client
* may not see any updates reflected in the browser for 2 seconds (worst case).
*
* unfortuately, this also means that we won't be able to scale up the # of
* concurrent users for this app until we have realtime streaming (since all
* connected clients will refresh every 2s).
*/
export const PLAYBACK_REFRESH_INTERVAL = 2 * SECOND
Loading