Skip to content

Commit

Permalink
feat: improve playback usability (#354)
Browse files Browse the repository at this point in the history
  • Loading branch information
cravend authored Apr 18, 2023
2 parents a2a72aa + d704bf9 commit e831441
Show file tree
Hide file tree
Showing 12 changed files with 182 additions and 93 deletions.
2 changes: 1 addition & 1 deletion app/app/(music)/track/[id]/TrackView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const TrackView = ({
),
musicItemId: track.id,
musicItemType: "track",
playbuttonContext: track.uri,
playbuttonContext: track.album.uri,
viewAlbum: true,
albumId: track.album.id,
}}
Expand Down
28 changes: 24 additions & 4 deletions app/app/(music)/track/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,39 @@ import TrackView from "./TrackView";

const getData = async (id: string) => {
const track = await fetchServer<SpotifyApi.TrackObjectFull>(
`https://api.spotify.com/v1/tracks/${id}`
`https://api.spotify.com/v1/tracks/${id}`,
{
next: {
revalidate: false,
},
}
);
const artist = await fetchServer<SpotifyApi.ArtistObjectFull>(
`https://api.spotify.com/v1/artists/${track.artists[0].id}`
`https://api.spotify.com/v1/artists/${track.artists[0].id}`,
{
next: {
revalidate: false,
},
}
);

const audioFeatures = await fetchServer<SpotifyApi.AudioFeaturesResponse>(
`https://api.spotify.com/v1/audio-features/${id}`
`https://api.spotify.com/v1/audio-features/${id}`,
{
next: {
revalidate: false,
},
}
);

const recommendations =
await fetchServer<SpotifyApi.RecommendationsFromSeedsResponse>(
`https://api.spotify.com/v1/recommendations?seed_tracks=${id}&limit=10`
`https://api.spotify.com/v1/recommendations?seed_tracks=${id}&limit=10`,
{
next: {
revalidate: false,
},
}
);

return { track, artist, audioFeatures, recommendations };
Expand Down
5 changes: 4 additions & 1 deletion app/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"use client";

import PlayerProvider from "@/components/Player/PlayerProvider";
import SidebarProvider from "@/components/Sidebar/SidebarProvider";

const AppProviders = ({ children }: { children: React.ReactNode }) => (
<SidebarProvider>{children}</SidebarProvider>
<SidebarProvider>
<PlayerProvider>{children}</PlayerProvider>
</SidebarProvider>
);

export default AppProviders;
19 changes: 19 additions & 0 deletions components/Player/PlayerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createContext, useContext } from "react";

import type { Dispatch, SetStateAction } from "react";

export type PlayerContextType = {
currentDeviceState: [
string | undefined,
Dispatch<SetStateAction<string | undefined>>
];
visibleState: [boolean, Dispatch<SetStateAction<boolean>>];
};
const PlayerContext = createContext<PlayerContextType>({
currentDeviceState: [undefined, () => undefined],
visibleState: [false, () => undefined],
});

export const usePlayer = () => useContext(PlayerContext);

export default PlayerContext;
30 changes: 30 additions & 0 deletions components/Player/PlayerProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useMemo, useState } from "react";

import PlayerContext from "./PlayerContext";

import type { PlayerContextType } from "./PlayerContext";

const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const [currentDeviceId, setCurrentDeviceId] = useState<string | undefined>(
undefined
);
const [visible, setVisible] = useState<boolean>(false);

const playbackContextValue = useMemo(
(): PlayerContextType => ({
currentDeviceState: [currentDeviceId, setCurrentDeviceId],
visibleState: [visible, setVisible],
}),
[currentDeviceId, visible]
);

return (
<PlayerContext.Provider value={playbackContextValue}>
{children}
</PlayerContext.Provider>
);
};

export default PlayerProvider;
61 changes: 61 additions & 0 deletions components/Player/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { useRouter } from "next/navigation";
import SpotifyPlayer from "react-spotify-web-playback";

import { usePlayer } from "@/components/Player/PlayerContext";

const Player = ({ accessToken }: { accessToken: string }) => {
const { currentDeviceState, visibleState } = usePlayer();
const [isVisible, setIsVisible] = visibleState;
const [, setCurrentDeviceId] = currentDeviceState;

const router = useRouter();

return (
<>
<div className="hidden">
<SpotifyPlayer
callback={(state) => {
if (state.status === "READY") {
setIsVisible(true);
setCurrentDeviceId(state.currentDeviceId);
}
}}
name="DKMS"
token={accessToken}
persistDeviceSelection
uris={[]}
/>
<br />
</div>
{isVisible ? (
<SpotifyPlayer
updateSavedStatus={() => router.refresh()}
syncExternalDevice
syncExternalDeviceInterval={2}
callback={(state) => {
setCurrentDeviceId(state.currentDeviceId);
}}
name="DKMS"
token={accessToken}
layout="compact"
showSaveIcon
persistDeviceSelection
uris={[]}
hideAttribution
/>
) : (
<button
className="btn btn-ghost bg-spotify text-white btn-block"
onClick={() => setIsVisible(true)}
type="button"
>
Connect to Spotify
</button>
)}
</>
);
};

export default Player;
6 changes: 2 additions & 4 deletions components/Sidebar/panels/Playback/PlaybackPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
"use client";

import Player from "@/components/Player";
import { useSidebar } from "@/components/Sidebar/SidebarContext";

import Player from "./Player";

const PlaybackPanel = ({ accessToken }: { accessToken: string }) => {
const [sidebar] = useSidebar();
const isCurrentSidebar = sidebar === "playback";

return (
<div
className={`w-full md:w-64 max-h-screen h-screen flex flex-col md:p-4 md:bg-gray-200 md:text-black ${
isCurrentSidebar ? "" : "hidden"
sidebar === "playback" ? "" : "hidden"
}`}
>
<div>
Expand Down
40 changes: 0 additions & 40 deletions components/Sidebar/panels/Playback/Player.tsx

This file was deleted.

44 changes: 12 additions & 32 deletions components/music/PlayButton.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,37 @@
"use client";

import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useState } from "react";

import { usePlayer } from "@/components/Player/PlayerContext";
import startPlaying from "@/lib/music/startPlaying";

import PlayIcon from "./PlayIcon";

import type { StartPlayingContextParams } from "@/lib/music/startPlaying";

const PlayButton = ({
uris,
contextUri,
offset,
}: StartPlayingContextParams) => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const PlayButton = (params: StartPlayingContextParams) => {
const {
currentDeviceState: [currentDeviceId],
} = usePlayer();
const [isFetching, setIsFetching] = useState(false);

// Create inline loading UI
const isMutating = isFetching || isPending;

const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
setIsFetching(true);
try {
if (offset) {
await startPlaying({ contextUri, offset });
} else if (uris && contextUri) {
await startPlaying({ uris, contextUri });
} else if (contextUri) {
await startPlaying({ contextUri });
} else if (uris) {
await startPlaying({ uris });
}
} catch (error) {
// Button will not do anything if there is no active device
}
await startPlaying({
...params,
deviceId: currentDeviceId,
});

setIsFetching(false);
startTransition(() => {
// Refresh the current route and fetch new data from the server without
// losing client-side browser or React state.
router.refresh();
});
};

return (
<button
className={`btn btn-ghost ${isMutating ? "loading" : ""}`}
className={`btn btn-ghost ${isFetching ? "loading" : ""}`}
onClick={(e) => void handleClick(e)}
type="button"
disabled={isMutating}
disabled={isFetching}
title="Play"
>
<PlayIcon />
Expand Down
26 changes: 17 additions & 9 deletions lib/music/startPlaying.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,35 @@ export type StartPlayingContextParams =
uris: string[];
contextUri?: undefined;
offset?: undefined;
deviceId?: string;
}
| {
uris?: undefined;
contextUri: string;
offset?: string;
deviceId?: string;
}
| {
uris: string[];
contextUri: string;
offset?: undefined;
deviceId?: string;
};

async function startPlaying(params: StartPlayingContextParams) {
return fetchClient(`https://api.spotify.com/v1/me/player/play`, {
method: "PUT",
body: JSON.stringify({
context_uri: params.contextUri,
uris: params.uris,
offset: params.offset ? { uri: params.offset } : undefined,
}),
cache: "no-cache",
});
return fetchClient(
`https://api.spotify.com/v1/me/player/play${
params.deviceId ? `?device_id=${params.deviceId}` : ""
}`,
{
method: "PUT",
body: JSON.stringify({
context_uri: params.contextUri,
uris: params.uris,
offset: params.offset ? { uri: params.offset } : undefined,
}),
cache: "no-cache",
}
);
}
export default startPlaying;
7 changes: 6 additions & 1 deletion lib/recommendations/getRecommendationsBySeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ const getRecommendationsBySeed = async ({
});
if (target) urlParams.append(target.key, target.value.toString());
return fetchServer<SpotifyApi.RecommendationsFromSeedsResponse>(
`https://api.spotify.com/v1/recommendations?${urlParams.toString()}`
`https://api.spotify.com/v1/recommendations?${urlParams.toString()}`,
{
next: {
revalidate: false,
},
}
);
};

Expand Down
7 changes: 6 additions & 1 deletion lib/recommendations/getRecommendedArtists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ const DEFAULT_ARTIST = "4NHQUGzhtTLFvgF5SZesLK";

const getRecommendedArtistData = async (id: string, limit?: number) => {
const data = await fetchServer<SpotifyApi.ArtistsRelatedArtistsResponse>(
`https://api.spotify.com/v1/artists/${id}/related-artists`
`https://api.spotify.com/v1/artists/${id}/related-artists`,
{
next: {
revalidate: false,
},
}
);
return data.artists.slice(0, limit);
};
Expand Down

0 comments on commit e831441

Please sign in to comment.