diff --git a/index.js b/index.js index a2c2733..2468dfe 100644 --- a/index.js +++ b/index.js @@ -6,11 +6,14 @@ import {AppRegistry} from 'react-native'; import App from './src/App'; import {name as appName} from './app.json'; import {AuthProvider} from './src/contexts/AuthContext'; +import {StrictMode} from 'react'; const Root = () => ( - - - + + + + + ); AppRegistry.registerComponent(appName, () => Root); diff --git a/src/App.tsx b/src/App.tsx index 2312e51..daddc8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import Header from './components/headers/Header'; type Screen = 'playlists' | 'tracks' | 'compare'; function App(): React.JSX.Element { - const isAuthenticated = useAuth(); + const {isAuthenticated} = useAuth(); const [selectedPlaylists, setSelectedPlaylists] = useState( [], ); @@ -60,7 +60,7 @@ function App(): React.JSX.Element { return ( <> -
+
{isAuthenticated && screen !== 'playlists' && ( )} diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx index 7c41dbe..edd8adc 100644 --- a/src/components/AuthButton.tsx +++ b/src/components/AuthButton.tsx @@ -1,7 +1,10 @@ import {Pressable, StyleSheet, Text} from 'react-native'; import {SPOTIFY_GREEN} from '../theme/colors'; import {useAuth} from '../contexts/AuthContext'; -import {AuthButtonProps} from '../types/SpotifyPlaylistProps'; + +type AuthButtonProps = { + clearAllPlaylists: () => void; +}; export default function AuthButton({clearAllPlaylists}: AuthButtonProps) { const {login, logout, isAuthenticated} = useAuth(); diff --git a/src/components/BackToPlaylistsButton.tsx b/src/components/BackToPlaylistsButton.tsx index 7382975..496fa7c 100644 --- a/src/components/BackToPlaylistsButton.tsx +++ b/src/components/BackToPlaylistsButton.tsx @@ -1,6 +1,9 @@ import {Pressable, StyleSheet, Text} from 'react-native'; import {SPOTIFY_GREEN} from '../theme/colors'; -import {BackToPlaylistsButtonProps} from '../types/SpotifyPlaylistProps'; + +type BackToPlaylistsButtonProps = { + clearAllPlaylists: () => void; +}; export default function BackToPlaylistsButton({ clearAllPlaylists, diff --git a/src/components/CompareTracks.tsx b/src/components/CompareTracks.tsx index 428b50b..9d45d16 100644 --- a/src/components/CompareTracks.tsx +++ b/src/components/CompareTracks.tsx @@ -1,58 +1,71 @@ -import {useState, useEffect, useMemo} from 'react'; -import {SpotifyTrack} from '../types/SpotifyPlaylist'; +import {useMemo} from 'react'; +import {SpotifyPlaylist, SpotifyTrack} from '../types/SpotifyPlaylist'; import Tracks from './Tracks'; -import {getPlaylistTracks} from '../services/trackService'; -import {useAuth} from '../contexts/AuthContext'; -import {SpotifyTracksComparisonProps} from '../types/SpotifyPlaylistProps'; import {View, StyleSheet} from 'react-native'; import {getSharedTracks} from '../utils/trackUtils'; import PlaylistHeader from './headers/PlaylistHeader'; import CompareTracksHeader from './headers/CompareTracksHeader'; +import ErrorMessage from './ErrorMessage'; +import {useFetchTracks} from '../hooks/useFetchSpotify'; +import Loader from './Loader'; + +type SpotifyTracksComparisonProps = { + selectedPlaylists: SpotifyPlaylist[]; +}; export default function TracksComparison({ selectedPlaylists, }: SpotifyTracksComparisonProps) { const [leftPlaylist, rightPlaylist] = selectedPlaylists; - const [leftTracks, setLeftTracks] = useState([]); - const [rightTracks, setRightTracks] = useState([]); - const {auth} = useAuth(); - const accessToken = auth?.accessToken; - const sharedTracks = useMemo( - () => getSharedTracks(leftTracks, rightTracks), - [leftTracks, rightTracks], - ); + const { + data: leftTracks, + error: leftError, + isLoading: leftIsLoading, + } = useFetchTracks(leftPlaylist.id); + const { + data: rightTracks, + error: rightError, + isLoading: rightIsLoading, + } = useFetchTracks(rightPlaylist.id); - useEffect(() => { - if (!accessToken || !leftPlaylist || !rightPlaylist) { - setLeftTracks([]); - setRightTracks([]); - return; - } - Promise.all([ - getPlaylistTracks(leftPlaylist.id, accessToken), - getPlaylistTracks(rightPlaylist.id, accessToken), - ]) - .then(([lData, rData]) => { - setLeftTracks(lData); - setRightTracks(rData); - }) - .catch(console.error); - }, [accessToken, leftPlaylist.id, rightPlaylist.id]); + const sharedTracks = useMemo(() => { + if (!leftTracks || !rightTracks) return null; + return getSharedTracks(leftTracks, rightTracks); + }, [leftTracks, rightTracks]); + + const renderCompareTracksColumn = ( + playlist: SpotifyPlaylist, + tracks: SpotifyTrack[] | null, + error: Error | null, + isLoading: boolean, + ) => ( + + + + + {tracks && } + + ); return ( <> - + + {sharedTracks && } - - - - - - - - + {renderCompareTracksColumn( + leftPlaylist, + leftTracks, + leftError, + leftIsLoading, + )} + {renderCompareTracksColumn( + rightPlaylist, + rightTracks, + rightError, + rightIsLoading, + )} ); diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..9bdfe22 --- /dev/null +++ b/src/components/ErrorMessage.tsx @@ -0,0 +1,27 @@ +import {View, Text, StyleSheet} from 'react-native'; + +type ErrorMessageProps = { + error: Error | null; +}; + +export default function ErrorMessage({error}: ErrorMessageProps) { + if (!error) return null; + return ( + + {error.message} + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 8, + backgroundColor: '#fdecea', // light red + borderRadius: 4, + marginVertical: 4, + }, + text: { + color: '#b00020', // dark red + fontSize: 14, + }, +}); diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 0000000..de3a0d9 --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,13 @@ +import {ActivityIndicator} from 'react-native'; +import {SPOTIFY_GREEN} from '../theme/colors'; + +type LoaderProps = { + isLoading: boolean; + size?: 'small' | 'large'; +}; + +export default function Loader({isLoading, size = 'large'}: LoaderProps) { + return ( + <>{isLoading && } + ); +} diff --git a/src/components/Playlist.tsx b/src/components/Playlist.tsx index d2f776b..75c1cf3 100644 --- a/src/components/Playlist.tsx +++ b/src/components/Playlist.tsx @@ -1,6 +1,13 @@ import {Image, Pressable, StyleSheet, Text, View} from 'react-native'; import {PLAYLIST_BG} from '../theme/colors'; -import {SpotifyPlaylistProps} from '../types/SpotifyPlaylistProps'; +import {SpotifyPlaylistActions} from '../types/SpotifyPlaylistProps'; +import {SpotifyPlaylist} from '../types/SpotifyPlaylist'; +import {getUriFromImages} from '../utils/spotifyImages'; + +type SpotifyPlaylistProps = SpotifyPlaylistActions & { + spotifyPlaylist: SpotifyPlaylist; + isSelected: boolean; +}; export default function Playlist({ spotifyPlaylist, @@ -8,6 +15,7 @@ export default function Playlist({ updateSelectedPlaylists, focusPlaylist, }: SpotifyPlaylistProps) { + const imageUri = getUriFromImages(spotifyPlaylist.images); const getBackgroundColor = (pressed: boolean) => { if (pressed) return PLAYLIST_BG.pressed; return isSelected ? PLAYLIST_BG.selected : PLAYLIST_BG.default; @@ -22,10 +30,9 @@ export default function Playlist({ }, ]}> - + {imageUri && ( + + )} {spotifyPlaylist.name} {spotifyPlaylist.description} diff --git a/src/components/PlaylistTracks.tsx b/src/components/PlaylistTracks.tsx index c8bc4ea..022173f 100644 --- a/src/components/PlaylistTracks.tsx +++ b/src/components/PlaylistTracks.tsx @@ -1,32 +1,24 @@ -import {useEffect, useState} from 'react'; -import {SpotifyPlaylist, SpotifyTrack} from '../types/SpotifyPlaylist'; -import {getPlaylistTracks} from '../services/trackService'; -import {useAuth} from '../contexts/AuthContext'; +import {SpotifyPlaylist} from '../types/SpotifyPlaylist'; import Tracks from './Tracks'; import {View, StyleSheet} from 'react-native'; import PlaylistHeader from './headers/PlaylistHeader'; +import ErrorMessage from './ErrorMessage'; +import {useFetchTracks} from '../hooks/useFetchSpotify'; +import Loader from './Loader'; type PlaylistTracksProps = { spotifyPlaylist: SpotifyPlaylist; }; export default function PlaylistTracks({spotifyPlaylist}: PlaylistTracksProps) { - const [tracks, setTracks] = useState([]); - const {auth} = useAuth(); - const accessToken = auth?.accessToken; - - useEffect(() => { - if (!accessToken || !spotifyPlaylist) return; - - getPlaylistTracks(spotifyPlaylist.id, accessToken) - .then(setTracks) - .catch(console.error); - }, [accessToken, spotifyPlaylist.id]); + const {data: tracks, error, isLoading} = useFetchTracks(spotifyPlaylist.id); return ( - + + + {tracks && } ); } diff --git a/src/components/Playlists.tsx b/src/components/Playlists.tsx index fdc51ea..3be4aa4 100644 --- a/src/components/Playlists.tsx +++ b/src/components/Playlists.tsx @@ -1,44 +1,42 @@ -import {useEffect, useState} from 'react'; import {SpotifyPlaylist} from '../types/SpotifyPlaylist'; -import {SpotifyPlaylistsProps} from '../types/SpotifyPlaylistProps'; -import {getPlaylists} from '../services/playlistService'; +import {SpotifyPlaylistActions} from '../types/SpotifyPlaylistProps'; import Playlist from './Playlist'; import Header from './headers/Header'; -import {useAuth} from '../contexts/AuthContext'; import {FlatList} from 'react-native'; +import ErrorMessage from './ErrorMessage'; +import {useFetchPlaylists} from '../hooks/useFetchSpotify'; +import Loader from './Loader'; + +type SpotifyPlaylistsProps = SpotifyPlaylistActions & { + selectedPlaylists: SpotifyPlaylist[]; +}; export default function Playlists({ selectedPlaylists, updateSelectedPlaylists, focusPlaylist, }: SpotifyPlaylistsProps) { - const [playlists, setPlaylists] = useState(null); - const {auth} = useAuth(); - const accessToken = auth?.accessToken; - - useEffect(() => { - if (!accessToken) { - setPlaylists(null); - return; - } - getPlaylists(accessToken).then(setPlaylists).catch(console.error); - }, [accessToken]); + const {data: playlists, error, isLoading} = useFetchPlaylists(); return ( <> -
- ( - p.id === item.id)} - updateSelectedPlaylists={updateSelectedPlaylists} - focusPlaylist={focusPlaylist} - /> - )} - keyExtractor={item => item.id} - /> +
+ + + {playlists && ( + ( + p.id === item.id)} + updateSelectedPlaylists={updateSelectedPlaylists} + focusPlaylist={focusPlaylist} + /> + )} + keyExtractor={item => item.id} + /> + )} ); } diff --git a/src/components/Track.tsx b/src/components/Track.tsx index 80f9619..04345f9 100644 --- a/src/components/Track.tsx +++ b/src/components/Track.tsx @@ -1,5 +1,11 @@ import {Image, StyleSheet, Text, View} from 'react-native'; -import {SpotifyTrackProps} from '../types/SpotifyPlaylistProps'; +import {SpotifyTrack} from '../types/SpotifyPlaylist'; +import {getUriFromImages} from '../utils/spotifyImages'; + +type SpotifyTrackProps = { + spotifyTrack: SpotifyTrack; + size?: 'small' | 'large'; +}; const IMAGE_SIZE_MAP = { small: 65, @@ -10,13 +16,16 @@ export default function Track({ spotifyTrack, size = 'large', }: SpotifyTrackProps) { + const imageUri = getUriFromImages(spotifyTrack.album.images); const imageSize = IMAGE_SIZE_MAP[size]; return ( - + {imageUri && ( + + )} {spotifyTrack.name} {spotifyTrack.artists[0].name} diff --git a/src/components/Tracks.tsx b/src/components/Tracks.tsx index b193e3c..e960e4b 100644 --- a/src/components/Tracks.tsx +++ b/src/components/Tracks.tsx @@ -1,7 +1,12 @@ +import {SpotifyTrack} from '../types/SpotifyPlaylist'; import Track from './Track'; -import {SpotifyTracksProps} from '../types/SpotifyPlaylistProps'; import {FlatList} from 'react-native'; +type SpotifyTracksProps = { + spotifyTracks: SpotifyTrack[]; + size?: 'small' | 'large'; +}; + export default function Tracks({ spotifyTracks, size = 'large', diff --git a/src/components/headers/CompareTracksHeader.tsx b/src/components/headers/CompareTracksHeader.tsx index 3129cff..8c28e9f 100644 --- a/src/components/headers/CompareTracksHeader.tsx +++ b/src/components/headers/CompareTracksHeader.tsx @@ -1,5 +1,10 @@ -import {CompareTracksHeaderProps} from '../../types/SpotifyPlaylistProps'; +import {SpotifyPlaylist} from '../../types/SpotifyPlaylist'; import Header from './Header'; +import {getUriFromImages} from '../../utils/spotifyImages'; + +type CompareTracksHeaderProps = { + spotifyPlaylists: SpotifyPlaylist[]; +}; export default function CompareTracksHeader({ spotifyPlaylists, @@ -7,7 +12,7 @@ export default function CompareTracksHeader({ return (
${spotifyPlaylists[1].name}`} - images={spotifyPlaylists.map(p => p.images[0].url)} + imageUris={spotifyPlaylists.map(p => getUriFromImages(p.images))} /> ); } diff --git a/src/components/headers/Header.tsx b/src/components/headers/Header.tsx index a328712..3e54967 100644 --- a/src/components/headers/Header.tsx +++ b/src/components/headers/Header.tsx @@ -1,13 +1,20 @@ import {StyleSheet, Text, View, Image} from 'react-native'; -import {HeaderProps} from '../../types/SpotifyPlaylistProps'; -export default function Header({title, images}: HeaderProps) { +type HeaderProps = { + title: string; + imageUris: Array; +}; + +export default function Header({title, imageUris}: HeaderProps) { + const validUris = imageUris + .map(uri => uri?.trim()) + .filter((uri): uri is string => !!uri); return ( - {images.map((image, index) => ( + {validUris.map((uri, index) => ( - + ))} diff --git a/src/components/headers/PlaylistHeader.tsx b/src/components/headers/PlaylistHeader.tsx index bff5b0e..d36fb57 100644 --- a/src/components/headers/PlaylistHeader.tsx +++ b/src/components/headers/PlaylistHeader.tsx @@ -1,11 +1,16 @@ -import {PlaylistHeaderProps} from '../../types/SpotifyPlaylistProps'; +import {SpotifyPlaylist} from '../../types/SpotifyPlaylist'; +import {getUriFromImages} from '../../utils/spotifyImages'; import Header from './Header'; +type PlaylistHeaderProps = { + spotifyPlaylist: SpotifyPlaylist; +}; + export default function PlaylistHeader({spotifyPlaylist}: PlaylistHeaderProps) { return (
); } diff --git a/src/config/spotifyAuthConfig.ts b/src/config/spotifyAuthConfig.ts index a895592..6ddd92a 100644 --- a/src/config/spotifyAuthConfig.ts +++ b/src/config/spotifyAuthConfig.ts @@ -1,6 +1,6 @@ const spotifyAuthConfig = { issuer: 'https://accounts.spotify.com', - clientId: 'e653508634504dbe874915f12cfee829', + clientId: '611fa35d48e94daaa0d828a3c14a535f', redirectUrl: 'com.supersetlist://callback', scopes: ['user-read-email', 'playlist-read-private'], serviceConfiguration: { diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index b4da937..198ed90 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,12 +1,16 @@ -import {createContext, useContext, useState, ReactNode} from 'react'; -import {SpotifyAuth} from '../types/SpotifyAuth'; -import {handleSpotifyAuth} from '../services/authService'; +import {createContext, useContext, useState, useRef, ReactNode} from 'react'; +import {SpotifyAuth, SpotifyRefreshResponse} from '../types/SpotifyAuth'; +import {handleSpotifyAuth, getRefreshToken} from '../services/authService'; +import { + isValidToken, + getUpdatedAuthFromRefreshResponse, +} from '../utils/authToken'; interface AuthContextType { - auth: SpotifyAuth | null; - login: () => void | Promise; - logout: () => void; isAuthenticated: boolean; + getValidAccessToken: () => Promise; + login: () => Promise; + logout: () => void; } interface AuthProviderProps { @@ -17,6 +21,38 @@ const AuthContext = createContext(undefined); export const AuthProvider = ({children}: AuthProviderProps) => { const [auth, setAuth] = useState(null); + const refreshTokenPromiseRef = useRef | null>(null); + + const handleTokenRefresh = async (authSnapshot: SpotifyAuth) => { + try { + const response: SpotifyRefreshResponse = await getRefreshToken( + authSnapshot, + ); + const updatedAuth = getUpdatedAuthFromRefreshResponse( + authSnapshot, + response, + ); + setAuth(updatedAuth); + return updatedAuth.accessToken; + } catch (err) { + setAuth(null); + throw err; + } finally { + refreshTokenPromiseRef.current = null; + } + }; + + const getValidAccessToken = async () => { + if (!auth) return null; + if (isValidToken(auth)) return auth.accessToken; + // If a refresh is already in progress, return the same promise + // (Prevents concurrent requests from triggering multiple getRefreshToken() calls) + if (refreshTokenPromiseRef.current) return refreshTokenPromiseRef.current; + // Initiate a refresh and store the promise for other callers to await + // (After it's completion, handleTokenRefresh clears the ref) + refreshTokenPromiseRef.current = handleTokenRefresh(auth); + return refreshTokenPromiseRef.current; + }; const login = async () => { try { @@ -24,18 +60,18 @@ export const AuthProvider = ({children}: AuthProviderProps) => { setAuth(authData); } catch (err) { console.error('Spotify login failed: ', err); + setAuth(null); } }; const logout = () => setAuth(null); - // TODO: isAuthenticated currently only checks for accessToken presence. - // Token expiry is not validated, so expired tokens may be treated as authenticated - // until this is addressed. - const isAuthenticated = !!auth?.accessToken; + // "authenticated" is having (or being able to obtain) a valid access token + const isAuthenticated = !!auth?.accessToken || !!auth?.refreshToken; return ( - + {children} ); diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts new file mode 100644 index 0000000..7a65780 --- /dev/null +++ b/src/hooks/useFetch.ts @@ -0,0 +1,54 @@ +import {useState, useEffect} from 'react'; +import {useAuth} from '../contexts/AuthContext'; + +type FetchState = { + data: T | null; + error: Error | null; + isLoading: boolean; +}; + +export function useFetch( + fetchFn: (accessToken: string, signal: AbortSignal) => Promise, + deps: any[] = [], +) { + const [fetchState, setFetchState] = useState>({ + data: null, + error: null, + isLoading: false, + }); + const {isAuthenticated, getValidAccessToken} = useAuth(); + + useEffect(() => { + const controller = new AbortController(); + const signal = controller.signal; + + const handleFetch = async () => { + setFetchState({data: null, error: null, isLoading: true}); + try { + const accessToken = await getValidAccessToken(); + if (!accessToken) { + setFetchState({data: null, error: null, isLoading: false}); + return; + } + const data = await fetchFn(accessToken, signal); + setFetchState({data, error: null, isLoading: false}); + } catch (error: any) { + if (signal.aborted) return; + setFetchState({data: null, error, isLoading: false}); + } + }; + + if (!isAuthenticated) { + setFetchState({data: null, error: null, isLoading: false}); + return () => controller.abort(); + } + + handleFetch(); + + return () => { + controller.abort(); + }; + }, [isAuthenticated, ...deps]); + + return fetchState; +} diff --git a/src/hooks/useFetchSpotify.ts b/src/hooks/useFetchSpotify.ts new file mode 100644 index 0000000..37b41e7 --- /dev/null +++ b/src/hooks/useFetchSpotify.ts @@ -0,0 +1,18 @@ +import {getPlaylists} from '../services/playlistService'; +import {getPlaylistTracks} from '../services/trackService'; +import {SpotifyPlaylist, SpotifyTrack} from '../types/SpotifyPlaylist'; +import {useFetch} from './useFetch'; + +export const useFetchPlaylists = () => { + return useFetch( + (accessToken, signal) => getPlaylists(accessToken, signal), + [], + ); +}; + +export const useFetchTracks = (playlistId: string) => { + return useFetch( + (accessToken, signal) => getPlaylistTracks(playlistId, accessToken, signal), + [playlistId], + ); +}; diff --git a/src/services/authService.ts b/src/services/authService.ts index 3246d6e..f189c0d 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,6 +1,31 @@ import {authorize} from 'react-native-app-auth'; import spotifyAuthConfig from '../config/spotifyAuthConfig'; -import {SpotifyAuth} from '../types/SpotifyAuth'; +import {SpotifyAuth, SpotifyRefreshResponse} from '../types/SpotifyAuth'; + +const REFRESH_TOKEN_URL = 'https://accounts.spotify.com/api/token'; + +// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens +export const getRefreshToken = async ( + auth: SpotifyAuth, +): Promise => { + const response = await fetch(REFRESH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: spotifyAuthConfig.clientId, + }).toString(), + }); + + if (!response.ok) { + throw new Error(`Spotify token refresh failed: ${response.status}`); + } + + return response.json(); +}; export const handleSpotifyAuth = async (): Promise => { try { diff --git a/src/services/playlistService.ts b/src/services/playlistService.ts index 5f76f9c..8721658 100644 --- a/src/services/playlistService.ts +++ b/src/services/playlistService.ts @@ -7,8 +7,13 @@ import { export const getPlaylists = async ( accessToken: string, + signal?: AbortSignal, ): Promise => { const url = `${SPOTIFY_API_BASE_URL}/me/playlists`; - const data: SpotifyPlaylistResponse = await spotifyFetch(url, accessToken); + const data: SpotifyPlaylistResponse = await spotifyFetch( + url, + accessToken, + signal, + ); return data.items; // data.items == SpotifyPlaylist[] }; diff --git a/src/services/spotifyApi.ts b/src/services/spotifyApi.ts index c23f80e..1340784 100644 --- a/src/services/spotifyApi.ts +++ b/src/services/spotifyApi.ts @@ -1,11 +1,13 @@ export const spotifyFetch = async ( url: string, accessToken: string, + signal?: AbortSignal, ): Promise => { const response = await fetch(url, { headers: { Authorization: 'Bearer ' + accessToken, }, + signal, }); if (!response.ok) { const error = `Spotify API error: ${response.status} ${response.statusText}`; diff --git a/src/services/trackService.ts b/src/services/trackService.ts index 55a31c4..d167ccd 100644 --- a/src/services/trackService.ts +++ b/src/services/trackService.ts @@ -5,8 +5,13 @@ import {SpotifyTrack, SpotifyTrackResponse} from '../types/SpotifyPlaylist'; export const getPlaylistTracks = async ( playlist_id: string, accessToken: string, + signal?: AbortSignal, ): Promise => { - const url = `${SPOTIFY_API_BASE_URL}/playlists/${playlist_id}/tracks`; - const data: SpotifyTrackResponse = await spotifyFetch(url, accessToken); - return data.items.map(item => item.track); + const url = `${SPOTIFY_API_BASE_URL}/playlists/${playlist_id}/items`; + const data: SpotifyTrackResponse = await spotifyFetch( + url, + accessToken, + signal, + ); + return data.items.map(i => i.item); }; diff --git a/src/types/SpotifyAuth.ts b/src/types/SpotifyAuth.ts index e80ae9c..e61d2b5 100644 --- a/src/types/SpotifyAuth.ts +++ b/src/types/SpotifyAuth.ts @@ -8,3 +8,12 @@ export type SpotifyAuth = { tokenAdditionalParameters?: Record; tokenType: string; }; + +// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens +export type SpotifyRefreshResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + scope: string; +}; diff --git a/src/types/SpotifyPlaylist.ts b/src/types/SpotifyPlaylist.ts index 8f1351e..1e1d7fe 100644 --- a/src/types/SpotifyPlaylist.ts +++ b/src/types/SpotifyPlaylist.ts @@ -7,11 +7,13 @@ export interface SpotifyPlaylistResponse { export interface SpotifyTrackResponse { items: { - track: SpotifyTrack; + item: SpotifyTrack; }[]; total: number; } +export type SpotifyImages = {url: string}[] | null; + export interface SpotifyPlaylist { id: string; external_urls: { @@ -23,9 +25,7 @@ export interface SpotifyPlaylist { href: string; total: number; }; - images: { - url: string; - }[]; + images: SpotifyImages; } export interface SpotifyTrack { diff --git a/src/types/SpotifyPlaylistProps.ts b/src/types/SpotifyPlaylistProps.ts index 0886e8b..03a4914 100644 --- a/src/types/SpotifyPlaylistProps.ts +++ b/src/types/SpotifyPlaylistProps.ts @@ -1,50 +1,6 @@ -import {SpotifyPlaylist, SpotifyTrack} from './SpotifyPlaylist'; +import {SpotifyPlaylist} from './SpotifyPlaylist'; -type SpotifyPlaylistActions = { +export type SpotifyPlaylistActions = { updateSelectedPlaylists: (p: SpotifyPlaylist) => void; focusPlaylist: (p: SpotifyPlaylist) => void; }; - -export type SpotifyPlaylistsProps = SpotifyPlaylistActions & { - selectedPlaylists: SpotifyPlaylist[]; -}; - -export type SpotifyPlaylistProps = SpotifyPlaylistActions & { - spotifyPlaylist: SpotifyPlaylist; - isSelected: boolean; -}; - -export type SpotifyTracksProps = { - spotifyTracks: SpotifyTrack[]; - size?: 'small' | 'large'; -}; - -export type SpotifyTracksComparisonProps = { - selectedPlaylists: SpotifyPlaylist[]; -}; - -export type SpotifyTrackProps = { - spotifyTrack: SpotifyTrack; - size?: 'small' | 'large'; -}; - -export type AuthButtonProps = { - clearAllPlaylists: () => void; -}; - -export type BackToPlaylistsButtonProps = { - clearAllPlaylists: () => void; -}; - -export type HeaderProps = { - title: string; - images: string[]; -}; - -export type PlaylistHeaderProps = { - spotifyPlaylist: SpotifyPlaylist; -}; - -export type CompareTracksHeaderProps = { - spotifyPlaylists: SpotifyPlaylist[]; -}; diff --git a/src/utils/authToken.ts b/src/utils/authToken.ts new file mode 100644 index 0000000..7493a88 --- /dev/null +++ b/src/utils/authToken.ts @@ -0,0 +1,30 @@ +import {SpotifyAuth, SpotifyRefreshResponse} from '../types/SpotifyAuth'; + +// For addressing edge cases +// - (i.e. when a request fires right at token expiry time) +const ACCESS_TOKEN_EXPIRY_BUFFER = 15_000; // 15-sec + +export const isValidToken = (auth: SpotifyAuth): boolean => { + const expiry = new Date(auth.accessTokenExpirationDate).getTime(); + if (!Number.isFinite(expiry)) return false; + return expiry > Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER; +}; + +export const getAccessTokenExpirationDate = (expires_in: number) => { + return new Date(Date.now() + expires_in * 1000).toISOString(); +}; + +// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens +export const getUpdatedAuthFromRefreshResponse = ( + auth: SpotifyAuth, + response: SpotifyRefreshResponse, +) => { + return { + ...auth, + accessToken: response.access_token, + refreshToken: response.refresh_token ?? auth.refreshToken, + accessTokenExpirationDate: getAccessTokenExpirationDate( + response.expires_in, + ), + }; +}; diff --git a/src/utils/spotifyImages.ts b/src/utils/spotifyImages.ts new file mode 100644 index 0000000..01b0fc4 --- /dev/null +++ b/src/utils/spotifyImages.ts @@ -0,0 +1,5 @@ +import {SpotifyImages} from '../types/SpotifyPlaylist'; + +export const getUriFromImages = (images: SpotifyImages): string | null => { + return images?.[0]?.url?.trim() || null; +};