mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Tabbed Interface with support for current playback & user library added
This commit is contained in:
parent
fe39ab0ffd
commit
247f1b563b
BIN
assets/rickroll.jpg
Normal file
BIN
assets/rickroll.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 93 KiB |
7499
package-lock.json
generated
7499
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
src/app.tsx
14
src/app.tsx
@ -74,13 +74,10 @@ function RootApp() {
|
|||||||
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
||||||
const [credentials, setCredentials] = useState<Credentials>({ clientId: "", clientSecret: "" });
|
const [credentials, setCredentials] = useState<Credentials>({ clientId: "", clientSecret: "" });
|
||||||
const [expires_in, setExpires_in] = useState<number>(0);
|
|
||||||
const [access_token, setAccess_token] = useState<string>("");
|
const [access_token, setAccess_token] = useState<string>("");
|
||||||
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||||
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
||||||
|
|
||||||
const setExpireTime = (expirationDuration: number) => setExpires_in(Date.now() + expirationDuration * 1000 /* 1s = 1000 ms */);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoggedIn(!!cachedCredentials);
|
setIsLoggedIn(!!cachedCredentials);
|
||||||
}, []);
|
}, []);
|
||||||
@ -109,7 +106,6 @@ function RootApp() {
|
|||||||
spotifyApi.setClientSecret(credentials.clientSecret);
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
|
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
|
||||||
setAccess_token(authRes.access_token);
|
setAccess_token(authRes.access_token);
|
||||||
setExpireTime(authRes.expires_in);
|
|
||||||
localStorage.setItem(CredentialKeys.refresh_token, authRes.refresh_token);
|
localStorage.setItem(CredentialKeys.refresh_token, authRes.refresh_token);
|
||||||
return res.end();
|
return res.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -119,7 +115,11 @@ function RootApp() {
|
|||||||
|
|
||||||
const server = app.listen(4304, () => {
|
const server = app.listen(4304, () => {
|
||||||
console.log("Server is running");
|
console.log("Server is running");
|
||||||
open(spotifyApi.createAuthorizeURL(["user-library-read", "user-library-modify"], "xxxyyysssddd")).catch((e) => console.error("Opening IPC connection with browser failed: ", e));
|
spotifyApi.setClientId(credentials.clientId);
|
||||||
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
|
open(spotifyApi.createAuthorizeURL(["user-library-read", "playlist-read-private", "user-library-modify"], "xxxyyysssddd")).catch((e) =>
|
||||||
|
console.error("Opening IPC connection with browser failed: ", e)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
server.close(() => console.log("Closed server"));
|
server.close(() => console.log("Closed server"));
|
||||||
@ -136,10 +136,10 @@ function RootApp() {
|
|||||||
return (
|
return (
|
||||||
<Window ref={windowRef} on={windowEvents} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
|
<Window ref={windowRef} on={windowEvents} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, expires_in, setAccess_token, setExpires_in: setExpireTime, ...credentials }}>
|
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials }}>
|
||||||
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<View style={`flex: 1; flex-direction: 'column'; justify-content: 'center'; align-items: 'stretch'; height: '100%';`}>
|
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}>
|
||||||
<Routes />
|
<Routes />
|
||||||
{isLoggedIn && <Player />}
|
{isLoggedIn && <Player />}
|
||||||
</View>
|
</View>
|
||||||
|
35
src/components/CurrentPlaylist.tsx
Normal file
35
src/components/CurrentPlaylist.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import playerContext from "../context/playerContext";
|
||||||
|
import { TrackButton } from "./PlaylistView";
|
||||||
|
|
||||||
|
function CurrentPlaylist() {
|
||||||
|
const { currentPlaylist, currentTrack, setCurrentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
|
if (!currentPlaylist && !currentTrack) {
|
||||||
|
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text>{ `<center><h2>${currentPlaylist?.name}</h2></center>` }</Text>
|
||||||
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
|
{currentPlaylist?.tracks.map(({ track }, index) => {
|
||||||
|
return (
|
||||||
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
active={currentTrack?.id === track.id}
|
||||||
|
artist={track.artists.map((x) => x.name).join(", ")}
|
||||||
|
name={track.name}
|
||||||
|
on={{ clicked: () => setCurrentTrack(track) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrentPlaylist;
|
@ -8,34 +8,18 @@ import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
|||||||
import ErrorApplet from "./shared/ErrorApplet";
|
import ErrorApplet from "./shared/ErrorApplet";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const {
|
const { data: categories, isError, isRefetchError, refetch } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
|
||||||
data: categories,
|
|
||||||
isError,
|
|
||||||
isRefetchError,
|
|
||||||
refetch,
|
|
||||||
} = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
|
|
||||||
QueryCacheKeys.categories,
|
QueryCacheKeys.categories,
|
||||||
(spotifyApi) =>
|
(spotifyApi) => spotifyApi.getCategories({ country: "US" }).then((categoriesReceived) => categoriesReceived.body.categories.items),
|
||||||
spotifyApi
|
|
||||||
.getCategories({ country: "US" })
|
|
||||||
.then((categoriesReceived) => categoriesReceived.body.categories.items),
|
|
||||||
{ initialData: [] }
|
{ initialData: [] }
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
|
||||||
<View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}>
|
<View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}>
|
||||||
{(isError || isRefetchError) && (
|
{(isError || isRefetchError) && <ErrorApplet message="Failed to query genres" reload={refetch} helps />}
|
||||||
<ErrorApplet message="Failed to query genres" reload={refetch} helps />
|
|
||||||
)}
|
|
||||||
{categories?.map((category, index) => {
|
{categories?.map((category, index) => {
|
||||||
return (
|
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
|
||||||
<CategoryCard
|
|
||||||
key={index + category.id}
|
|
||||||
id={category.id}
|
|
||||||
name={category.name}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@ -51,14 +35,9 @@ interface CategoryCardProps {
|
|||||||
|
|
||||||
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { data: playlists, isError } = useSpotifyQuery<
|
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
||||||
SpotifyApi.PlaylistObjectSimplified[]
|
|
||||||
>(
|
|
||||||
[QueryCacheKeys.categoryPlaylists, id],
|
[QueryCacheKeys.categoryPlaylists, id],
|
||||||
(spotifyApi) =>
|
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id, { limit: 4 }).then((playlistsRes) => playlistsRes.body.playlists.items),
|
||||||
spotifyApi
|
|
||||||
.getPlaylistsForCategory(id, { limit: 4 })
|
|
||||||
.then((playlistsRes) => playlistsRes.body.playlists.items),
|
|
||||||
{ initialData: [] }
|
{ initialData: [] }
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -66,26 +45,14 @@ const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
|||||||
history.push(`/genre/playlists/${id}`, { name });
|
history.push(`/genre/playlists/${id}`, { name });
|
||||||
}
|
}
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <></ >;
|
return <></>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View id="container" styleSheet={categoryStylesheet}>
|
<View id="container" styleSheet={categoryStylesheet}>
|
||||||
<Button
|
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
||||||
id="anchor-heading"
|
|
||||||
cursor={CursorShape.PointingHandCursor}
|
|
||||||
on={{ MouseButtonRelease: goToGenre }}
|
|
||||||
text={name}
|
|
||||||
/>
|
|
||||||
<View id="child-view">
|
<View id="child-view">
|
||||||
{playlists?.map((playlist, index) => {
|
{playlists?.map((playlist, index) => {
|
||||||
return (
|
return <PlaylistCard key={index + playlist.id} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />;
|
||||||
<PlaylistCard
|
|
||||||
key={index + playlist.id}
|
|
||||||
id={playlist.id}
|
|
||||||
name={playlist.name}
|
|
||||||
thumbnail={playlist.images[0].url}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -126,8 +93,7 @@ interface PlaylistCardProps {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlaylistCard = React.memo(
|
export const PlaylistCard = React.memo(({ id, name, thumbnail }: PlaylistCardProps) => {
|
||||||
({ id, name, thumbnail }: PlaylistCardProps) => {
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
function gotoPlaylist() {
|
function gotoPlaylist() {
|
||||||
@ -149,19 +115,8 @@ export const PlaylistCard = React.memo(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View id="playlist-container" cursor={CursorShape.PointingHandCursor} styleSheet={playlistStyleSheet} on={{ MouseButtonRelease: gotoPlaylist }}>
|
||||||
id="playlist-container"
|
<CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />
|
||||||
cursor={CursorShape.PointingHandCursor}
|
|
||||||
styleSheet={playlistStyleSheet}
|
|
||||||
on={{ MouseButtonRelease: gotoPlaylist }}
|
|
||||||
>
|
|
||||||
<CachedImage
|
|
||||||
src={thumbnail}
|
|
||||||
maxSize={{ height: 150, width: 150 }}
|
|
||||||
scaledContents
|
|
||||||
alt={name}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
87
src/components/Library.tsx
Normal file
87
src/components/Library.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { ScrollArea, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Redirect, Route } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import playerContext from "../context/playerContext";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
import { PlaylistCard } from "./Home";
|
||||||
|
import { PlaylistSimpleControls, TrackButton } from "./PlaylistView";
|
||||||
|
import { TabMenuItem } from "./TabMenu";
|
||||||
|
|
||||||
|
function Library() {
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'row';">
|
||||||
|
<Redirect from="/library" to="/library/saved-tracks" />
|
||||||
|
<View style="flex-direction: 'column'; flex: 1; max-width: 150px;">
|
||||||
|
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
|
||||||
|
<TabMenuItem title="Playlists" url="/library/playlists" />
|
||||||
|
</View>
|
||||||
|
<Route exact path="/library/saved-tracks">
|
||||||
|
<UserSavedTracks />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/library/playlists">
|
||||||
|
<UserPlaylists />
|
||||||
|
</Route>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Library;
|
||||||
|
|
||||||
|
function UserPlaylists() {
|
||||||
|
const { data: userPlaylists, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(QueryCacheKeys.userPlaylists, (spotifyApi) =>
|
||||||
|
spotifyApi.getUserPlaylists().then((userPlaylists) => {
|
||||||
|
return userPlaylists.body.items;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'row'; flex-wrap: 'wrap'; justify-content: 'space-evenly'; width: 330px; align-items: 'center';">
|
||||||
|
{userPlaylists?.map((playlist, index) => (
|
||||||
|
<PlaylistCard key={index + playlist.id} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserSavedTracks() {
|
||||||
|
const userSavedPlaylistId = "user-saved-tracks";
|
||||||
|
const { data: userTracks, isError, isLoading } = useSpotifyQuery<SpotifyApi.SavedTrackObject[]>(QueryCacheKeys.userSavedTracks, (spotifyApi) =>
|
||||||
|
spotifyApi.getMySavedTracks({ limit: 50 }).then((tracks) => tracks.body.items)
|
||||||
|
);
|
||||||
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
|
function handlePlaylistPlayPause() {
|
||||||
|
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
||||||
|
setCurrentPlaylist({ id: userSavedPlaylistId, name: "Liked Tracks", thumbnail: "https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg", tracks: userTracks });
|
||||||
|
setCurrentTrack(userTracks[0].track);
|
||||||
|
} else {
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
||||||
|
{userTracks?.map(({ track }) => (
|
||||||
|
<TrackButton
|
||||||
|
active={currentPlaylist?.id === userSavedPlaylistId && currentTrack?.id === track.id}
|
||||||
|
artist={track.artists.map((x) => x.name).join(", ")}
|
||||||
|
name={track.name}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
setCurrentTrack(track);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
@ -6,11 +6,10 @@ import { shuffleArray } from "../helpers/shuffleArray";
|
|||||||
import NodeMpv from "node-mpv";
|
import NodeMpv from "node-mpv";
|
||||||
import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
|
import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
|
||||||
import PlayerProgressBar from "./PlayerProgressBar";
|
import PlayerProgressBar from "./PlayerProgressBar";
|
||||||
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular } from "../icons";
|
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular, heart } from "../icons";
|
||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
import authContext from "../context/authContext";
|
|
||||||
import useSpotifyApi from "../hooks/useSpotifyApi";
|
|
||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
|
import useTrackReaction from "../hooks/useTrackReaction";
|
||||||
|
|
||||||
export const audioPlayer = new NodeMpv(
|
export const audioPlayer = new NodeMpv(
|
||||||
{
|
{
|
||||||
@ -26,8 +25,7 @@ export const audioPlayer = new NodeMpv(
|
|||||||
|
|
||||||
function Player(): ReactElement {
|
function Player(): ReactElement {
|
||||||
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
|
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
|
||||||
const spotifyApi = useSpotifyApi();
|
const { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
const { access_token } = useContext(authContext);
|
|
||||||
const initVolume = parseFloat(localStorage.getItem("volume") ?? "55");
|
const initVolume = parseFloat(localStorage.getItem("volume") ?? "55");
|
||||||
const [isPaused, setIsPaused] = useState(true);
|
const [isPaused, setIsPaused] = useState(true);
|
||||||
const [volume, setVolume] = useState<number>(initVolume);
|
const [volume, setVolume] = useState<number>(initVolume);
|
||||||
@ -60,7 +58,7 @@ function Player(): ReactElement {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start audio player", error);
|
console.error("Failed to start audio player", error);
|
||||||
showError(error, "[Failed starting audio player]: ")
|
showError(error, "[Failed starting audio player]: ");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -71,19 +69,6 @@ function Player(): ReactElement {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// (async () => {
|
|
||||||
// try {
|
|
||||||
// if (access_token) {
|
|
||||||
// const userSavedTrack = await spotifyApi.getMySavedTracks();
|
|
||||||
// console.log("userSavedTrack:", userSavedTrack);
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Failed to get spotify user saved tracks: ", error);
|
|
||||||
// }
|
|
||||||
// })();
|
|
||||||
// }, [access_token]);
|
|
||||||
|
|
||||||
// track change effect
|
// track change effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -143,14 +128,14 @@ function Player(): ReactElement {
|
|||||||
};
|
};
|
||||||
const pauseListener = () => {
|
const pauseListener = () => {
|
||||||
setIsPaused(true);
|
setIsPaused(true);
|
||||||
}
|
};
|
||||||
const resumeListener = () => {
|
const resumeListener = () => {
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
};
|
};
|
||||||
audioPlayer.on("status", statusListener);
|
audioPlayer.on("status", statusListener);
|
||||||
audioPlayer.on("stopped", stopListener);
|
audioPlayer.on("stopped", stopListener);
|
||||||
audioPlayer.on("paused", pauseListener)
|
audioPlayer.on("paused", pauseListener);
|
||||||
audioPlayer.on("resumed", resumeListener)
|
audioPlayer.on("resumed", resumeListener);
|
||||||
return () => {
|
return () => {
|
||||||
audioPlayer.off("status", statusListener);
|
audioPlayer.off("status", statusListener);
|
||||||
audioPlayer.off("stopped", stopListener);
|
audioPlayer.off("stopped", stopListener);
|
||||||
@ -189,13 +174,13 @@ function Player(): ReactElement {
|
|||||||
await audioPlayer.stop();
|
await audioPlayer.stop();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, "[Failed at audio-player stop]: ")
|
showError(error, "[Failed at audio-player stop]: ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const artistsNames = currentTrack?.artists?.map((x) => x.name);
|
const artistsNames = currentTrack?.artists?.map((x) => x.name);
|
||||||
return (
|
return (
|
||||||
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 100px;">
|
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
|
||||||
<GridRow>
|
<GridRow>
|
||||||
<GridColumn width={2}>
|
<GridColumn width={2}>
|
||||||
<Text ref={titleRef} wordWrap>
|
<Text ref={titleRef} wordWrap>
|
||||||
@ -221,7 +206,16 @@ function Player(): ReactElement {
|
|||||||
</GridColumn>
|
</GridColumn>
|
||||||
<GridColumn width={2}>
|
<GridColumn width={2}>
|
||||||
<BoxView>
|
<BoxView>
|
||||||
<IconButton icon={new QIcon(heartRegular)} />
|
<IconButton
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
if (currentTrack) {
|
||||||
|
reactToTrack({ added_at: Date.now().toString(), track: currentTrack });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : heartRegular)}
|
||||||
|
/>
|
||||||
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} />
|
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} />
|
||||||
</BoxView>
|
</BoxView>
|
||||||
</GridColumn>
|
</GridColumn>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React, { FC, useContext } from "react";
|
import React, { FC, useContext } from "react";
|
||||||
import { BoxView, View, Button, ScrollArea, Text } from "@nodegui/react-nodegui";
|
import { View, Button, ScrollArea, Text } from "@nodegui/react-nodegui";
|
||||||
import BackButton from "./BackButton";
|
import BackButton from "./BackButton";
|
||||||
import { useLocation, useParams } from "react-router";
|
import { useLocation, useParams } from "react-router";
|
||||||
import { Direction, QAbstractButtonSignals, QIcon } from "@nodegui/nodegui";
|
import { QAbstractButtonSignals, QIcon } from "@nodegui/nodegui";
|
||||||
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
@ -44,14 +44,10 @@ const PlaylistView: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={`flex: 1; flex-direction: 'column'; flex-grow: 1;`}>
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
|
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === params.id} />
|
||||||
<BackButton />
|
|
||||||
<IconButton icon={new QIcon(heartRegular)} />
|
|
||||||
<IconButton style={`background-color: #00be5f; color: white;`} on={{ clicked: handlePlaylistPlayPause }} icon={new QIcon(currentPlaylist?.id === params.id ? stop : play)} />
|
|
||||||
</View>
|
|
||||||
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
||||||
<ScrollArea style={`flx:1; flex-grow: 1; border: none;`}>
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
<View style={`flex-direction:column; flex: 1;`}>
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
{isLoading && <Text>{`Loading Tracks...`}</Text>}
|
{isLoading && <Text>{`Loading Tracks...`}</Text>}
|
||||||
{isError && (
|
{isError && (
|
||||||
@ -70,7 +66,7 @@ const PlaylistView: FC = () => {
|
|||||||
{tracks?.map(({ track }, index) => {
|
{tracks?.map(({ track }, index) => {
|
||||||
return (
|
return (
|
||||||
<TrackButton
|
<TrackButton
|
||||||
key={index+track.id}
|
key={index + track.id}
|
||||||
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
||||||
artist={track.artists.map((x) => x.name).join(", ")}
|
artist={track.artists.map((x) => x.name).join(", ")}
|
||||||
name={track.name}
|
name={track.name}
|
||||||
@ -84,14 +80,14 @@ const PlaylistView: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TrackButtonProps {
|
export interface TrackButtonProps {
|
||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
on: Partial<QAbstractButtonSignals | WidgetEventListeners>;
|
on: Partial<QAbstractButtonSignals | WidgetEventListeners>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackButton: FC<TrackButtonProps> = ({ name, artist, on, active }) => {
|
export const TrackButton: FC<TrackButtonProps> = ({ name, artist, on, active }) => {
|
||||||
return <Button id={`${active ? "active" : ""}`} on={on} text={`${name} -- ${artist}`} styleSheet={trackButtonStyle} />;
|
return <Button id={`${active ? "active" : ""}`} on={on} text={`${name} -- ${artist}`} styleSheet={trackButtonStyle} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -103,3 +99,18 @@ const trackButtonStyle = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default PlaylistView;
|
export default PlaylistView;
|
||||||
|
|
||||||
|
interface PlaylistSimpleControlsProps {
|
||||||
|
handlePlaylistPlayPause: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive }: PlaylistSimpleControlsProps) {
|
||||||
|
return (
|
||||||
|
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
|
||||||
|
<BackButton />
|
||||||
|
<IconButton icon={new QIcon(heartRegular)} />
|
||||||
|
<IconButton style={`background-color: #00be5f; color: white;`} on={{ clicked: handlePlaylistPlayPause }} icon={new QIcon(isActive ? stop : play)} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Route} from "react-router"
|
import { View, Button, Text } from "@nodegui/react-nodegui";
|
||||||
import {View, Button} from "@nodegui/react-nodegui"
|
import { useHistory, useLocation } from "react-router";
|
||||||
import {QIcon} from "@nodegui/nodegui"
|
|
||||||
|
function TabMenu() {
|
||||||
|
|
||||||
function TabMenu(){
|
|
||||||
return (
|
return (
|
||||||
<View id="tabmenu" styleSheet={tabBarStylesheet}>
|
<View id="tabmenu" styleSheet={tabBarStylesheet}>
|
||||||
<TabMenuItem title="Browse"/>
|
<View>
|
||||||
<TabMenuItem title="Library"/>
|
<Text>{`<h1>Spotube</h1>`}</Text>
|
||||||
<TabMenuItem title="Currently Playing"/>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
<TabMenuItem url="/home" title="Browse" />
|
||||||
|
<TabMenuItem url="/library" title="Library" />
|
||||||
|
<TabMenuItem url="/currently" title="Currently Playing" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabBarStylesheet = `
|
export const tabBarStylesheet = `
|
||||||
#tabmenu{
|
#tabmenu{
|
||||||
flex-direction: 'column';
|
padding: 10px;
|
||||||
align-items: 'center';
|
flex-direction: 'row';
|
||||||
max-width: 225px;
|
justify-content: 'space-around';
|
||||||
}
|
|
||||||
#tabmenu-item{
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
}
|
||||||
#tabmenu-item:hover{
|
#tabmenu-item:hover{
|
||||||
color: green;
|
color: green;
|
||||||
@ -28,20 +28,30 @@ const tabBarStylesheet = `
|
|||||||
#tabmenu-item:active{
|
#tabmenu-item:active{
|
||||||
color: #59ff88;
|
color: #59ff88;
|
||||||
}
|
}
|
||||||
`
|
#tabmenu-active-item{
|
||||||
|
background-color: green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default TabMenu;
|
export default TabMenu;
|
||||||
|
|
||||||
interface TabMenuItemProps{
|
export interface TabMenuItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
url: string;
|
||||||
/**
|
/**
|
||||||
* path to the icon in string
|
* path to the icon in string
|
||||||
*/
|
*/
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabMenuItem({icon, title}:TabMenuItemProps){
|
export function TabMenuItem({ icon, title, url }: TabMenuItemProps) {
|
||||||
return (
|
const location = useLocation();
|
||||||
<Button id="tabmenu-item" text={title}/>
|
const history = useHistory();
|
||||||
)
|
|
||||||
|
function clicked() {
|
||||||
|
history.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button on={{ clicked }} id={location.pathname.replace("/", " ").startsWith(url.replace("/", " ")) ? "tabmenu-active-item" : `tabmenu-item`} text={title} />;
|
||||||
}
|
}
|
@ -11,4 +11,6 @@ export enum QueryCacheKeys{
|
|||||||
categoryPlaylists = "categoryPlaylists",
|
categoryPlaylists = "categoryPlaylists",
|
||||||
genrePlaylists="genrePlaylists",
|
genrePlaylists="genrePlaylists",
|
||||||
playlistTracks="playlistTracks",
|
playlistTracks="playlistTracks",
|
||||||
|
userPlaylists = "user-palylists",
|
||||||
|
userSavedTracks = "user-saved-tracks"
|
||||||
}
|
}
|
@ -6,16 +6,6 @@ export interface AuthContext {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
/**
|
|
||||||
* the time when the current access token will expire \
|
|
||||||
* always update this with `Date.now() + expires_in`
|
|
||||||
*/
|
|
||||||
expires_in: number;
|
|
||||||
/**
|
|
||||||
* sets the time when the current access token will expire \
|
|
||||||
* always update this with `Date.now() + expires_in`
|
|
||||||
*/
|
|
||||||
setExpires_in: (arg: number)=>void;
|
|
||||||
setAccess_token: Dispatch<SetStateAction<string>>;
|
setAccess_token: Dispatch<SetStateAction<string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,10 +13,8 @@ const authContext = React.createContext<AuthContext>({
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
setIsLoggedIn() {},
|
setIsLoggedIn() {},
|
||||||
access_token: "",
|
access_token: "",
|
||||||
expires_in: 0,
|
|
||||||
clientId: "",
|
clientId: "",
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
setExpires_in() {},
|
|
||||||
setAccess_token() {},
|
setAccess_token() {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import React, { Dispatch, SetStateAction } from "react";
|
|||||||
|
|
||||||
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
||||||
|
|
||||||
export type CurrentPlaylist = { tracks: SpotifyApi.PlaylistTrackObject[]; id: string; name: string; thumbnail: string };
|
export type CurrentPlaylist = { tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[]; id: string; name: string; thumbnail: string };
|
||||||
|
|
||||||
export interface PlayerContext {
|
export interface PlayerContext {
|
||||||
currentPlaylist?: CurrentPlaylist;
|
currentPlaylist?: CurrentPlaylist;
|
||||||
|
24
src/hooks/useSpotifyMutation.ts
Normal file
24
src/hooks/useSpotifyMutation.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMutation, UseMutationOptions } from "react-query";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
|
type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (spotifyApi: SpotifyWebApi, variables: TVariables) => Promise<TData>;
|
||||||
|
|
||||||
|
function useSpotifyMutation<TData = unknown, TVariable = unknown>(mutationFn: SpotifyMutationFn<TData, TVariable>, options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>) {
|
||||||
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>((arg) => mutationFn(spotifyApi, arg), options);
|
||||||
|
const { isError, error } = mutation;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyMutation;
|
31
src/hooks/useTrackReaction.ts
Normal file
31
src/hooks/useTrackReaction.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useQueryClient } from "react-query";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
|
import useSpotifyQuery from "./useSpotifyQuery";
|
||||||
|
|
||||||
|
function useTrackReaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: favoriteTracks } = useSpotifyQuery<SpotifyApi.SavedTrackObject[]>(QueryCacheKeys.userSavedTracks, (spotifyApi) =>
|
||||||
|
spotifyApi.getMySavedTracks({ limit: 50 }).then((tracks) => tracks.body.items)
|
||||||
|
);
|
||||||
|
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>(
|
||||||
|
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
|
||||||
|
{
|
||||||
|
onSuccess(_, track) {
|
||||||
|
queryClient.setQueryData<SpotifyApi.SavedTrackObject[]>(
|
||||||
|
QueryCacheKeys.userSavedTracks,
|
||||||
|
isFavorite(track.track.id) ? (old) => (old ?? []).filter((oldTrack) => oldTrack.track.id !== track.track.id) : (old) => [...(old ?? []), track]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);
|
||||||
|
|
||||||
|
function isFavorite(trackId: string) {
|
||||||
|
return favoriteTrackIds?.includes(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactToTrack, isFavorite, favoriteTracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTrackReaction;
|
@ -1,30 +1,42 @@
|
|||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import {View} from "@nodegui/react-nodegui";
|
import { Redirect, Route } from "react-router";
|
||||||
import { Route } from "react-router";
|
|
||||||
import authContext from "./context/authContext";
|
import authContext from "./context/authContext";
|
||||||
import Home from "./components/Home";
|
import Home from "./components/Home";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import PlaylistView from "./components/PlaylistView";
|
import PlaylistView from "./components/PlaylistView";
|
||||||
import PlaylistGenreView from "./components/PlaylistGenreView";
|
import PlaylistGenreView from "./components/PlaylistGenreView";
|
||||||
import TabMenu from "./components/TabMenu";
|
import TabMenu from "./components/TabMenu";
|
||||||
|
import CurrentPlaylist from "./components/CurrentPlaylist";
|
||||||
|
import Library from "./components/Library";
|
||||||
|
|
||||||
function Routes() {
|
function Routes() {
|
||||||
const {
|
const { isLoggedIn } = useContext(authContext);
|
||||||
isLoggedIn
|
|
||||||
} = useContext(authContext);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
{ isLoggedIn ?
|
{isLoggedIn ? (
|
||||||
<View style="background-color: black; flex: 1; flex-direction: 'column';">
|
<>
|
||||||
|
<Redirect from="/" to="/home" />
|
||||||
<TabMenu />
|
<TabMenu />
|
||||||
<Route exact path="/"><Home/></Route>
|
<Route exact path="/home">
|
||||||
<Route exact path="/playlist/:id"><PlaylistView /></Route>
|
<Home />
|
||||||
<Route exact path="/genre/playlists/:id"><PlaylistGenreView /></Route>
|
</Route>
|
||||||
</View>
|
<Route exact path="/playlist/:id">
|
||||||
: <Login/>
|
<PlaylistView />
|
||||||
}
|
</Route>
|
||||||
|
<Route exact path="/genre/playlists/:id">
|
||||||
|
<PlaylistGenreView />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Login />
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/currently">
|
||||||
|
<CurrentPlaylist />
|
||||||
|
</Route>
|
||||||
|
<Route path="/library">
|
||||||
|
<Library />
|
||||||
</Route>
|
</Route>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user