mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Added: SearchResult comp enhanced, Error, loading, retry handler comp, pagination
This commit is contained in:
parent
d695172804
commit
e91a218a8c
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
BIN
assets/loading-spinner.gif
Normal file
BIN
assets/loading-spinner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
@ -53,7 +53,6 @@ function RootApp() {
|
|||||||
if (nativeEv) {
|
if (nativeEv) {
|
||||||
const event = new QKeyEvent(nativeEv);
|
const event = new QKeyEvent(nativeEv);
|
||||||
const eventKey = event.key();
|
const eventKey = event.key();
|
||||||
console.log("eventKey:", eventKey);
|
|
||||||
if (audioPlayer.isRunning() && currentTrack)
|
if (audioPlayer.isRunning() && currentTrack)
|
||||||
switch (eventKey) {
|
switch (eventKey) {
|
||||||
case 32: //space
|
case 32: //space
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
import { TrackButton, TrackTableIndex } from "./PlaylistView";
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
function CurrentPlaylist() {
|
function CurrentPlaylist() {
|
||||||
const { currentPlaylist, currentTrack, setCurrentTrack } = useContext(playerContext);
|
const { currentPlaylist, currentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
if (!currentPlaylist && !currentTrack) {
|
if (!currentPlaylist && !currentTrack) {
|
||||||
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
|
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentTrack && !currentPlaylist) {
|
||||||
|
<View style="flex: 1;">
|
||||||
|
<TrackButton track={currentTrack} index={0}/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
|
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
|
||||||
@ -17,16 +24,7 @@ function CurrentPlaylist() {
|
|||||||
<ScrollArea style={`flex: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;`}>
|
||||||
{currentPlaylist?.tracks.map(({ track }, index) => {
|
{currentPlaylist?.tracks.map(({ track }, index) => {
|
||||||
return (
|
return <TrackButton key={index + track.id} track={track} index={index} />;
|
||||||
<TrackButton
|
|
||||||
key={index + track.id}
|
|
||||||
active={currentTrack?.id === track.id}
|
|
||||||
track={track}
|
|
||||||
index={index}
|
|
||||||
on={{ MouseButtonRelease: () => setCurrentTrack(track) }}
|
|
||||||
onTrackClick={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
@ -1,21 +1,14 @@
|
|||||||
import React, { useContext, useMemo, useState } from "react";
|
import React from "react";
|
||||||
import { Button, ScrollArea, View, Text } from "@nodegui/react-nodegui";
|
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
|
||||||
import { useHistory } from "react-router";
|
import { useHistory } from "react-router";
|
||||||
import CachedImage from "./shared/CachedImage";
|
import { CursorShape, QMouseEvent } from "@nodegui/nodegui";
|
||||||
import { CursorShape, QIcon, QMouseEvent } from "@nodegui/nodegui";
|
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
import ErrorApplet from "./shared/ErrorApplet";
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
import IconButton from "./shared/IconButton";
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
import { heart, heartRegular, pause, play } from "../icons";
|
|
||||||
import playerContext from "../context/playerContext";
|
|
||||||
import { audioPlayer } from "./Player";
|
|
||||||
import showError from "../helpers/showError";
|
|
||||||
import { generateRandomColor, getDarkenForeground } from "../helpers/RandomColor";
|
|
||||||
import usePlaylistReaction from "../hooks/usePlaylistReaction";
|
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data: categories, isError, isRefetchError, refetch } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
|
const { data: categories, isError, refetch, isLoading } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
|
||||||
QueryCacheKeys.categories,
|
QueryCacheKeys.categories,
|
||||||
(spotifyApi) => spotifyApi.getCategories({ country: "US" }).then((categoriesReceived) => categoriesReceived.body.categories.items),
|
(spotifyApi) => spotifyApi.getCategories({ country: "US" }).then((categoriesReceived) => categoriesReceived.body.categories.items),
|
||||||
{ initialData: [] }
|
{ initialData: [] }
|
||||||
@ -24,7 +17,7 @@ function Home() {
|
|||||||
return (
|
return (
|
||||||
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
|
<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) && <ErrorApplet message="Failed to query genres" reload={refetch} helps />}
|
<PlaceholderApplet error={isError} message="Failed to query genres" reload={refetch} helps loading={isLoading} />
|
||||||
{categories?.map((category, index) => {
|
{categories?.map((category, index) => {
|
||||||
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
|
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
|
||||||
})}
|
})}
|
||||||
@ -95,110 +88,3 @@ const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
|
||||||
playlist: SpotifyApi.PlaylistObjectSimplified;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PlaylistCard = React.memo(({ playlist }: PlaylistCardProps) => {
|
|
||||||
const { id, description, name, images } = playlist;
|
|
||||||
const history = useHistory();
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
|
||||||
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), {
|
|
||||||
initialData: [],
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
|
|
||||||
|
|
||||||
const handlePlaylistPlayPause = async () => {
|
|
||||||
try {
|
|
||||||
const { data: tracks, isSuccess } = await refetch();
|
|
||||||
if (currentPlaylist?.id !== id && isSuccess && tracks) {
|
|
||||||
setCurrentPlaylist({ tracks, id, name, thumbnail: images[0].url });
|
|
||||||
setCurrentTrack(tracks[0].track);
|
|
||||||
} else {
|
|
||||||
await audioPlayer.stop();
|
|
||||||
setCurrentTrack(undefined);
|
|
||||||
setCurrentPlaylist(undefined);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error, "[Failed adding playlist to queue]: ");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function gotoPlaylist(native?: any) {
|
|
||||||
const key = new QMouseEvent(native);
|
|
||||||
if (key.button() === 1) {
|
|
||||||
history.push(`/playlist/${id}`, { name, thumbnail: images[0].url });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgColor1 = useMemo(() => generateRandomColor(), []);
|
|
||||||
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
|
|
||||||
|
|
||||||
const playlistStyleSheet = `
|
|
||||||
#playlist-container{
|
|
||||||
width: 150px;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 5px;
|
|
||||||
min-height: 150px;
|
|
||||||
background-color: ${bgColor1};
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
#playlist-container:hover{
|
|
||||||
border: 1px solid green;
|
|
||||||
}
|
|
||||||
#playlist-container:clicked{
|
|
||||||
border: 5px solid green;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
id="playlist-container"
|
|
||||||
cursor={CursorShape.PointingHandCursor}
|
|
||||||
styleSheet={playlistStyleSheet}
|
|
||||||
on={{
|
|
||||||
MouseButtonRelease: gotoPlaylist,
|
|
||||||
HoverEnter() {
|
|
||||||
setHovered(true);
|
|
||||||
},
|
|
||||||
HoverLeave() {
|
|
||||||
setHovered(false);
|
|
||||||
},
|
|
||||||
}}>
|
|
||||||
{/* <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} /> */}
|
|
||||||
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist }}>
|
|
||||||
{`
|
|
||||||
<center>
|
|
||||||
<h3>${name}</h3>
|
|
||||||
<p>${description}</p>
|
|
||||||
</center>
|
|
||||||
`}
|
|
||||||
</Text>
|
|
||||||
{(hovered || currentPlaylist?.id === id) && (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
style={`position: absolute; bottom: 30px; left: '55%'; background-color: ${color};`}
|
|
||||||
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
|
|
||||||
on={{
|
|
||||||
clicked() {
|
|
||||||
reactToPlaylist(playlist);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
|
|
||||||
style={`position: absolute; bottom: 30px; left: '80%'; background-color: ${color};`}
|
|
||||||
on={{
|
|
||||||
clicked() {
|
|
||||||
handlePlaylistPlayPause();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { ScrollArea, View } from "@nodegui/react-nodegui";
|
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Redirect, Route } from "react-router";
|
import { Redirect, Route } from "react-router";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
import { PlaylistCard } from "./Home";
|
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
|
||||||
import { PlaylistSimpleControls, TrackButton, TrackTableIndex } from "./PlaylistView";
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
import { TabMenuItem } from "./TabMenu";
|
import { TabMenuItem } from "./TabMenu";
|
||||||
|
|
||||||
function Library() {
|
function Library() {
|
||||||
@ -38,6 +41,7 @@ function UserPlaylists() {
|
|||||||
return (
|
return (
|
||||||
<ScrollArea style="flex: 1; border: none;">
|
<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';">
|
<View style="flex: 1; flex-direction: 'row'; flex-wrap: 'wrap'; justify-content: 'space-evenly'; width: 330px; align-items: 'center';">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />
|
||||||
{userPlaylists?.map((playlist, index) => (
|
{userPlaylists?.map((playlist, index) => (
|
||||||
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
||||||
))}
|
))}
|
||||||
@ -48,10 +52,23 @@ function UserPlaylists() {
|
|||||||
|
|
||||||
function UserSavedTracks() {
|
function UserSavedTracks() {
|
||||||
const userSavedPlaylistId = "user-saved-tracks";
|
const userSavedPlaylistId = "user-saved-tracks";
|
||||||
const { data: userTracks, isError, isLoading } = useSpotifyQuery<SpotifyApi.SavedTrackObject[]>(QueryCacheKeys.userSavedTracks, (spotifyApi) =>
|
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
|
||||||
spotifyApi.getMySavedTracks({ limit: 50 }).then((tracks) => tracks.body.items)
|
QueryCacheKeys.userSavedTracks,
|
||||||
|
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
|
const userTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
|
|
||||||
function handlePlaylistPlayPause(index?: number) {
|
function handlePlaylistPlayPause(index?: number) {
|
||||||
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
||||||
@ -63,26 +80,58 @@ function UserSavedTracks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playlist: SpotifyApi.PlaylistObjectFull = {
|
||||||
|
collaborative: false,
|
||||||
|
description: "User Playlist",
|
||||||
|
tracks: {
|
||||||
|
items: [userTracks ?? []].map(
|
||||||
|
(userTrack) =>
|
||||||
|
(({
|
||||||
|
...userTrack,
|
||||||
|
added_by: "Me",
|
||||||
|
is_local: false,
|
||||||
|
added_at: Date.now(),
|
||||||
|
} as unknown) as SpotifyApi.PlaylistTrackObject)
|
||||||
|
),
|
||||||
|
limit: 20,
|
||||||
|
href: "",
|
||||||
|
next: "",
|
||||||
|
offset: 0,
|
||||||
|
previous: "",
|
||||||
|
total: 20,
|
||||||
|
},
|
||||||
|
external_urls: { spotify: "" },
|
||||||
|
followers: { href: null, total: 2 },
|
||||||
|
href: "",
|
||||||
|
id: userSavedPlaylistId,
|
||||||
|
images: [],
|
||||||
|
name: "User saved track",
|
||||||
|
owner: { external_urls: { spotify: "" }, href: "", id: "Me", type: "user", uri: "spotify:user:me", display_name: "User", followers: { href: null, total: 0 } },
|
||||||
|
public: false,
|
||||||
|
snapshot_id: userSavedPlaylistId + "snapshot",
|
||||||
|
type: "playlist",
|
||||||
|
uri: "spotify:user:me:saved-tracks",
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
|
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
|
||||||
<TrackTableIndex />
|
<TrackTableIndex />
|
||||||
<ScrollArea style="flex: 1; border: none;">
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
||||||
{userTracks?.map(({ track }, index) => (
|
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
||||||
<TrackButton
|
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)}
|
||||||
key={index+track.id}
|
{hasNextPage && (
|
||||||
active={currentPlaylist?.id === userSavedPlaylistId && currentTrack?.id === track.id}
|
<Button
|
||||||
track={track}
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
index={index}
|
text="Load more"
|
||||||
on={{
|
on={{
|
||||||
MouseButtonRelease() {
|
clicked() {
|
||||||
setCurrentTrack(track);
|
fetchNextPage();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onTrackClick={()=>handlePlaylistPlayPause(index)}
|
enabled={!isFetchingNextPage}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
|
@ -174,6 +174,8 @@ function Player(): ReactElement {
|
|||||||
async function stopPlayback() {
|
async function stopPlayback() {
|
||||||
try {
|
try {
|
||||||
if (playerRunning) {
|
if (playerRunning) {
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
await audioPlayer.stop();
|
await audioPlayer.stop();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Direction, Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
|
import { Direction, Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
|
||||||
import { BoxView, Slider, Text, useEventHandler, View } from "@nodegui/react-nodegui";
|
import { BoxView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui";
|
||||||
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
|
||||||
import NodeMpv from "node-mpv";
|
import NodeMpv from "node-mpv";
|
||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
@ -36,7 +35,6 @@ function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProp
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const progressListener = (seconds: number) => {
|
const progressListener = (seconds: number) => {
|
||||||
console.log("seconds", seconds);
|
|
||||||
setTrackTime(seconds);
|
setTrackTime(seconds);
|
||||||
};
|
};
|
||||||
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
|
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
|
||||||
|
@ -5,18 +5,19 @@ import { useLocation, useParams } from "react-router";
|
|||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
import BackButton from "./BackButton";
|
import BackButton from "./BackButton";
|
||||||
import { PlaylistCard } from "./Home";
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
|
||||||
function PlaylistGenreView() {
|
function PlaylistGenreView() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const location = useLocation<{ name: string }>();
|
const location = useLocation<{ name: string }>();
|
||||||
const { data: playlists } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
const { data: playlists, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
||||||
[QueryCacheKeys.genrePlaylists, id],
|
[QueryCacheKeys.genrePlaylists, id],
|
||||||
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id).then((playlistsRes) => playlistsRes.body.playlists.items),
|
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id).then((playlistsRes) => playlistsRes.body.playlists.items),
|
||||||
{ initialData: [] }
|
{ initialData: [] }
|
||||||
);
|
);
|
||||||
|
|
||||||
return <GenreView heading={location.state.name} playlists={playlists ?? []} />;
|
return <GenreView isError={isError} isLoading={isLoading} refetch={refetch} heading={location.state.name} playlists={playlists ?? []} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlaylistGenreView;
|
export default PlaylistGenreView;
|
||||||
@ -26,9 +27,12 @@ interface GenreViewProps {
|
|||||||
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
||||||
loadMore?: QAbstractButtonSignals["clicked"];
|
loadMore?: QAbstractButtonSignals["clicked"];
|
||||||
isLoadable?: boolean;
|
isLoadable?: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GenreView({ heading, playlists, loadMore, isLoadable }: GenreViewProps) {
|
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: GenreViewProps) {
|
||||||
const playlistGenreViewStylesheet = `
|
const playlistGenreViewStylesheet = `
|
||||||
#genre-container{
|
#genre-container{
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -56,6 +60,7 @@ export function GenreView({ heading, playlists, loadMore, isLoadable }: GenreVie
|
|||||||
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
|
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
|
||||||
<ScrollArea id="scroll-view">
|
<ScrollArea id="scroll-view">
|
||||||
<View id="child-container">
|
<View id="child-container">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed loading ${heading}'s playlists`} />
|
||||||
{playlists?.map((playlist, index) => {
|
{playlists?.map((playlist, index) => {
|
||||||
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
||||||
})}
|
})}
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
import React, { FC, useContext, useMemo } from "react";
|
import React, { FC, useContext } from "react";
|
||||||
import { View, Button, ScrollArea, Text } from "@nodegui/react-nodegui";
|
import { View, 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 { QAbstractButtonSignals, QIcon, QMouseEvent, QWidgetSignals } from "@nodegui/nodegui";
|
import { QIcon } from "@nodegui/nodegui";
|
||||||
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";
|
||||||
import { heart, heartRegular, pause, play, stop } from "../icons";
|
import { heart, heartRegular, play, stop } from "../icons";
|
||||||
import { audioPlayer } from "./Player";
|
import { audioPlayer } from "./Player";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
import useTrackReaction from "../hooks/useTrackReaction";
|
|
||||||
import usePlaylistReaction from "../hooks/usePlaylistReaction";
|
import usePlaylistReaction from "../hooks/usePlaylistReaction";
|
||||||
import { msToMinAndSec } from "../helpers/msToMin:sec";
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
|
||||||
export interface PlaylistTrackRes {
|
export interface PlaylistTrackRes {
|
||||||
name: string;
|
name: string;
|
||||||
@ -21,7 +20,7 @@ export interface PlaylistTrackRes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistView: FC = () => {
|
const PlaylistView: FC = () => {
|
||||||
const { setCurrentTrack, currentPlaylist, currentTrack, setCurrentPlaylist } = useContext(playerContext);
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const location = useLocation<{ name: string; thumbnail: string }>();
|
const location = useLocation<{ name: string; thumbnail: string }>();
|
||||||
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
|
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
|
||||||
@ -34,10 +33,10 @@ const PlaylistView: FC = () => {
|
|||||||
{ initialData: [] }
|
{ initialData: [] }
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlaylistPlayPause = (index?: number) => {
|
const handlePlaylistPlayPause = () => {
|
||||||
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
|
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
|
||||||
setCurrentPlaylist({ ...params, ...location.state, tracks });
|
setCurrentPlaylist({ ...params, ...location.state, tracks });
|
||||||
setCurrentTrack(tracks[index ?? 0].track);
|
setCurrentTrack(tracks[0].track);
|
||||||
} else {
|
} else {
|
||||||
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
|
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
|
||||||
setCurrentTrack(undefined);
|
setCurrentTrack(undefined);
|
||||||
@ -45,10 +44,6 @@ const PlaylistView: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
|
||||||
setCurrentTrack(track);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={`flex: 1; flex-direction: 'column';`}>
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
<PlaylistSimpleControls
|
<PlaylistSimpleControls
|
||||||
@ -61,36 +56,10 @@ const PlaylistView: FC = () => {
|
|||||||
{<TrackTableIndex />}
|
{<TrackTableIndex />}
|
||||||
<ScrollArea style={`flex: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>}
|
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} />
|
||||||
{isError && (
|
|
||||||
<>
|
|
||||||
<Text>{`Failed to load ${location.state.name} tracks`}</Text>
|
|
||||||
<Button
|
|
||||||
on={{
|
|
||||||
clicked() {
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
text="Retry"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tracks?.map(({ track }, index) => {
|
{tracks?.map(({ track }, index) => {
|
||||||
if (track) {
|
if (track) {
|
||||||
return (
|
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />;
|
||||||
<TrackButton
|
|
||||||
key={index + track.id}
|
|
||||||
track={track}
|
|
||||||
index={index}
|
|
||||||
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
|
||||||
on={{
|
|
||||||
MouseButtonRelease: () => trackClickHandler(track),
|
|
||||||
}}
|
|
||||||
onTrackClick={() => {
|
|
||||||
handlePlaylistPlayPause(index);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@ -99,62 +68,6 @@ const PlaylistView: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TrackButtonProps {
|
|
||||||
track: SpotifyApi.TrackObjectFull;
|
|
||||||
on: Partial<QWidgetSignals | WidgetEventListeners>;
|
|
||||||
onTrackClick?: QAbstractButtonSignals["clicked"];
|
|
||||||
active: boolean;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrackButton: FC<TrackButtonProps> = ({ track, active, index, on, onTrackClick }) => {
|
|
||||||
const { reactToTrack, isFavorite } = useTrackReaction();
|
|
||||||
|
|
||||||
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
id={active ? "active" : "track-button"}
|
|
||||||
styleSheet={trackButtonStyle}
|
|
||||||
on={{
|
|
||||||
...on,
|
|
||||||
MouseButtonRelease(native: any) {
|
|
||||||
if (new QMouseEvent(native).button() === 1) {
|
|
||||||
(on as WidgetEventListeners).MouseButtonRelease();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}>
|
|
||||||
<Text style="padding: 5px;">{index + 1}</Text>
|
|
||||||
<View style="flex-direction: 'column'; width: '35%';">
|
|
||||||
<Text>{`<h3>${track.name}</h3>`}</Text>
|
|
||||||
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style="width: '25%';">{track.album.name}</Text>
|
|
||||||
<Text style="width: '15%';">{duration}</Text>
|
|
||||||
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
|
|
||||||
<IconButton
|
|
||||||
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
|
|
||||||
on={{
|
|
||||||
clicked() {
|
|
||||||
reactToTrack({ track, added_at: "" });
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton icon={new QIcon(active ? pause : play)} on={{ clicked: onTrackClick }} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trackButtonStyle = `
|
|
||||||
#active{
|
|
||||||
background-color: #34eb71;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
#track-button:hover, #active:hover{
|
|
||||||
background-color: rgba(229, 224, 224, 0.48);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default PlaylistView;
|
export default PlaylistView;
|
||||||
|
|
||||||
export function TrackTableIndex() {
|
export function TrackTableIndex() {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { QIcon, QMouseEvent } from "@nodegui/nodegui";
|
import { CursorShape, QIcon, QKeyEvent, QMouseEvent } from "@nodegui/nodegui";
|
||||||
import { LineEdit, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
import { LineEdit, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useHistory } from "react-router";
|
import { useHistory } from "react-router";
|
||||||
@ -6,14 +6,16 @@ import { QueryCacheKeys } from "../conf";
|
|||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
import { search } from "../icons";
|
import { search } from "../icons";
|
||||||
import { PlaylistCard } from "./Home";
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
import { TrackButton, TrackTableIndex } from "./PlaylistView";
|
|
||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
const history = useHistory<{ searchQuery: string }>();
|
const history = useHistory<{ searchQuery: string }>();
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
const { data: searchResults, isError, refetch } = useSpotifyQuery<SpotifyApi.SearchResponse>(
|
const { data: searchResults, refetch, isError, isLoading } = useSpotifyQuery<SpotifyApi.SearchResponse>(
|
||||||
QueryCacheKeys.search,
|
QueryCacheKeys.search,
|
||||||
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
|
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
|
||||||
{ enabled: false }
|
{ enabled: false }
|
||||||
@ -27,6 +29,7 @@ function Search() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
|
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
|
||||||
<View>
|
<View>
|
||||||
@ -37,6 +40,13 @@ function Search() {
|
|||||||
textChanged(t) {
|
textChanged(t) {
|
||||||
setSearchQuery(t);
|
setSearchQuery(t);
|
||||||
},
|
},
|
||||||
|
KeyRelease(native: any) {
|
||||||
|
const key = new QKeyEvent(native);
|
||||||
|
const isEnter = key.key() === 16777220;
|
||||||
|
if (isEnter) {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} />
|
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} />
|
||||||
@ -44,22 +54,33 @@ function Search() {
|
|||||||
<ScrollArea style="flex: 1;">
|
<ScrollArea style="flex: 1;">
|
||||||
<View style="flex-direction: 'column'; flex: 1;">
|
<View style="flex-direction: 'column'; flex: 1;">
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<Text>{`<h2>Songs</h2>`}</Text>
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) {
|
||||||
|
history.push("/search/songs", { searchQuery });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}>{`<h2>Songs</h2>`}</Text>
|
||||||
<TrackTableIndex />
|
<TrackTableIndex />
|
||||||
|
{placeholder}
|
||||||
{searchResults?.tracks?.items.map((track, index) => (
|
{searchResults?.tracks?.items.map((track, index) => (
|
||||||
<TrackButton key={index+track.id} active={false} index={index} track={track} on={{}} onTrackClick={() => {}} />
|
<TrackButton key={index + track.id} index={index} track={track} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<Text
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
on={{
|
on={{
|
||||||
MouseButtonRelease(native: any) {
|
MouseButtonRelease(native: any) {
|
||||||
if (new QMouseEvent(native).button() === 1) {
|
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) {
|
||||||
history.push("/search/playlists", { searchQuery });
|
history.push("/search/playlists", { searchQuery });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}>{`<h2>Playlists</h2>`}</Text>
|
}}>{`<h2>Playlists</h2>`}</Text>
|
||||||
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
|
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
|
||||||
|
{placeholder}
|
||||||
{searchResults?.playlists?.items.map((playlist, index) => (
|
{searchResults?.playlists?.items.map((playlist, index) => (
|
||||||
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
||||||
))}
|
))}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
@ -6,17 +6,22 @@ import { GenreView } from "./PlaylistGenreView";
|
|||||||
|
|
||||||
function SearchResultPlaylistCollection() {
|
function SearchResultPlaylistCollection() {
|
||||||
const location = useLocation<{ searchQuery: string }>();
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
QueryCacheKeys.searchPlaylist,
|
QueryCacheKeys.searchPlaylist,
|
||||||
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
return (lastPage.playlists?.offset ?? 0) + 1;
|
if (lastPage.playlists?.next) {
|
||||||
|
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<GenreView
|
<GenreView
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading || isFetchingNextPage}
|
||||||
|
refetch={refetch}
|
||||||
heading={"Search: " + location.state.searchQuery}
|
heading={"Search: " + location.state.searchQuery}
|
||||||
playlists={
|
playlists={
|
||||||
(searchResults?.pages
|
(searchResults?.pages
|
||||||
@ -24,10 +29,8 @@ function SearchResultPlaylistCollection() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
|
||||||
}
|
}
|
||||||
loadMore={() => {
|
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
||||||
fetchNextPage();
|
isLoadable={!isFetchingNextPage}
|
||||||
}}
|
|
||||||
isLoadable={hasNextPage || !isFetchingNextPage}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
58
src/components/SearchResultSongsCollection.tsx
Normal file
58
src/components/SearchResultSongsCollection.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
|
function SearchResultSongsCollection() {
|
||||||
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
|
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
|
QueryCacheKeys.searchSongs,
|
||||||
|
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.tracks?.next) {
|
||||||
|
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text>{`
|
||||||
|
<center>
|
||||||
|
<h2>Search: ${location.state.searchQuery}</h2>
|
||||||
|
</center>
|
||||||
|
`}</Text>
|
||||||
|
<TrackTableIndex />
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
||||||
|
{searchResults?.pages
|
||||||
|
.map((searchResult) => searchResult.tracks?.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1)
|
||||||
|
.map((track, index) => track && <TrackButton key={index + track.id} index={index} track={track} />)}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
|
text="Load more"
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchResultSongsCollection;
|
@ -1,24 +0,0 @@
|
|||||||
import { View, Button, Text } from "@nodegui/react-nodegui";
|
|
||||||
import React from "react"
|
|
||||||
|
|
||||||
interface ErrorAppletProps {
|
|
||||||
message?: string;
|
|
||||||
reload: Function;
|
|
||||||
helps?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorApplet({ message, reload, helps }: ErrorAppletProps) {
|
|
||||||
return (
|
|
||||||
<View style="flex-direction: 'column'; align-items: 'center';">
|
|
||||||
<Text openExternalLinks>{`
|
|
||||||
<h3>${message ? message : 'An error occured'}</h3>
|
|
||||||
${helps ? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
|
|
||||||
</p>`: ``
|
|
||||||
}
|
|
||||||
`}</Text>
|
|
||||||
<Button on={{ clicked() { reload() } }} text="Reload" />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorApplet;
|
|
56
src/components/shared/PlaceholderApplet.tsx
Normal file
56
src/components/shared/PlaceholderApplet.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { View, Button, Text } from "@nodegui/react-nodegui";
|
||||||
|
import { QLabel, QMovie, } from "@nodegui/nodegui";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { loadingSpinner } from "../../icons";
|
||||||
|
|
||||||
|
interface ErrorAppletProps {
|
||||||
|
error: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
message?: string;
|
||||||
|
reload: Function;
|
||||||
|
helps?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) {
|
||||||
|
const textRef = useRef<QLabel>();
|
||||||
|
const movie = new QMovie();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movie.setFileName(loadingSpinner);
|
||||||
|
textRef.current?.setMovie(movie);
|
||||||
|
textRef.current?.show();
|
||||||
|
movie.start();
|
||||||
|
}, []);
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; justify-content: 'center'; align-items: 'center';">
|
||||||
|
<Text ref={textRef} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
return (
|
||||||
|
<View style="flex-direction: 'column'; align-items: 'center';">
|
||||||
|
<Text openExternalLinks>{`
|
||||||
|
<h3>${message ? message : "An error occured"}</h3>
|
||||||
|
${
|
||||||
|
helps
|
||||||
|
? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
|
||||||
|
</p>`
|
||||||
|
: ``
|
||||||
|
}
|
||||||
|
`}</Text>
|
||||||
|
<Button
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reload();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
text="Reload"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaceholderApplet;
|
123
src/components/shared/PlaylistCard.tsx
Normal file
123
src/components/shared/PlaylistCard.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { CursorShape, QIcon, QMouseEvent } from '@nodegui/nodegui';
|
||||||
|
import { Text, View } from '@nodegui/react-nodegui';
|
||||||
|
import React, { useContext, useMemo, useState } from 'react'
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
import { QueryCacheKeys } from '../../conf';
|
||||||
|
import playerContext from '../../context/playerContext';
|
||||||
|
import { generateRandomColor, getDarkenForeground } from '../../helpers/RandomColor';
|
||||||
|
import showError from '../../helpers/showError';
|
||||||
|
import usePlaylistReaction from '../../hooks/usePlaylistReaction';
|
||||||
|
import useSpotifyQuery from '../../hooks/useSpotifyQuery';
|
||||||
|
import { heart, heartRegular, pause, play } from '../../icons';
|
||||||
|
import { audioPlayer } from '../Player';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface PlaylistCardProps {
|
||||||
|
playlist: SpotifyApi.PlaylistObjectSimplified;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
||||||
|
const { id, description, name, images } = playlist;
|
||||||
|
const history = useHistory();
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
||||||
|
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), {
|
||||||
|
initialData: [],
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
|
||||||
|
|
||||||
|
const handlePlaylistPlayPause = async () => {
|
||||||
|
try {
|
||||||
|
const { data: tracks, isSuccess } = await refetch();
|
||||||
|
if (currentPlaylist?.id !== id && isSuccess && tracks) {
|
||||||
|
setCurrentPlaylist({ tracks, id, name, thumbnail: images[0].url });
|
||||||
|
setCurrentTrack(tracks[0].track);
|
||||||
|
} else {
|
||||||
|
await audioPlayer.stop();
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed adding playlist to queue]: ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function gotoPlaylist(native?: any) {
|
||||||
|
const key = new QMouseEvent(native);
|
||||||
|
if (key.button() === 1) {
|
||||||
|
history.push(`/playlist/${id}`, { name, thumbnail: images[0].url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColor1 = useMemo(() => generateRandomColor(), []);
|
||||||
|
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
|
||||||
|
|
||||||
|
const playlistStyleSheet = `
|
||||||
|
#playlist-container{
|
||||||
|
width: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 150px;
|
||||||
|
background-color: ${bgColor1};
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
#playlist-container:hover{
|
||||||
|
border: 1px solid green;
|
||||||
|
}
|
||||||
|
#playlist-container:clicked{
|
||||||
|
border: 5px solid green;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
id="playlist-container"
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
styleSheet={playlistStyleSheet}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease: gotoPlaylist,
|
||||||
|
HoverEnter() {
|
||||||
|
setHovered(true);
|
||||||
|
},
|
||||||
|
HoverLeave() {
|
||||||
|
setHovered(false);
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
{/* <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} /> */}
|
||||||
|
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist }}>
|
||||||
|
{`
|
||||||
|
<center>
|
||||||
|
<h3>${name}</h3>
|
||||||
|
<p>${description}</p>
|
||||||
|
</center>
|
||||||
|
`}
|
||||||
|
</Text>
|
||||||
|
{(hovered || currentPlaylist?.id === id) && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
style={`position: absolute; bottom: 30px; left: '55%'; background-color: ${color};`}
|
||||||
|
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reactToPlaylist(playlist);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
|
||||||
|
style={`position: absolute; bottom: 30px; left: '80%'; background-color: ${color};`}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
handlePlaylistPlayPause();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaylistCard;
|
85
src/components/shared/TrackButton.tsx
Normal file
85
src/components/shared/TrackButton.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { QIcon, QMouseEvent } from "@nodegui/nodegui";
|
||||||
|
import { Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { FC, useContext, useMemo } from "react";
|
||||||
|
import playerContext from "../../context/playerContext";
|
||||||
|
import { msToMinAndSec } from "../../helpers/msToMin:sec";
|
||||||
|
import useTrackReaction from "../../hooks/useTrackReaction";
|
||||||
|
import { heart, heartRegular, pause, play } from "../../icons";
|
||||||
|
import { audioPlayer } from "../Player";
|
||||||
|
import IconButton from "./IconButton";
|
||||||
|
|
||||||
|
export interface TrackButtonProps {
|
||||||
|
track: SpotifyApi.TrackObjectFull;
|
||||||
|
playlist?: SpotifyApi.PlaylistObjectFull;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
|
||||||
|
const { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
|
||||||
|
const handlePlaylistPlayPause = (index?: number) => {
|
||||||
|
if (currentPlaylist?.id !== playlist?.id && playlist?.tracks) {
|
||||||
|
setCurrentPlaylist({ id: playlist.id, name: playlist.name, thumbnail: playlist.images[0].url, tracks: playlist.tracks.items });
|
||||||
|
setCurrentTrack(playlist.tracks.items[index ?? 0].track);
|
||||||
|
} else {
|
||||||
|
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
||||||
|
setCurrentTrack(track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
|
||||||
|
const active = (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) || currentTrack?.id === track.id;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
id={active?"active":"track-button"}
|
||||||
|
styleSheet={trackButtonStyle}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (new QMouseEvent(native).button() === 1 && playlist) {
|
||||||
|
handlePlaylistPlayPause(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Text style="padding: 5px;">{index + 1}</Text>
|
||||||
|
<View style="flex-direction: 'column'; width: '35%';">
|
||||||
|
<Text>{`<h3>${track.name}</h3>`}</Text>
|
||||||
|
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style="width: '25%';">{track.album.name}</Text>
|
||||||
|
<Text style="width: '15%';">{duration}</Text>
|
||||||
|
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reactToTrack({ track, added_at: "" });
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(active ? pause : play)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
trackClickHandler(track);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackButtonStyle = `
|
||||||
|
#active{
|
||||||
|
background-color: #34eb71;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
#track-button:hover{
|
||||||
|
background-color: rgba(229, 224, 224, 0.48);
|
||||||
|
}
|
||||||
|
`;
|
@ -17,5 +17,6 @@ export enum QueryCacheKeys{
|
|||||||
userPlaylists = "user-palylists",
|
userPlaylists = "user-palylists",
|
||||||
userSavedTracks = "user-saved-tracks",
|
userSavedTracks = "user-saved-tracks",
|
||||||
search = "search",
|
search = "search",
|
||||||
searchPlaylist="searchPlaylist"
|
searchPlaylist = "searchPlaylist",
|
||||||
|
searchSongs = "searchSongs"
|
||||||
}
|
}
|
@ -1,13 +1,18 @@
|
|||||||
import { useQueryClient } from "react-query";
|
import { useQueryClient } from "react-query";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
|
||||||
import useSpotifyMutation from "./useSpotifyMutation";
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
import useSpotifyQuery from "./useSpotifyQuery";
|
import useSpotifyQuery from "./useSpotifyQuery";
|
||||||
|
|
||||||
function useTrackReaction() {
|
function useTrackReaction() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: favoriteTracks } = useSpotifyQuery<SpotifyApi.SavedTrackObject[]>(QueryCacheKeys.userSavedTracks, (spotifyApi) =>
|
const { data: userSavedTracks } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(QueryCacheKeys.userSavedTracks, (spotifyApi, { pageParam }) =>
|
||||||
spotifyApi.getMySavedTracks({ limit: 50 }).then((tracks) => tracks.body.items)
|
spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body)
|
||||||
);
|
);
|
||||||
|
const favoriteTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>(
|
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>(
|
||||||
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
|
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
|
||||||
{
|
{
|
||||||
|
@ -8,6 +8,7 @@ import _heart from "../assets/heart-solid.svg"
|
|||||||
import _random from "../assets/random-solid.svg"
|
import _random from "../assets/random-solid.svg"
|
||||||
import _stop from "../assets/stop-solid.svg"
|
import _stop from "../assets/stop-solid.svg"
|
||||||
import _search from "../assets/search-solid.svg";
|
import _search from "../assets/search-solid.svg";
|
||||||
|
import _loadingSpinner from "../assets/loading-spinner.gif";
|
||||||
|
|
||||||
export const play = _play;
|
export const play = _play;
|
||||||
export const pause = _pause;
|
export const pause = _pause;
|
||||||
@ -19,3 +20,4 @@ export const heart = _heart;
|
|||||||
export const random = _random;
|
export const random = _random;
|
||||||
export const stop = _stop;
|
export const stop = _stop;
|
||||||
export const search = _search;
|
export const search = _search;
|
||||||
|
export const loadingSpinner = _loadingSpinner;
|
@ -10,6 +10,7 @@ import CurrentPlaylist from "./components/CurrentPlaylist";
|
|||||||
import Library from "./components/Library";
|
import Library from "./components/Library";
|
||||||
import Search from "./components/Search";
|
import Search from "./components/Search";
|
||||||
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
|
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
|
||||||
|
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
|
||||||
|
|
||||||
function Routes() {
|
function Routes() {
|
||||||
const { isLoggedIn } = useContext(authContext);
|
const { isLoggedIn } = useContext(authContext);
|
||||||
@ -46,6 +47,9 @@ function Routes() {
|
|||||||
<Route exact path="/search/playlists">
|
<Route exact path="/search/playlists">
|
||||||
<SearchResultPlaylistCollection />
|
<SearchResultPlaylistCollection />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route exact path="/search/songs">
|
||||||
|
<SearchResultSongsCollection />
|
||||||
|
</Route>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user