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;
+};