From 8e2d5afce867b94ff86474e24de6a057fb3ab56c Mon Sep 17 00:00:00 2001 From: MarcoMadera Date: Fri, 15 Sep 2023 23:43:35 -0700 Subject: [PATCH] will throw error --- components/CardTrack/CardTrack.tsx | 79 +++--- .../CardTrack/__tests__/CardTrack.spec.tsx | 13 +- components/ContextMenu/ContextMenu.tsx | 96 +++---- components/EpisodeCard/EpisodeCard.tsx | 102 +++++--- components/PortalTarget/PortalTarget.ts | 18 ++ components/PortalTarget/index.ts | 2 + .../RemoveTracksModal/RemoveTracksModal.tsx | 10 +- components/Toast/Toast.tsx | 69 +++-- components/index.ts | 1 + context/SpotifyContext.tsx | 18 +- hooks/index.ts | 1 + hooks/useContextMenu.ts | 11 +- hooks/useCustomContext.ts | 14 + hooks/useLyricsContext.ts | 11 +- hooks/useModal.ts | 10 +- hooks/useToast.ts | 11 +- hooks/useTranslations.ts | 12 +- layouts/playlist/utils.ts | 31 +-- types/spotify.ts | 2 +- utils/__tests__/playCurrentTrack.spec.ts | 246 +++++------------- utils/errors.ts | 50 ++++ utils/index.ts | 1 + utils/playCurrentTrack.ts | 71 +++-- utils/spotifyCalls/callSpotifyApi.ts | 17 ++ 24 files changed, 428 insertions(+), 468 deletions(-) create mode 100644 components/PortalTarget/PortalTarget.ts create mode 100644 components/PortalTarget/index.ts create mode 100644 hooks/useCustomContext.ts create mode 100644 utils/errors.ts diff --git a/components/CardTrack/CardTrack.tsx b/components/CardTrack/CardTrack.tsx index fdcfef4e..a71c3329 100644 --- a/components/CardTrack/CardTrack.tsx +++ b/components/CardTrack/CardTrack.tsx @@ -24,6 +24,7 @@ import { import { ITrack } from "types/spotify"; import { ContentType, + handlePlayCurrentTrackError, playCurrentTrack, templateReplace, ToastMessage, @@ -136,50 +137,40 @@ function CardTrack({ "[index] 55px [first] 14fr [popularity] 1fr [last] minmax(60px,1fr)", }; - function playThisTrack() { - playCurrentTrack(track, { - allTracks, - player, - user, - accessToken, - deviceId, - playlistUri, - playlistId: pageDetails?.id, - setCurrentlyPlaying, - setPlaylistPlayingId, - isSingleTrack, - position, - setAccessToken, - uri, - uris, - }).then((status) => { - if (status === 404) { - (player as Spotify.Player).disconnect(); - addToast({ - variant: "error", - message: translations[ToastMessage.UnableToPlayReconnecting], - }); - setReconnectionError(true); - } - if (status === 200) { - const source = pageDetails?.uri; - const isCollection = source?.split(":")?.[3]; - setPlayedSource( - isCollection && pageDetails?.type && pageDetails?.id - ? `spotify:${pageDetails.type}:${pageDetails.id}` - : source ?? track?.uri - ); - } - if (status === 400) { - addToast({ - variant: "error", - message: templateReplace( - translations[ToastMessage.ErrorPlayingThis], - [translations[ContentType.Track]] - ), - }); - } - }); + async function playThisTrack() { + try { + const playlistPlayingId = await playCurrentTrack(track, { + allTracks, + player, + user, + accessToken, + deviceId, + playlistUri, + playlistId: pageDetails?.id, + setCurrentlyPlaying, + isSingleTrack, + position, + setAccessToken, + uri, + uris, + }); + + const source = pageDetails?.uri; + const isCollection = source?.split(":")?.[3]; + setPlaylistPlayingId(playlistPlayingId); + setPlayedSource( + isCollection && pageDetails?.type && pageDetails?.id + ? `spotify:${pageDetails.type}:${pageDetails.id}` + : source ?? track?.uri + ); + } catch (error) { + handlePlayCurrentTrackError(error, { + addToast, + player: player as Spotify.Player, + setReconnectionError, + translations, + }); + } } const id = useId(); diff --git a/components/CardTrack/__tests__/CardTrack.spec.tsx b/components/CardTrack/__tests__/CardTrack.spec.tsx index 8d59d2d9..1f2bb8bf 100644 --- a/components/CardTrack/__tests__/CardTrack.spec.tsx +++ b/components/CardTrack/__tests__/CardTrack.spec.tsx @@ -8,7 +8,12 @@ import { IUserContext } from "context/UserContext"; import { useOnScreen } from "hooks"; import { IUtilsMocks } from "types/mocks"; import { ISpotifyContext } from "types/spotify"; -import { Language, playCurrentTrack } from "utils"; +import { + BadRequestError, + Language, + NotFoundError, + playCurrentTrack, +} from "utils"; const { track, getAllTranslations } = jest.requireActual( "utils/__tests__/__mocks__/mocks.ts" @@ -70,7 +75,7 @@ describe("cardTrack", () => { it("should play on double click", async () => { expect.assertions(2); (useOnScreen as jest.Mock).mockImplementationOnce(() => true); - (playCurrentTrack as jest.Mock>).mockResolvedValue(200); + (playCurrentTrack as jest.Mock>).mockResolvedValue("id"); const setPlayedSource = jest.fn(); const player = { getCurrentState: jest.fn().mockResolvedValue({ position: 0 }), @@ -113,7 +118,7 @@ describe("cardTrack", () => { it("should add toast reconnection error", async () => { expect.assertions(4); (useOnScreen as jest.Mock).mockImplementationOnce(() => true); - (playCurrentTrack as jest.Mock>).mockResolvedValue(404); + (playCurrentTrack as jest.Mock).mockRejectedValue(new NotFoundError()); const player = { disconnect: jest.fn(), activateElement: jest.fn().mockResolvedValue(200), @@ -155,7 +160,7 @@ describe("cardTrack", () => { it("should add toast with error playing this track", async () => { expect.assertions(1); (useOnScreen as jest.Mock).mockImplementationOnce(() => true); - (playCurrentTrack as jest.Mock>).mockResolvedValue(400); + (playCurrentTrack as jest.Mock).mockRejectedValue(new BadRequestError()); const setReconnectionError = jest.fn(); const player = { activateElement: jest.fn().mockResolvedValue(200), diff --git a/components/ContextMenu/ContextMenu.tsx b/components/ContextMenu/ContextMenu.tsx index 22910657..fea10d95 100644 --- a/components/ContextMenu/ContextMenu.tsx +++ b/components/ContextMenu/ContextMenu.tsx @@ -1,25 +1,20 @@ -import { - ReactPortal, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; - -import { createPortal } from "react-dom"; +import { ReactElement, useLayoutEffect, useRef, useState } from "react"; -import { CardContentContextMenu, CardTrackContextMenu } from "components"; -import { ICardContentContextMenu } from "components/CardContentContextMenu"; +import { + CardContentContextMenu, + CardTrackContextMenu, + PortalTarget, +} from "components"; +import type { ICardContentContextMenu } from "components/CardContentContextMenu"; import { useContextMenu, useEventListener } from "hooks"; -import { ITrack } from "types/spotify"; +import type { ITrack } from "types/spotify"; import { calculateContextMenuPosition, CONTEXT_MENU_SIDE_OFFSET, positionContextMenu, } from "utils"; -export default function ContextMenu(): ReactPortal | null { - const [targetNode, setTargetNode] = useState(); +export default function ContextMenu(): ReactElement | null { const { contextMenuData, removeContextMenu } = useContextMenu(); const [contextMenuPos, setContextMenuPos] = useState({ x: (contextMenuData?.position.x ?? 0) - 30, @@ -31,10 +26,6 @@ export default function ContextMenu(): ReactPortal | null { }); const contextMenuRef = useRef(null); - useEffect(() => { - setTargetNode(document.querySelector("#contextMenu")); - }, []); - useEventListener({ target: document.querySelector("#__next"), type: "click", @@ -53,11 +44,7 @@ export default function ContextMenu(): ReactPortal | null { }); }, [contextMenuData]); - if (targetNode === null) { - throw new Error("ContextMenu needs a target element with id: contextMenu"); - } - - if (targetNode === undefined || !contextMenuData) { + if (!contextMenuData) { return null; } @@ -74,36 +61,37 @@ export default function ContextMenu(): ReactPortal | null { CONTEXT_MENU_SIDE_OFFSET ); - return createPortal( -
- {contextMenuData.data?.type === "track" || - contextMenuData.data?.type === "episode" ? ( - - ) : ( - - )} - -
, - targetNode + return ( + +
+ {contextMenuData.data?.type === "track" || + contextMenuData.data?.type === "episode" ? ( + + ) : ( + + )} + +
+
); } diff --git a/components/EpisodeCard/EpisodeCard.tsx b/components/EpisodeCard/EpisodeCard.tsx index 02957721..ea74c1a8 100644 --- a/components/EpisodeCard/EpisodeCard.tsx +++ b/components/EpisodeCard/EpisodeCard.tsx @@ -17,6 +17,7 @@ import { formatTime, getSiteUrl, getTimeAgo, + handlePlayCurrentTrackError, playCurrentTrack, templateReplace, ToastMessage, @@ -48,6 +49,8 @@ export default function EpisodeCard({ pageDetails, setCurrentlyPlaying, setPlaylistPlayingId, + setReconnectionError, + setPlayedSource, } = useSpotify(); const { user, accessToken, setAccessToken } = useAuth(); const { addToast } = useToast(); @@ -72,6 +75,65 @@ export default function EpisodeCard({ if (!item) return null; + async function playThisTrack() { + try { + const playlistPlayingId = await playCurrentTrack( + { + album: { + id: show.id, + images: item.images ?? [], + name: show.name, + release_date: item.release_date, + type: "album", + uri: show.uri, + }, + artists: [ + { + name: show.name, + id: show.id, + type: "artist", + uri: `spotify:show:${show.id}`, + }, + ], + id: item.id, + name: item.name, + explicit: item.explicit ?? false, + type: "track", + uri: item.uri, + }, + { + player, + user, + allTracks, + accessToken, + deviceId, + playlistUri: pageDetails?.uri, + playlistId: pageDetails?.id, + setCurrentlyPlaying, + isSingleTrack: true, + position, + setAccessToken, + } + ); + + const source = pageDetails?.uri; + const isCollection = source?.split(":")?.[3]; + setPlaylistPlayingId(playlistPlayingId); + setPlayedSource( + isCollection && pageDetails?.type && pageDetails?.id + ? `spotify:${pageDetails.type}:${pageDetails.id}` + : source ?? item.uri + ); + } catch (error) { + handlePlayCurrentTrackError(error, { + addToast, + player: player as Spotify.Player, + setReconnectionError, + translations, + }); + } + } + return (
{ @@ -205,45 +267,7 @@ export default function EpisodeCard({ if (isPremium) { (player as Spotify.Player)?.activateElement(); } - playCurrentTrack( - { - album: { - id: show.id, - images: item.images ?? [], - name: show.name, - release_date: item.release_date, - type: "album", - uri: show.uri, - }, - artists: [ - { - name: show.name, - id: show.id, - type: "artist", - uri: `spotify:show:${show.id}`, - }, - ], - id: item.id, - name: item.name, - explicit: item.explicit ?? false, - type: "track", - uri: item.uri, - }, - { - player, - user, - allTracks, - accessToken, - deviceId, - playlistUri: pageDetails?.uri, - playlistId: pageDetails?.id, - setCurrentlyPlaying, - setPlaylistPlayingId, - isSingleTrack: true, - position, - setAccessToken, - } - ); + playThisTrack(); }} > {isThisEpisodePlaying && isPlaying ? : } diff --git a/components/PortalTarget/PortalTarget.ts b/components/PortalTarget/PortalTarget.ts new file mode 100644 index 00000000..d34bb013 --- /dev/null +++ b/components/PortalTarget/PortalTarget.ts @@ -0,0 +1,18 @@ +import { PropsWithChildren, ReactPortal } from "react"; + +import { createPortal } from "react-dom"; + +import { TargetElementError } from "utils"; + +export default function PortalTarget({ + children, + targetId, +}: PropsWithChildren<{ targetId: string }>): ReactPortal { + const targetElement = document.querySelector(`#${targetId}`); + + if (targetElement === null) { + throw new TargetElementError(targetId); + } + + return createPortal(children, targetElement); +} diff --git a/components/PortalTarget/index.ts b/components/PortalTarget/index.ts new file mode 100644 index 00000000..66a35c4b --- /dev/null +++ b/components/PortalTarget/index.ts @@ -0,0 +1,2 @@ +export { default } from "./PortalTarget"; +export * from "./PortalTarget"; diff --git a/components/RemoveTracksModal/RemoveTracksModal.tsx b/components/RemoveTracksModal/RemoveTracksModal.tsx index ef57790e..afbbc074 100644 --- a/components/RemoveTracksModal/RemoveTracksModal.tsx +++ b/components/RemoveTracksModal/RemoveTracksModal.tsx @@ -124,12 +124,8 @@ export default function RemoveTracksModal({ async function handleRemoveTracksFromPlaylist() { const indexes = [...new Set([...corruptedSongsIdx, ...duplicateTracksIdx])]; - const snapshot = await removeTracks( - pageDetails?.id, - indexes, - pageDetails?.snapshot_id - ); - if (snapshot) { + try { + await removeTracks(pageDetails?.id, indexes, pageDetails?.snapshot_id); setAllTracks((tracks) => { return tracks.filter((_, i) => { if (indexes.includes(i)) { @@ -152,7 +148,7 @@ export default function RemoveTracksModal({ translations[ContentType.Playlist], ]), }); - } else { + } catch (error) { addToast({ variant: "error", message: templateReplace( diff --git a/components/Toast/Toast.tsx b/components/Toast/Toast.tsx index 395e0054..1b3439ea 100644 --- a/components/Toast/Toast.tsx +++ b/components/Toast/Toast.tsx @@ -1,11 +1,9 @@ -import { ReactPortal, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; - -import { ToastCard } from "components"; +import { PortalTarget, ToastCard } from "components"; import { useToast } from "hooks/useToast"; -export default function Toast(): ReactPortal | null { +export default function Toast(): ReactElement | null { const [targetNode, setTargetNode] = useState(); const { toasts } = useToast(); @@ -14,41 +12,38 @@ export default function Toast(): ReactPortal | null { setTargetNode(document.querySelector("#toast")); }, []); - if (targetNode === null) { - throw new Error("Toast needs a target element with id: toast"); - } - if (targetNode === undefined || !toasts.length) { return null; } - return createPortal( -
- {toasts.map(({ id, variant, message, displayTime }) => ( - - ))} - -
, - targetNode + return ( + +
+ {toasts.map(({ id, variant, message, displayTime }) => ( + + ))} + +
+
); } diff --git a/components/index.ts b/components/index.ts index a1f24726..d1ed436f 100644 --- a/components/index.ts +++ b/components/index.ts @@ -73,3 +73,4 @@ export { default as UserWidget } from "./UserWidget"; export { default as VirtualizedList } from "./VirtualizedList"; export { default as VolumeControl } from "./VolumeControl"; export { default as LyricsPIPButton } from "./LyricsPIPButton"; +export { default as PortalTarget } from "./PortalTarget"; diff --git a/context/SpotifyContext.tsx b/context/SpotifyContext.tsx index 5c58a4bc..7755c094 100644 --- a/context/SpotifyContext.tsx +++ b/context/SpotifyContext.tsx @@ -25,6 +25,7 @@ import { ITrack, PlaylistItems, } from "types/spotify"; +import { RemoveTrackError } from "utils"; import { removeTracksFromPlaylist } from "utils/spotifyCalls"; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -115,20 +116,11 @@ export function SpotifyContextProvider({ tracks: number[], snapshotID: string | undefined ) => { - try { - const res = await removeTracksFromPlaylist( - playlist, - tracks, - snapshotID - ); - if (!res) { - throw Error("Failed to remove tracks"); - } - return res.snapshot_id; - } catch (err) { - console.warn(err); - return; + const res = await removeTracksFromPlaylist(playlist, tracks, snapshotID); + if (!res?.snapshot_id) { + throw new RemoveTrackError(); } + return res.snapshot_id; }, [] ); diff --git a/hooks/index.ts b/hooks/index.ts index 10947736..2db0b682 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -30,3 +30,4 @@ export * from "./useTranslations"; export * from "./useUserPlaylists"; export * from "./useLyricsInPictureInPicture"; export * from "./useLyricsContext"; +export * from "./useCustomContext"; diff --git a/hooks/useContextMenu.ts b/hooks/useContextMenu.ts index 1b469937..86c97a63 100644 --- a/hooks/useContextMenu.ts +++ b/hooks/useContextMenu.ts @@ -1,17 +1,12 @@ -import { useCallback, useContext } from "react"; +import { useCallback } from "react"; import ContextMenuContext from "context/ContextMenuContext"; +import { useCustomContext } from "hooks"; import type { UseContextMenu } from "types/contextMenu"; export function useContextMenu(): UseContextMenu { - const context = useContext(ContextMenuContext); - - if (context === undefined) { - throw new Error("useContextMenu must be used within a ContextMenuProvider"); - } - const { contextMenuData, setContextMenuData, setModalData, modalData } = - context; + useCustomContext(ContextMenuContext); const removeContextMenu: UseContextMenu["removeContextMenu"] = useCallback(() => { diff --git a/hooks/useCustomContext.ts b/hooks/useCustomContext.ts new file mode 100644 index 00000000..c85e360b --- /dev/null +++ b/hooks/useCustomContext.ts @@ -0,0 +1,14 @@ +import { Context, useContext } from "react"; + +import { ContextError } from "utils"; + +export function useCustomContext( + Context: Context +): NonNullable { + const context = useContext(Context); + if (!context) { + throw new ContextError(Context); + } + + return context; +} diff --git a/hooks/useLyricsContext.ts b/hooks/useLyricsContext.ts index 7a8c0852..138eea5f 100644 --- a/hooks/useLyricsContext.ts +++ b/hooks/useLyricsContext.ts @@ -1,13 +1,6 @@ -import { useContext } from "react"; - import LyricsContext, { ILyricsContext } from "context/LyricsContextProvider"; +import { useCustomContext } from "hooks"; export function useLyricsContext(): ILyricsContext { - const context = useContext(LyricsContext); - if (!context) - throw new Error( - "useLyricsContext must be used within a LyricsContextProvider" - ); - - return context; + return useCustomContext(LyricsContext); } diff --git a/hooks/useModal.ts b/hooks/useModal.ts index e9247584..47d2e8ca 100644 --- a/hooks/useModal.ts +++ b/hooks/useModal.ts @@ -1,12 +1,6 @@ -import { useContext } from "react"; - import ModalContext, { IModalContext } from "context/ModalContext"; +import { useCustomContext } from "hooks"; export function useModal(): IModalContext { - const context = useContext(ModalContext); - - if (!context) { - throw new Error("useModal must be used within a ModalContextProvider"); - } - return context; + return useCustomContext(ModalContext); } diff --git a/hooks/useToast.ts b/hooks/useToast.ts index c94ccbd9..fad3ffc7 100644 --- a/hooks/useToast.ts +++ b/hooks/useToast.ts @@ -1,18 +1,13 @@ -import { useCallback, useContext, useId } from "react"; +import { useCallback, useId } from "react"; import ToastContext from "context/ToastContext"; +import { useCustomContext } from "hooks"; import type { IToast, UseToast } from "types/toast"; export function useToast(): UseToast { - const context = useContext(ToastContext); + const { toasts, setToasts } = useCustomContext(ToastContext); const id = useId(); - if (context === undefined) { - throw new Error("useToast must be used within a ToastProvider"); - } - - const { toasts, setToasts } = context; - const removeToast: UseToast["removeToast"] = useCallback( (toastId) => { const timeOut = toasts.find((toast) => toast.id === toastId)?.timeOut; diff --git a/hooks/useTranslations.ts b/hooks/useTranslations.ts index a039eb0e..1b4be0f1 100644 --- a/hooks/useTranslations.ts +++ b/hooks/useTranslations.ts @@ -1,16 +1,8 @@ -import { useContext } from "react"; - import TranslationsContext, { TranslationsContextProviderProps, } from "context/TranslationsContext"; +import { useCustomContext } from "hooks"; export function useTranslations(): TranslationsContextProviderProps { - const context = useContext(TranslationsContext); - - if (!context) { - throw new Error( - "useTranslations must be used within a TranslationsContextProvider" - ); - } - return context; + return useCustomContext(TranslationsContext); } diff --git a/layouts/playlist/utils.ts b/layouts/playlist/utils.ts index f5ce5b93..c5e8022e 100644 --- a/layouts/playlist/utils.ts +++ b/layouts/playlist/utils.ts @@ -2,7 +2,7 @@ import { NextRouter } from "next/router"; import { IPageDetails, ITrack, PlaylistItems } from "types/spotify"; import { UseToast } from "types/toast"; -import { getAllMyPlaylists } from "utils"; +import { CreatePlaylistError, getAllMyPlaylists } from "utils"; import { addCustomPlaylistImage, addItemsToPlaylist, @@ -29,8 +29,7 @@ export async function handleSaveToPlaylistClick({ }): Promise { try { const playlist = await createCustomPlaylist({ - addToast, - accessToken: accessToken || "", + accessToken: accessToken ?? "", user, pageDetails, }); @@ -43,46 +42,42 @@ export async function handleSaveToPlaylistClick({ const uris = getTrackUris(allTracks); await addTracksToPlaylist({ - playlistId: playlist?.id || "", + playlistId: playlist?.id ?? "", uris, setPlaylists, accessToken, }); await navigateToPlaylistPage({ - playlistId: playlist?.id || "", + playlistId: playlist?.id ?? "", accessToken, router, addToast, }); - } catch (e) { - console.error(e); - addToast({ - message: "Error creating playlist", - variant: "error", - }); + } catch (error) { + if (CreatePlaylistError.isThisError(error)) { + addToast({ + message: error.message, + variant: "error", + }); + } } } async function createCustomPlaylist({ - addToast, accessToken, user, pageDetails, }: Pick< Parameters[0], - "addToast" | "accessToken" | "user" | "pageDetails" + "accessToken" | "user" | "pageDetails" >) { const playlist = await createPlaylist(user?.id, { name: pageDetails?.name, }); if (!playlist) { - addToast({ - message: "Error creating playlist", - variant: "error", - }); - throw new Error("Error creating playlist"); + throw new CreatePlaylistError(); } await addCustomPlaylistImage({ diff --git a/types/spotify.ts b/types/spotify.ts index e5b29bb0..7e965037 100644 --- a/types/spotify.ts +++ b/types/spotify.ts @@ -126,7 +126,7 @@ export interface ISpotifyContext { playlist: string | undefined, tracks: number[], snapshotID: string | undefined - ) => Promise; + ) => Promise; displayInFullScreen: DisplayInFullScreen; setDisplayInFullScreen: Dispatch>; isPictureInPictureLyircsCanvas: boolean; diff --git a/utils/__tests__/playCurrentTrack.spec.ts b/utils/__tests__/playCurrentTrack.spec.ts index 1203d888..95301f84 100644 --- a/utils/__tests__/playCurrentTrack.spec.ts +++ b/utils/__tests__/playCurrentTrack.spec.ts @@ -1,5 +1,6 @@ import { AudioPlayer } from "hooks/useSpotifyPlayer"; import { IUtilsMocks } from "types/mocks"; +import { BadRequestError, NotFoundError } from "utils/errors"; import { playCurrentTrack } from "utils/playCurrentTrack"; import { play } from "utils/spotifyCalls"; @@ -19,136 +20,71 @@ const { track, user, accessToken } = jest.requireActual( ); describe("playCurrentTrack", () => { - it("should play a track for premium user and setPlaylistPlayingId undefined for singleTrack", async () => { - expect.assertions(2); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); + const player = { + src: "", + currentTime: 0, + allTracks: [], + play: jest.fn(), + } as unknown as AudioPlayer; + + const setCurrentlyPlaying = jest.fn(); + const setAccessToken = jest.fn(); + + const config = { + player, + user, + allTracks: [track], + accessToken, + deviceId: "deviceId", + playlistUri: "playlistUri", + setCurrentlyPlaying, + playlistId: "playlistId", + isSingleTrack: true, + position: 0, + setAccessToken, + uri: "spotify:track:123", + }; + + it("should play a track for premium user and result must be undefined for singleTrack", async () => { + expect.assertions(1); - const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, - isSingleTrack: true, - position: 0, - setAccessToken, - uri: "spotify:track:123", - }); + const result = await playCurrentTrack(track, config); - expect(result).toBe(200); - expect(setPlaylistPlayingId).toHaveBeenCalledWith(undefined); + expect(result).toBeUndefined(); }); - it("should play uris for premium user and setPlaylistPlayingId for singleTrack", async () => { - expect.assertions(2); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); + it("should play uris for premium user", async () => { + expect.assertions(1); const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, - isSingleTrack: true, - position: 0, - setAccessToken, + ...config, uri: undefined, }); - expect(result).toBe(200); - expect(setPlaylistPlayingId).toHaveBeenCalledWith(undefined); + expect(result).toBeUndefined(); }); - it("should play a track for premium user", async () => { - expect.assertions(2); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); + it("should play a track for premium user and non single track", async () => { + expect.assertions(1); const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, + ...config, isSingleTrack: false, - position: 0, - setAccessToken, - uri: "spotify:track:123", }); - expect(result).toBe(200); - expect(setPlaylistPlayingId).toHaveBeenCalledWith("playlistId"); + expect(result).toBe("playlistId"); }); - it("should play a track for non-premium user", async () => { - expect.assertions(2); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); + it("should play a track for non-premium user and non single track", async () => { + expect.assertions(1); const result = await playCurrentTrack(track, { - player, + ...config, user: { ...user, product: "open" }, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, isSingleTrack: false, - position: 0, - setAccessToken, - uri: "spotify:track:123", }); - expect(result).toBe(200); - expect(setPlaylistPlayingId).toHaveBeenCalledWith("playlistId"); + expect(result).toBe("playlistId"); }); it("should return 400 for a different status", async () => { @@ -157,101 +93,39 @@ describe("playCurrentTrack", () => { status: 500, ok: false, }); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); + async function handler() { + await playCurrentTrack(track, { ...config, isSingleTrack: false }); + } - const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, - isSingleTrack: false, - position: 0, - setAccessToken, - uri: "spotify:track:123", - }); - - expect(result).toBe(400); + await expect(handler).rejects.toThrow(BadRequestError); }); - it("should return 404", async () => { + it("should throw NotFoundError if status is 404", async () => { expect.assertions(1); (play as jest.Mock).mockResolvedValue({ status: 404, ok: false, }); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); - const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: "deviceId", - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, - isSingleTrack: false, - position: 0, - setAccessToken, - uri: "spotify:track:123", - }); + async function handler() { + await playCurrentTrack(track, { ...config, isSingleTrack: false }); + } - expect(result).toBe(404); + await expect(handler).rejects.toThrow(NotFoundError); }); - it("should return 400 if not deviceId", async () => { + it("should throw BadRequestError if not deviceId", async () => { expect.assertions(1); - const player = { - src: "", - currentTime: 0, - allTracks: [], - play: jest.fn(), - } as unknown as AudioPlayer; - const setCurrentlyPlaying = jest.fn(); - const setPlaylistPlayingId = jest.fn(); - const setAccessToken = jest.fn(); - - const result = await playCurrentTrack(track, { - player, - user, - allTracks: [track], - accessToken, - deviceId: undefined, - playlistUri: "playlistUri", - setCurrentlyPlaying, - playlistId: "playlistId", - setPlaylistPlayingId, - isSingleTrack: false, - position: 0, - setAccessToken, - uri: undefined, - }); + async function handler() { + await playCurrentTrack(track, { + ...config, + deviceId: undefined, + uri: undefined, + }); + } - expect(result).toBe(400); + await expect(handler).rejects.toThrow(BadRequestError); }); }); diff --git a/utils/errors.ts b/utils/errors.ts new file mode 100644 index 00000000..a6eb8a06 --- /dev/null +++ b/utils/errors.ts @@ -0,0 +1,50 @@ +import { Context } from "react"; + +class BaseError extends Error { + static isThisError(error: unknown): error is Error { + return error instanceof this; + } +} + +export class ContextError extends BaseError { + constructor(context: Context) { + const contextName = context.displayName ?? "Context"; + super(`use${contextName} must be used within a ${contextName}Provider`); + this.name = "ContextError"; + } +} + +export class TargetElementError extends BaseError { + constructor(elementId: string) { + super(`Component needs a target element with id: ${elementId}`); + this.name = "TargetElementError"; + } +} + +export class RemoveTrackError extends BaseError { + constructor() { + super("Failed to remove tracks"); + this.name = "RemoveTrackError"; + } +} + +export class CreatePlaylistError extends BaseError { + constructor() { + super("Failed to create playlist"); + this.name = "CreatePlaylistError"; + } +} + +export class BadRequestError extends BaseError { + constructor() { + super("Bad request"); + this.name = "BadRequestError"; + } +} + +export class NotFoundError extends BaseError { + constructor() { + super("Not found"); + this.name = "NotFoundError"; + } +} diff --git a/utils/index.ts b/utils/index.ts index 8820950f..0e9ef951 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -58,3 +58,4 @@ export * from "./topBar"; export * from "./wait"; export * from "./within"; export * from "./findIndexOrLast"; +export * from "./errors"; diff --git a/utils/playCurrentTrack.ts b/utils/playCurrentTrack.ts index c596ccbd..eb050390 100644 --- a/utils/playCurrentTrack.ts +++ b/utils/playCurrentTrack.ts @@ -1,7 +1,11 @@ import { Dispatch, SetStateAction } from "react"; +import { BadRequestError, NotFoundError } from "./errors"; +import { ContentType, ToastMessage } from "./getTranslations"; +import { templateReplace } from "./templateReplace"; import { AudioPlayer } from "hooks/useSpotifyPlayer"; import { ITrack } from "types/spotify"; +import { NewToast } from "types/toast"; import { play } from "utils/spotifyCalls"; interface Config { @@ -13,7 +17,6 @@ interface Config { player: AudioPlayer | Spotify.Player | undefined; setCurrentlyPlaying: Dispatch>; playlistId: string | undefined; - setPlaylistPlayingId: Dispatch>; isSingleTrack?: boolean; position?: number; setAccessToken: Dispatch>; @@ -21,6 +24,39 @@ interface Config { uris?: string[]; } +export function handlePlayCurrentTrackError( + error: unknown, + { + player, + addToast, + setReconnectionError, + translations, + }: { + player: Spotify.Player; + addToast: (toast: NewToast) => void; + setReconnectionError: (value: SetStateAction) => void; + translations: Record; + } +): void { + if (NotFoundError.isThisError(error)) { + player.disconnect(); + addToast({ + variant: "error", + message: translations[ToastMessage.UnableToPlayReconnecting], + }); + setReconnectionError(true); + } + + if (BadRequestError.isThisError(error)) { + addToast({ + variant: "error", + message: templateReplace(translations[ToastMessage.ErrorPlayingThis], [ + translations[ContentType.Track], + ]), + }); + } +} + export async function playCurrentTrack( track: ITrack | undefined, { @@ -32,14 +68,13 @@ export async function playCurrentTrack( playlistUri, setCurrentlyPlaying, playlistId, - setPlaylistPlayingId, isSingleTrack, position, setAccessToken, uri, uris, }: Config -): Promise { +): Promise { const isPremium = user?.product === "premium"; if (!isPremium && track?.preview_url) { (player as AudioPlayer).currentTime = 0; @@ -47,8 +82,7 @@ export async function playCurrentTrack( (player as AudioPlayer).play(); (player as AudioPlayer).allTracks = allTracks; setCurrentlyPlaying(track); - setPlaylistPlayingId(playlistId); - return 200; + return playlistId; } if (accessToken && track && deviceId) { @@ -76,22 +110,15 @@ export async function playCurrentTrack( offset: track.position, }; - const playStatus = await play( - accessToken, - deviceId, - playConfig, - setAccessToken - ).then((res) => { - if (res.status === 404) { - return 404; - } - if (res.ok) { - setPlaylistPlayingId(isSingleTrack ? undefined : playlistId); - return 200; - } - return 400; - }); - return playStatus; + const res = await play(accessToken, deviceId, playConfig, setAccessToken); + + if (res.status === 404) { + throw new NotFoundError(); + } + if (res.ok) { + return isSingleTrack ? undefined : playlistId; + } + throw new BadRequestError(); } - return 400; + throw new BadRequestError(); } diff --git a/utils/spotifyCalls/callSpotifyApi.ts b/utils/spotifyCalls/callSpotifyApi.ts index 69cfac47..d1493c17 100644 --- a/utils/spotifyCalls/callSpotifyApi.ts +++ b/utils/spotifyCalls/callSpotifyApi.ts @@ -1,3 +1,4 @@ +import { refreshAccessToken } from "./refreshAccessToken"; import { ACCESS_TOKEN_COOKIE } from "utils/constants"; import { takeCookie } from "utils/cookies"; @@ -7,6 +8,7 @@ interface ICallSpotifyApi { accessToken?: string | null; cookies?: string; body?: BodyInit | null; + retry?: boolean; } export async function callSpotifyApi({ @@ -15,6 +17,7 @@ export async function callSpotifyApi({ accessToken, cookies, body, + retry, }: ICallSpotifyApi): Promise { const res = await fetch(`https://api.spotify.com/v1${endpoint}`, { method, @@ -27,5 +30,19 @@ export async function callSpotifyApi({ body, }); + if (res.ok && res.status === 401 && !retry) { + const { access_token } = (await refreshAccessToken()) ?? {}; + if (access_token) { + return callSpotifyApi({ + endpoint, + method, + accessToken: access_token, + cookies, + body, + retry: true, + }); + } + } + return res; }