Added: SearchResult comp enhanced, Error, loading, retry handler comp, pagination

This commit is contained in:
KRTirtho 2021-03-19 15:44:41 +06:00
parent d695172804
commit e91a218a8c
21 changed files with 487 additions and 300 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

BIN
assets/loading-spinner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -53,7 +53,6 @@ function RootApp() {
if (nativeEv) {
const event = new QKeyEvent(nativeEv);
const eventKey = event.key();
console.log("eventKey:", eventKey);
if (audioPlayer.isRunning() && currentTrack)
switch (eventKey) {
case 32: //space

View File

@ -1,15 +1,22 @@
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React, { useContext } from "react";
import playerContext from "../context/playerContext";
import { TrackButton, TrackTableIndex } from "./PlaylistView";
import { TrackTableIndex } from "./PlaylistView";
import { TrackButton } from "./shared/TrackButton";
function CurrentPlaylist() {
const { currentPlaylist, currentTrack, setCurrentTrack } = useContext(playerContext);
const { currentPlaylist, currentTrack } = useContext(playerContext);
if (!currentPlaylist && !currentTrack) {
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 (
<View style="flex: 1; flex-direction: 'column';">
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
@ -17,16 +24,7 @@ function CurrentPlaylist() {
<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}
track={track}
index={index}
on={{ MouseButtonRelease: () => setCurrentTrack(track) }}
onTrackClick={() => {}}
/>
);
return <TrackButton key={index + track.id} track={track} index={index} />;
})}
</View>
</ScrollArea>

View File

@ -1,21 +1,14 @@
import React, { useContext, useMemo, useState } from "react";
import { Button, ScrollArea, View, Text } from "@nodegui/react-nodegui";
import React from "react";
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
import { useHistory } from "react-router";
import CachedImage from "./shared/CachedImage";
import { CursorShape, QIcon, QMouseEvent } from "@nodegui/nodegui";
import { CursorShape, QMouseEvent } from "@nodegui/nodegui";
import { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import ErrorApplet from "./shared/ErrorApplet";
import IconButton from "./shared/IconButton";
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";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
function Home() {
const { data: categories, isError, isRefetchError, refetch } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
const { data: categories, isError, refetch, isLoading } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
QueryCacheKeys.categories,
(spotifyApi) => spotifyApi.getCategories({ country: "US" }).then((categoriesReceived) => categoriesReceived.body.categories.items),
{ initialData: [] }
@ -24,7 +17,7 @@ function Home() {
return (
<ScrollArea style={`flex-grow: 1; border: none; 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) => {
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
})}
@ -95,110 +88,3 @@ const CategoryCard = ({ id, name }: CategoryCardProps) => {
</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>
);
});

View File

@ -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 { Redirect, Route } from "react-router";
import { QueryCacheKeys } from "../conf";
import playerContext from "../context/playerContext";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import { PlaylistCard } from "./Home";
import { PlaylistSimpleControls, TrackButton, TrackTableIndex } from "./PlaylistView";
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
import { TrackButton } from "./shared/TrackButton";
import { TabMenuItem } from "./TabMenu";
function Library() {
@ -38,6 +41,7 @@ function UserPlaylists() {
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';">
<PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />
{userPlaylists?.map((playlist, index) => (
<PlaylistCard key={index + playlist.id} playlist={playlist} />
))}
@ -48,10 +52,23 @@ function UserPlaylists() {
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 { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
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) {
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 (
<View style="flex: 1; flex-direction: 'column';">
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
<TrackTableIndex/>
<TrackTableIndex />
<ScrollArea style="flex: 1; border: none;">
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
{userTracks?.map(({ track }, index) => (
<TrackButton
key={index+track.id}
active={currentPlaylist?.id === userSavedPlaylistId && currentTrack?.id === track.id}
track={track}
index={index}
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)}
{hasNextPage && (
<Button
style="flex-grow: 0; align-self: 'center';"
text="Load more"
on={{
MouseButtonRelease() {
setCurrentTrack(track);
clicked() {
fetchNextPage();
},
}}
onTrackClick={()=>handlePlaylistPlayPause(index)}
enabled={!isFetchingNextPage}
/>
))}
)}
</View>
</ScrollArea>
</View>

View File

@ -174,6 +174,8 @@ function Player(): ReactElement {
async function stopPlayback() {
try {
if (playerRunning) {
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
await audioPlayer.stop();
}
} catch (error) {

View File

@ -1,6 +1,5 @@
import { Direction, Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
import { BoxView, Slider, Text, useEventHandler, View } from "@nodegui/react-nodegui";
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
import { BoxView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui";
import NodeMpv from "node-mpv";
import React, { useContext, useEffect, useState } from "react";
import playerContext from "../context/playerContext";
@ -36,7 +35,6 @@ function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProp
useEffect(() => {
const progressListener = (seconds: number) => {
console.log("seconds", seconds);
setTrackTime(seconds);
};
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {

View File

@ -5,18 +5,19 @@ import { useLocation, useParams } from "react-router";
import { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import BackButton from "./BackButton";
import { PlaylistCard } from "./Home";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
function PlaylistGenreView() {
const { id } = useParams<{ id: string }>();
const location = useLocation<{ name: string }>();
const { data: playlists } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
const { data: playlists, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
[QueryCacheKeys.genrePlaylists, id],
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id).then((playlistsRes) => playlistsRes.body.playlists.items),
{ 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;
@ -26,9 +27,12 @@ interface GenreViewProps {
playlists: SpotifyApi.PlaylistObjectSimplified[];
loadMore?: QAbstractButtonSignals["clicked"];
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 = `
#genre-container{
flex-direction: column;
@ -56,6 +60,7 @@ export function GenreView({ heading, playlists, loadMore, isLoadable }: GenreVie
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
<ScrollArea id="scroll-view">
<View id="child-container">
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed loading ${heading}'s playlists`} />
{playlists?.map((playlist, index) => {
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
})}

View File

@ -1,18 +1,17 @@
import React, { FC, useContext, useMemo } from "react";
import { View, Button, ScrollArea, Text } from "@nodegui/react-nodegui";
import React, { FC, useContext } from "react";
import { View, ScrollArea, Text } from "@nodegui/react-nodegui";
import BackButton from "./BackButton";
import { useLocation, useParams } from "react-router";
import { QAbstractButtonSignals, QIcon, QMouseEvent, QWidgetSignals } from "@nodegui/nodegui";
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
import { QIcon } from "@nodegui/nodegui";
import playerContext from "../context/playerContext";
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 { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import useTrackReaction from "../hooks/useTrackReaction";
import usePlaylistReaction from "../hooks/usePlaylistReaction";
import { msToMinAndSec } from "../helpers/msToMin:sec";
import { TrackButton } from "./shared/TrackButton";
import PlaceholderApplet from "./shared/PlaceholderApplet";
export interface PlaylistTrackRes {
name: string;
@ -21,7 +20,7 @@ export interface PlaylistTrackRes {
}
const PlaylistView: FC = () => {
const { setCurrentTrack, currentPlaylist, currentTrack, setCurrentPlaylist } = useContext(playerContext);
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
const params = useParams<{ id: string }>();
const location = useLocation<{ name: string; thumbnail: string }>();
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
@ -34,10 +33,10 @@ const PlaylistView: FC = () => {
{ initialData: [] }
);
const handlePlaylistPlayPause = (index?: number) => {
const handlePlaylistPlayPause = () => {
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[index ?? 0].track);
setCurrentTrack(tracks[0].track);
} else {
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
setCurrentTrack(undefined);
@ -45,10 +44,6 @@ const PlaylistView: FC = () => {
}
};
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
setCurrentTrack(track);
};
return (
<View style={`flex: 1; flex-direction: 'column';`}>
<PlaylistSimpleControls
@ -61,36 +56,10 @@ const PlaylistView: FC = () => {
{<TrackTableIndex />}
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
{isLoading && <Text>{`Loading Tracks...`}</Text>}
{isError && (
<>
<Text>{`Failed to load ${location.state.name} tracks`}</Text>
<Button
on={{
clicked() {
refetch();
},
}}
text="Retry"
/>
</>
)}
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} />
{tracks?.map(({ track }, index) => {
if (track) {
return (
<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);
}}
/>
);
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />;
}
})}
</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 function TrackTableIndex() {

View File

@ -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 React, { useState } from "react";
import { useHistory } from "react-router";
@ -6,14 +6,16 @@ import { QueryCacheKeys } from "../conf";
import showError from "../helpers/showError";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import { search } from "../icons";
import { PlaylistCard } from "./Home";
import { TrackButton, TrackTableIndex } from "./PlaylistView";
import { TrackTableIndex } from "./PlaylistView";
import IconButton from "./shared/IconButton";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
import { TrackButton } from "./shared/TrackButton";
function Search() {
const history = useHistory<{ searchQuery: 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,
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
{ enabled: false }
@ -27,6 +29,7 @@ function Search() {
}
}
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
return (
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
<View>
@ -37,6 +40,13 @@ function Search() {
textChanged(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 }} />
@ -44,24 +54,35 @@ function Search() {
<ScrollArea style="flex: 1;">
<View style="flex-direction: 'column'; flex: 1;">
<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 />
{placeholder}
{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 style="flex: 1; flex-direction: 'column';">
<Text
cursor={CursorShape.PointingHandCursor}
on={{
MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1) {
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) {
history.push("/search/playlists", { searchQuery });
}
},
}}>{`<h2>Playlists</h2>`}</Text>
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
{placeholder}
{searchResults?.playlists?.items.map((playlist, index) => (
<PlaylistCard key={index+playlist.id} playlist={playlist} />
<PlaylistCard key={index + playlist.id} playlist={playlist} />
))}
</View>
</View>

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
import { useLocation } from "react-router";
import { QueryCacheKeys } from "../conf";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
@ -6,28 +6,31 @@ import { GenreView } from "./PlaylistGenreView";
function SearchResultPlaylistCollection() {
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,
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
{
getNextPageParam: (lastPage) => {
return (lastPage.playlists?.offset ?? 0) + 1;
if (lastPage.playlists?.next) {
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0);
}
},
}
);
return (
<GenreView
heading={"Search: "+location.state.searchQuery}
isError={isError}
isLoading={isLoading || isFetchingNextPage}
refetch={refetch}
heading={"Search: " + location.state.searchQuery}
playlists={
(searchResults?.pages
?.map((page) => page.playlists?.items)
.filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
}
loadMore={() => {
fetchNextPage();
}}
isLoadable={hasNextPage || !isFetchingNextPage}
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
isLoadable={!isFetchingNextPage}
/>
);
}

View 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;

View File

@ -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;

View 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;

View 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;

View 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);
}
`;

View File

@ -17,5 +17,6 @@ export enum QueryCacheKeys{
userPlaylists = "user-palylists",
userSavedTracks = "user-saved-tracks",
search = "search",
searchPlaylist="searchPlaylist"
searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs"
}

View File

@ -1,13 +1,18 @@
import { useQueryClient } from "react-query";
import { QueryCacheKeys } from "../conf";
import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
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 { data: userSavedTracks } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(QueryCacheKeys.userSavedTracks, (spotifyApi, { pageParam }) =>
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>(
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
{

View File

@ -8,6 +8,7 @@ import _heart from "../assets/heart-solid.svg"
import _random from "../assets/random-solid.svg"
import _stop from "../assets/stop-solid.svg"
import _search from "../assets/search-solid.svg";
import _loadingSpinner from "../assets/loading-spinner.gif";
export const play = _play;
export const pause = _pause;
@ -18,4 +19,5 @@ export const heartRegular = _heartRegular;
export const heart = _heart;
export const random = _random;
export const stop = _stop;
export const search = _search;
export const search = _search;
export const loadingSpinner = _loadingSpinner;

View File

@ -10,6 +10,7 @@ import CurrentPlaylist from "./components/CurrentPlaylist";
import Library from "./components/Library";
import Search from "./components/Search";
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
function Routes() {
const { isLoggedIn } = useContext(authContext);
@ -46,6 +47,9 @@ function Routes() {
<Route exact path="/search/playlists">
<SearchResultPlaylistCollection />
</Route>
<Route exact path="/search/songs">
<SearchResultSongsCollection />
</Route>
</>
);
}