ESLint, Pretteir configured, Library Followed Artists route

This commit is contained in:
KRTirtho 2021-06-16 19:36:56 +06:00
parent f54ce27d77
commit 7024da3fed
53 changed files with 6803 additions and 3896 deletions

45
.eslintrc.js Normal file
View File

@ -0,0 +1,45 @@
/**
* @type {import("eslint").Linter.Config}
*/
const config = {
parser: "@typescript-eslint/parser",
extends: [
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
plugins: ["react", "@typescript-eslint"],
env: {
browser: true,
es6: true,
},
settings: {
react: {
version: "16.14.0",
},
},
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
},
ignorePatterns: [".eslintrc.js", "./src/reportWebVitals.ts"],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: "module",
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"@typescript-eslint/no-var-requires": "off",
"prettier/prettier": "warn",
"react/prop-types": "off",
"linebreak-style": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
},
};
module.exports = config;

11
.prettierrc.js Normal file
View File

@ -0,0 +1,11 @@
/**
* @type {import("prettier").Options}
*/
const config = {
tabWidth: 4,
printWidth: 90,
semi: true,
endOfLine: "auto",
trailingComma: "all",
};
module.exports = config;

4098
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "webpack --mode=production", "build": "webpack --mode=production",
"dev": "nodemon -w src/** -e ts,tsx -x 'node esbuild.config.mjs'", "dev": "nodemon -w src/ -e ts,tsx -x 'node esbuild.config.mjs'",
"check-types": "tsc --noEmit --watch", "check-types": "nodemon --quiet -e tsx,ts -w src/ -x tsc --noEmit --pretty",
"start": "cd dist && qode index.js", "start": "cd dist && qode index.js",
"start:watch": "nodemon -w dist -e js -x \"npm start\"", "start:watch": "nodemon -w dist -e js -x \"npm start\"",
"start-dev": "concurrently -n \"esbuild,spotube,tsc\" -p \"{name}\" -c \"bgYellow.black.bold,bgGreen.black.bold,bgBlue.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\" \"npm run check-types\"", "start-dev": "concurrently -n \"esbuild,spotube,tsc\" -p \"{name}\" -c \"bgYellow.black.bold,bgGreen.black.bold,bgBlue.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\" \"npm run check-types\"",
@ -60,16 +60,26 @@
"@types/spotify-web-api-node": "^5.0.0", "@types/spotify-web-api-node": "^5.0.0",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/webpack-env": "^1.15.3", "@types/webpack-env": "^1.15.3",
"@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0",
"@vitejs/plugin-react-refresh": "^1.3.3", "@vitejs/plugin-react-refresh": "^1.3.3",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"concurrently": "^6.0.0", "concurrently": "^6.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"esbuild": "^0.12.8", "esbuild": "^0.12.8",
"esbuild-loader": "^2.13.1", "esbuild-loader": "^2.13.1",
"eslint": "^7.28.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.2.0", "fork-ts-checker-webpack-plugin": "^6.2.0",
"native-addon-loader": "^2.0.1", "native-addon-loader": "^2.0.1",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"prettier": "^2.3.1",
"typescript": "^4.2.3", "typescript": "^4.2.3",
"webpack": "^5.27.0", "webpack": "^5.27.0",
"webpack-cli": "^4.4.0" "webpack-cli": "^4.4.0"

View File

@ -1,6 +1,13 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Window, hot, View } from "@nodegui/react-nodegui"; import { Window, hot, View } from "@nodegui/react-nodegui";
import { QIcon, QMainWindow, WidgetEventTypes, WindowState, QShortcut, QKeySequence } from "@nodegui/nodegui"; import {
QIcon,
QMainWindow,
WidgetEventTypes,
WindowState,
QShortcut,
QKeySequence,
} from "@nodegui/nodegui";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import Routes from "./routes"; import Routes from "./routes";
import { LocalStorage } from "node-localstorage"; import { LocalStorage } from "node-localstorage";
@ -16,7 +23,9 @@ import fs from "fs";
import path from "path"; import path from "path";
import { confDir, LocalStorageKeys } from "./conf"; import { confDir, LocalStorageKeys } from "./conf";
import spotubeIcon from "../assets/icon.svg"; import spotubeIcon from "../assets/icon.svg";
import preferencesContext, { PreferencesContextProperties } from "./context/preferencesContext"; import preferencesContext, {
PreferencesContextProperties,
} from "./context/preferencesContext";
export interface Credentials { export interface Credentials {
clientId: string; clientId: string;
@ -77,30 +86,53 @@ function RootApp() {
useEffect(() => { useEffect(() => {
// saving changed credentials to storage // saving changed credentials to storage
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials)); localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
if (credentials.clientId && credentials.clientSecret && !localStorage.getItem(LocalStorageKeys.refresh_token)) { if (
credentials.clientId &&
credentials.clientSecret &&
!localStorage.getItem(LocalStorageKeys.refresh_token)
) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.get<null, null, null, { code: string }>("/auth/spotify/callback", async (req, res) => { app.get<null, null, null, { code: string }>(
"/auth/spotify/callback",
async (req, res) => {
try { try {
spotifyApi.setClientId(credentials.clientId); spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret); spotifyApi.setClientSecret(credentials.clientSecret);
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code); const { body: authRes } = await spotifyApi.authorizationCodeGrant(
req.query.code,
);
setAccess_token(authRes.access_token); setAccess_token(authRes.access_token);
localStorage.setItem(LocalStorageKeys.refresh_token, authRes.refresh_token); localStorage.setItem(
LocalStorageKeys.refresh_token,
authRes.refresh_token,
);
setIsLoggedIn(true); setIsLoggedIn(true);
return res.end(); return res.end();
} catch (error) { } catch (error) {
console.error("Failed to fullfil code grant flow: ", error); console.error("Failed to fullfil code grant flow: ", error);
} }
}); },
);
const server = app.listen(4304, () => { const server = app.listen(4304, () => {
console.log("Server is running"); console.log("Server is running");
spotifyApi.setClientId(credentials.clientId); spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret); spotifyApi.setClientSecret(credentials.clientSecret);
open(spotifyApi.createAuthorizeURL(["user-library-read", "playlist-read-private", "user-library-modify", "playlist-modify-private", "playlist-modify-public"], "xxxyyysssddd")).catch((e) => open(
console.error("Opening IPC connection with browser failed: ", e) spotifyApi.createAuthorizeURL(
[
"user-library-read",
"playlist-read-private",
"user-library-modify",
"playlist-modify-private",
"playlist-modify-public",
],
"xxxyyysssddd",
),
).catch((e) =>
console.error("Opening IPC connection with browser failed: ", e),
); );
}); });
return () => { return () => {
@ -118,7 +150,9 @@ function RootApp() {
useEffect(() => { useEffect(() => {
const onWindowClose = () => { const onWindowClose = () => {
if (audioPlayer.isRunning()) { if (audioPlayer.isRunning()) {
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e)); audioPlayer
.stop()
.catch((e) => console.error("Failed to quit MPV player: ", e));
} }
}; };
@ -144,7 +178,11 @@ function RootApp() {
async function spaceAction() { async function spaceAction() {
try { try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause(); currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isPaused())
? await audioPlayer.play()
: await audioPlayer.pause();
console.log("You pressed SPACE"); console.log("You pressed SPACE");
} catch (error) { } catch (error) {
showError(error, "[Failed to play/pause audioPlayer]: "); showError(error, "[Failed to play/pause audioPlayer]: ");
@ -152,7 +190,10 @@ function RootApp() {
} }
async function rightAction() { async function rightAction() {
try { try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5)); currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(+5));
console.log("You pressed RIGHT"); console.log("You pressed RIGHT");
} catch (error) { } catch (error) {
showError(error, "[Failed to seek audioPlayer]: "); showError(error, "[Failed to seek audioPlayer]: ");
@ -160,7 +201,10 @@ function RootApp() {
} }
async function leftAction() { async function leftAction() {
try { try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5)); currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(-5));
console.log("You pressed LEFT"); console.log("You pressed LEFT");
} catch (error) { } catch (error) {
showError(error, "[Failed to seek audioPlayer]: "); showError(error, "[Failed to seek audioPlayer]: ");
@ -183,13 +227,39 @@ function RootApp() {
}); });
return ( return (
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}> <Window
ref={windowRef}
windowState={WindowState.WindowMaximized}
windowIcon={winIcon}
windowTitle="Spotube"
minSize={minSize}
>
<MemoryRouter> <MemoryRouter>
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}> <authContext.Provider
<preferencesContext.Provider value={{ ...preferences, setPreferences }}> value={{
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}> isLoggedIn,
setIsLoggedIn,
access_token,
setAccess_token,
...credentials,
setCredentials,
}}
>
<preferencesContext.Provider
value={{ ...preferences, setPreferences }}
>
<playerContext.Provider
value={{
currentPlaylist,
currentTrack,
setCurrentPlaylist,
setCurrentTrack,
}}
>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}> <View
style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}
>
<Routes /> <Routes />
{isLoggedIn && <Player />} {isLoggedIn && <Player />}
</View> </View>

10
src/components/Artist.tsx Normal file
View File

@ -0,0 +1,10 @@
import { ScrollArea } from "@nodegui/react-nodegui";
import React from "react";
function Artist() {
return (
<ScrollArea style="min-height: 750px; max-height: 1980px; max-width: 1980px; min-width: 700px; border: none;"></ScrollArea>
);
}
export default Artist;

View File

@ -7,7 +7,13 @@ import IconButton from "./shared/IconButton";
function BackButton(): ReactElement { function BackButton(): ReactElement {
const history = useHistory(); const history = useHistory();
return <IconButton style={"align-self: flex-start;"} icon={new QIcon(angleLeft)} on={{ clicked: () => history.goBack() }} />; return (
<IconButton
style={"align-self: flex-start;"}
icon={new QIcon(angleLeft)}
on={{ clicked: () => history.goBack() }}
/>
);
} }
export default BackButton; export default BackButton;

View File

@ -8,13 +8,15 @@ function CurrentPlaylist() {
const { currentPlaylist, currentTrack } = 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) { if (currentTrack && !currentPlaylist) {
<View style="flex: 1;"> <View style="flex: 1;">
<TrackButton track={currentTrack} index={0} /> <TrackButton track={currentTrack} index={0} />
</View> </View>;
} }
return ( return (
@ -24,7 +26,13 @@ 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 <TrackButton key={index + track.id} track={track} index={index} />; return (
<TrackButton
key={index + track.id}
track={track}
index={index}
/>
);
})} })}
</View> </View>
</ScrollArea> </ScrollArea>

View File

@ -7,16 +7,27 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import CategoryCardView from "./shared/CategoryCardView"; import CategoryCardView from "./shared/CategoryCardView";
function Home() { function Home() {
const { data: pagedCategories, isError, refetch, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>( const {
data: pagedCategories,
isError,
refetch,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>(
QueryCacheKeys.categories, QueryCacheKeys.categories,
(spotifyApi, { pageParam }) => spotifyApi.getCategories({ country: "US", limit: 10, offset: pageParam }).then((categoriesReceived) => categoriesReceived.body.categories), (spotifyApi, { pageParam }) =>
spotifyApi
.getCategories({ country: "US", limit: 10, offset: pageParam })
.then((categoriesReceived) => categoriesReceived.body.categories),
{ {
getNextPageParam(lastPage) { getNextPageParam(lastPage) {
if (lastPage.next) { if (lastPage.next) {
return lastPage.offset + lastPage.limit; return lastPage.offset + lastPage.limit;
} }
}, },
} },
); );
const categories = pagedCategories?.pages const categories = pagedCategories?.pages
@ -27,11 +38,29 @@ function Home() {
return ( return (
<ScrollArea style={`flex-grow: 1; border: none;`}> <ScrollArea style={`flex-grow: 1; border: none;`}>
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}> <View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
<PlaceholderApplet error={isError} message="Failed to query genres" reload={refetch} helps loading={isLoading} /> <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}
/>
);
})} })}
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />} {hasNextPage && (
<Button
on={{ clicked: () => fetchNextPage() }}
text="Load More"
enabled={!isFetchingNextPage}
/>
)}
</View> </View>
</ScrollArea> </ScrollArea>
); );
@ -45,7 +74,9 @@ interface CategoryCardProps {
} }
const CategoryCard = ({ id, name }: CategoryCardProps) => { const CategoryCard = ({ id, name }: CategoryCardProps) => {
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>( const { data: playlists, isError } = useSpotifyQuery<
SpotifyApi.PlaylistObjectSimplified[]
>(
[QueryCacheKeys.categoryPlaylists, id], [QueryCacheKeys.categoryPlaylists, id],
async (spotifyApi) => { async (spotifyApi) => {
const option = { limit: 4 }; const option = { limit: 4 };
@ -57,8 +88,15 @@ const CategoryCard = ({ id, name }: CategoryCardProps) => {
} }
return res.body.playlists.items; return res.body.playlists.items;
}, },
{ initialData: [] } { initialData: [] },
); );
return <CategoryCardView url={`/genre/playlists/${id}`} isError={isError} name={name} playlists={playlists ?? []} />; return (
<CategoryCardView
url={`/genre/playlists/${id}`}
isError={isError}
name={name}
playlists={playlists ?? []}
/>
);
}; };

View File

@ -1,4 +1,5 @@
import { Button, ScrollArea, View } from "@nodegui/react-nodegui"; import { CursorShape } from "@nodegui/nodegui";
import { Button, ScrollArea, Text, 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";
@ -6,6 +7,7 @@ import playerContext from "../context/playerContext";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery"; import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import { GenreView } from "./PlaylistGenreView"; import { GenreView } from "./PlaylistGenreView";
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView"; import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
import CachedImage from "./shared/CachedImage";
import PlaceholderApplet from "./shared/PlaceholderApplet"; import PlaceholderApplet from "./shared/PlaceholderApplet";
import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton"; import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton";
import { TabMenuItem } from "./TabMenu"; import { TabMenuItem } from "./TabMenu";
@ -17,6 +19,7 @@ function Library() {
<View style="max-width: 350px; justify-content: 'space-evenly'"> <View style="max-width: 350px; justify-content: 'space-evenly'">
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" /> <TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
<TabMenuItem title="Playlists" url="/library/playlists" /> <TabMenuItem title="Playlists" url="/library/playlists" />
<TabMenuItem title="Artists" url="/library/followed-artists" />
</View> </View>
<Route exact path="/library/saved-tracks"> <Route exact path="/library/saved-tracks">
<UserSavedTracks /> <UserSavedTracks />
@ -24,6 +27,9 @@ function Library() {
<Route exact path="/library/playlists"> <Route exact path="/library/playlists">
<UserPlaylists /> <UserPlaylists />
</Route> </Route>
<Route exact path="/library/followed-artists">
<FollowedArtists />
</Route>
</View> </View>
); );
} }
@ -31,10 +37,20 @@ function Library() {
export default Library; export default Library;
function UserPlaylists() { function UserPlaylists() {
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>( const {
data: userPagedPlaylists,
isError,
isLoading,
refetch,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
QueryCacheKeys.userPlaylists, QueryCacheKeys.userPlaylists,
(spotifyApi, { pageParam }) => (spotifyApi, { pageParam }) =>
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => { spotifyApi
.getUserPlaylists({ limit: 20, offset: pageParam })
.then((userPlaylists) => {
return userPlaylists.body; return userPlaylists.body;
}), }),
{ {
@ -43,7 +59,7 @@ function UserPlaylists() {
return lastPage.offset + lastPage.limit; return lastPage.offset + lastPage.limit;
} }
}, },
} },
); );
const userPlaylists = userPagedPlaylists?.pages const userPlaylists = userPagedPlaylists?.pages
@ -66,18 +82,30 @@ function UserPlaylists() {
function UserSavedTracks() { function UserSavedTracks() {
const userSavedPlaylistId = "user-saved-tracks"; const userSavedPlaylistId = "user-saved-tracks";
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>( const {
data: userSavedTracks,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks, QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body), (spotifyApi, { pageParam }) =>
spotifyApi
.getMySavedTracks({ limit: 50, offset: pageParam })
.then((res) => res.body),
{ {
getNextPageParam(lastPage) { getNextPageParam(lastPage) {
if (lastPage.next) { if (lastPage.next) {
return lastPage.offset + lastPage.limit; return lastPage.offset + lastPage.limit;
} }
}, },
} },
); );
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext); const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } =
useContext(playerContext);
const userTracks = userSavedTracks?.pages const userTracks = userSavedTracks?.pages
?.map((page) => page.items) ?.map((page) => page.items)
@ -86,7 +114,13 @@ function UserSavedTracks() {
function handlePlaylistPlayPause(index?: number) { function handlePlaylistPlayPause(index?: number) {
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) { if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
setCurrentPlaylist({ id: userSavedPlaylistId, name: "Liked Tracks", thumbnail: "https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg", tracks: userTracks }); setCurrentPlaylist({
id: userSavedPlaylistId,
name: "Liked Tracks",
thumbnail:
"https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg",
tracks: userTracks,
});
setCurrentTrack(userTracks[index ?? 0].track); setCurrentTrack(userTracks[index ?? 0].track);
} else { } else {
setCurrentPlaylist(undefined); setCurrentPlaylist(undefined);
@ -111,7 +145,15 @@ function UserSavedTracks() {
id: userSavedPlaylistId, id: userSavedPlaylistId,
images: [{ url: "https://facebook.com/img.jpeg" }], images: [{ url: "https://facebook.com/img.jpeg" }],
name: "User saved track", 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 } }, owner: {
external_urls: { spotify: "" },
href: "",
id: "Me",
type: "user",
uri: "spotify:user:me",
display_name: "User",
followers: { href: null, total: 0 },
},
public: false, public: false,
snapshot_id: userSavedPlaylistId + "snapshot", snapshot_id: userSavedPlaylistId + "snapshot",
type: "playlist", type: "playlist",
@ -119,12 +161,30 @@ function UserSavedTracks() {
}; };
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';">
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} /> <PlaceholderApplet
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)} 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 && ( {hasNextPage && (
<Button <Button
style="flex-grow: 0; align-self: 'center';" style="flex-grow: 0; align-self: 'center';"
@ -142,3 +202,84 @@ function UserSavedTracks() {
</View> </View>
); );
} }
function FollowedArtists() {
const {
data: pagedFollowedArtists,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<
SpotifyApi.CursorBasedPagingObject<SpotifyApi.ArtistObjectFull>
>(
QueryCacheKeys.followedArtists,
(spotifyApi, { pageParam }) =>
spotifyApi
.getFollowedArtists({ limit: 50, after: pageParam })
.then((res) => res.body.artists),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.cursors.after + lastPage.limit;
}
},
},
);
const followedArtists = pagedFollowedArtists?.pages
?.map((page) => page.items)
.filter(Boolean)
.flat(1) as SpotifyApi.ArtistObjectFull[] | undefined;
return (
<ScrollArea style="min-height: 750px; max-height: 1980px; max-width: 1980px; min-width: 700px; border: none;">
<View style="flex: 1; flex-direction: 'row'; flex-wrap: wrap; width: 330px;">
<PlaceholderApplet
error={isError}
loading={isLoading || isFetchingNextPage}
message="Failed querying spotify"
reload={refetch}
/>
{followedArtists?.map((artist, index) => {
return <ArtistCard key={index + artist.id} artist={artist} />;
})}
{hasNextPage && (
<Button
style="flex-grow: 0; align-self: 'center';"
text="Load more"
on={{
clicked() {
fetchNextPage();
},
}}
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
);
}
interface ArtistCardProps {
artist: SpotifyApi.ArtistObjectFull;
}
function ArtistCard({ artist }: ArtistCardProps) {
return (
<View style="max-width: 150px; max-height: 200px; flex-direction: 'column'; align-items: 'center'; margin: 5px 0;">
<CachedImage
cursor={CursorShape.PointingHandCursor}
maxSize={{ height: 150, width: 150 }}
scaledContents
alt={artist.name}
src={artist.images[0].url}
/>
<Text>{artist.name}</Text>
<Button cursor={CursorShape.PointingHandCursor} text="Follow" />
</View>
);
}

View File

@ -1,15 +1,23 @@
import { FlexLayout, QDialog, QLabel, QPushButton, QScrollArea, QWidget, TextFormat } from "@nodegui/nodegui"; import {
FlexLayout,
QDialog,
QLabel,
QPushButton,
QScrollArea,
QWidget,
TextFormat,
} from "@nodegui/nodegui";
import React, { PropsWithChildren, useEffect, useState } from "react"; import React, { PropsWithChildren, useEffect, useState } from "react";
import showError from "../helpers/showError"; import showError from "../helpers/showError";
import fetchLyrics from "../helpers/fetchLyrics"; import fetchLyrics from "../helpers/fetchLyrics";
interface ManualLyricDialogProps extends PropsWithChildren<{}> { interface ManualLyricDialogProps extends PropsWithChildren<unknown> {
open: boolean; open: boolean;
onClose?: (closed: boolean) => void; onClose?: (closed: boolean) => void;
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull; track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
} }
function ManualLyricDialog({ open, track, onClose }: ManualLyricDialogProps) { function ManualLyricDialog({ open, track }: ManualLyricDialogProps) {
const dialog = new QDialog(); const dialog = new QDialog();
const areaContainer = new QWidget(); const areaContainer = new QWidget();
const retryButton = new QPushButton(); const retryButton = new QPushButton();
@ -23,7 +31,7 @@ function ManualLyricDialog({ open, track, onClose }: ManualLyricDialogProps) {
async function handleBtnClick() { async function handleBtnClick() {
try { try {
const lyrics = await fetchLyrics(artists, track.name); const lyrics = await fetchLyrics(artists, track.name);
console.log('lyrics:', lyrics) console.log("lyrics:", lyrics);
setLyrics(lyrics); setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found"); setLyricNotFound(lyrics === "Not Found");
} catch (error) { } catch (error) {

View File

@ -1,12 +1,31 @@
import { Direction, Orientation, QAbstractSliderSignals, QIcon } from "@nodegui/nodegui"; import { Direction, Orientation, QAbstractSliderSignals, QIcon } from "@nodegui/nodegui";
import { BoxView, GridColumn, GridRow, GridView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui"; import {
BoxView,
GridColumn,
GridRow,
GridView,
Slider,
Text,
useEventHandler,
} from "@nodegui/react-nodegui";
import React, { ReactElement, useContext, useEffect, useState } from "react"; import React, { ReactElement, useContext, useEffect, useState } from "react";
import playerContext, { CurrentPlaylist } from "../context/playerContext"; import playerContext, { CurrentPlaylist } from "../context/playerContext";
import { shuffleArray } from "../helpers/shuffleArray"; import { shuffleArray } from "../helpers/shuffleArray";
import NodeMpv from "node-mpv"; import NodeMpv from "node-mpv";
import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack"; import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack";
import PlayerProgressBar from "./PlayerProgressBar"; import PlayerProgressBar from "./PlayerProgressBar";
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular, heart, musicNode, download } from "../icons"; import {
random as shuffleIcon,
play,
pause,
backward,
forward,
stop,
heartRegular,
heart,
musicNode,
download,
} from "../icons";
import IconButton from "./shared/IconButton"; import IconButton from "./shared/IconButton";
import showError from "../helpers/showError"; import showError from "../helpers/showError";
import useTrackReaction from "../hooks/useTrackReaction"; import useTrackReaction from "../hooks/useTrackReaction";
@ -23,16 +42,19 @@ export const audioPlayer = new NodeMpv(
// debug: true, // debug: true,
// verbose: true, // verbose: true,
}, },
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"] ["--ytdl-raw-options-set=format=140,http-chunk-size=300000"],
); );
function Player(): ReactElement { function Player(): ReactElement {
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext); const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } =
useContext(playerContext);
const { reactToTrack, isFavorite } = useTrackReaction(); const { reactToTrack, isFavorite } = useTrackReaction();
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume); const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
const [isPaused, setIsPaused] = useState(true); const [isPaused, setIsPaused] = useState(true);
const [volume, setVolume] = useState<number>(() => (cachedVolume ? parseFloat(cachedVolume) : 55)); const [volume, setVolume] = useState<number>(() =>
cachedVolume ? parseFloat(cachedVolume) : 55,
);
const [totalDuration, setTotalDuration] = useState<number>(0); const [totalDuration, setTotalDuration] = useState<number>(0);
const [shuffle, setShuffle] = useState<boolean>(false); const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]); const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
@ -51,7 +73,7 @@ function Player(): ReactElement {
localStorage.setItem(LocalStorageKeys.volume, volume.toString()); localStorage.setItem(LocalStorageKeys.volume, volume.toString());
}, },
}, },
[volume] [volume],
); );
const playerRunning = audioPlayer.isRunning(); const playerRunning = audioPlayer.isRunning();
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist); const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
@ -88,7 +110,11 @@ function Player(): ReactElement {
useEffect(() => { useEffect(() => {
// caching current track // caching current track
if (!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack); if (!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
else localStorage.setItem(LocalStorageKeys.cachedTrack, JSON.stringify(currentTrack)); else
localStorage.setItem(
LocalStorageKeys.cachedTrack,
JSON.stringify(currentTrack),
);
(async () => { (async () => {
try { try {
@ -115,7 +141,11 @@ function Player(): ReactElement {
setShuffle(false); setShuffle(false);
// caching playlist // caching playlist
if (!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist); if (!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
else localStorage.setItem(LocalStorageKeys.cachedPlaylist, JSON.stringify(currentPlaylist)); else
localStorage.setItem(
LocalStorageKeys.cachedPlaylist,
JSON.stringify(currentPlaylist),
);
}, [currentPlaylist]); }, [currentPlaylist]);
useEffect(() => { useEffect(() => {
@ -140,6 +170,7 @@ function Player(): ReactElement {
// live Effect // live Effect
useEffect(() => { useEffect(() => {
if (playerRunning) { if (playerRunning) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const statusListener = (status: { property: string; value: any }) => { const statusListener = (status: { property: string; value: any }) => {
if (status?.property === "duration") { if (status?.property === "duration") {
setTotalDuration(status.value ?? 0); setTotalDuration(status.value ?? 0);
@ -149,9 +180,17 @@ function Player(): ReactElement {
setIsStopped(true); setIsStopped(true);
setIsPaused(true); setIsPaused(true);
// go to next track // go to next track
if (currentTrack && playlistTracksIds && currentPlaylist?.tracks.length !== 0) { if (
currentTrack &&
playlistTracksIds &&
currentPlaylist?.tracks.length !== 0
) {
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1; const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
setCurrentTrack(currentPlaylist?.tracks[index > playlistTracksIds.length - 1 ? 0 : index].track); setCurrentTrack(
currentPlaylist?.tracks[
index > playlistTracksIds.length - 1 ? 0 : index
].track,
);
} }
}; };
const pauseListener = () => { const pauseListener = () => {
@ -192,7 +231,15 @@ function Player(): ReactElement {
const prevOrNext = (constant: number) => { const prevOrNext = (constant: number) => {
if (currentTrack && playlistTracksIds && currentPlaylist) { if (currentTrack && playlistTracksIds && currentPlaylist) {
const index = playlistTracksIds.indexOf(currentTrack.id) + constant; const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track); setCurrentTrack(
currentPlaylist.tracks[
index > playlistTracksIds?.length - 1
? 0
: index < 0
? playlistTracksIds.length - 1
: index
].track,
);
} }
}; };
@ -216,29 +263,71 @@ function Player(): ReactElement {
<Text wordWrap openExternalLinks> <Text wordWrap openExternalLinks>
{artistsNames && currentTrack {artistsNames && currentTrack
? ` ? `
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${currentTrack.name}</a></b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "feat. " + artistsNames.slice(1).join(", ") : ""}</p> <p><b><a href="${currentYtTrack?.youtube_uri}"}>${
currentTrack.name
}</a></b> - ${artistsNames[0]} ${
artistsNames.length > 1
? "feat. " + artistsNames.slice(1).join(", ")
: ""
}</p>
` `
: `<b>Oh, dear don't waste time</b>`} : `<b>Oh, dear don't waste time</b>`}
</Text> </Text>
</GridColumn> </GridColumn>
<GridColumn width={4}> <GridColumn width={4}>
<BoxView direction={Direction.TopToBottom} style={`max-width: 600px; min-width: 380px;`}> <BoxView
{currentTrack && <ManualLyricDialog open={openLyrics} track={currentTrack} />} direction={Direction.TopToBottom}
<PlayerProgressBar audioPlayer={audioPlayer} totalDuration={totalDuration} /> style={`max-width: 600px; min-width: 380px;`}
>
{currentTrack && (
<ManualLyricDialog open={openLyrics} track={currentTrack} />
)}
<PlayerProgressBar
audioPlayer={audioPlayer}
totalDuration={totalDuration}
/>
<BoxView direction={Direction.LeftToRight}> <BoxView direction={Direction.LeftToRight}>
<IconButton style={`background-color: ${shuffle ? "orange" : "rgba(255, 255, 255, 0.055)"}`} on={{ clicked: () => setShuffle(!shuffle) }} icon={new QIcon(shuffleIcon)} /> <IconButton
<IconButton on={{ clicked: () => prevOrNext(-1) }} icon={new QIcon(backward)} /> style={`background-color: ${
<IconButton on={{ clicked: handlePlayPause }} icon={new QIcon(isStopped || isPaused || !currentTrack ? play : pause)} /> shuffle ? "orange" : "rgba(255, 255, 255, 0.055)"
<IconButton on={{ clicked: () => prevOrNext(1) }} icon={new QIcon(forward)} /> }`}
<IconButton icon={new QIcon(stop)} on={{ clicked: stopPlayback }} /> on={{ clicked: () => setShuffle(!shuffle) }}
icon={new QIcon(shuffleIcon)}
/>
<IconButton
on={{ clicked: () => prevOrNext(-1) }}
icon={new QIcon(backward)}
/>
<IconButton
on={{ clicked: handlePlayPause }}
icon={
new QIcon(
isStopped || isPaused || !currentTrack
? play
: pause,
)
}
/>
<IconButton
on={{ clicked: () => prevOrNext(1) }}
icon={new QIcon(forward)}
/>
<IconButton
icon={new QIcon(stop)}
on={{ clicked: stopPlayback }}
/>
</BoxView> </BoxView>
</BoxView> </BoxView>
</GridColumn> </GridColumn>
<GridColumn width={2}> <GridColumn width={2}>
<BoxView> <BoxView>
<IconButton <IconButton
style={isActiveDownloading() && !isFinishedDownloading() ? "background-color: green;" : ""} style={
isActiveDownloading() && !isFinishedDownloading()
? "background-color: green;"
: ""
}
enabled={!!currentYtTrack} enabled={!!currentYtTrack}
icon={new QIcon(download)} icon={new QIcon(download)}
on={{ on={{
@ -251,18 +340,36 @@ function Player(): ReactElement {
on={{ on={{
clicked() { clicked() {
if (currentTrack) { if (currentTrack) {
reactToTrack({ added_at: Date.now().toString(), track: currentTrack }); reactToTrack({
added_at: Date.now().toString(),
track: currentTrack,
});
} }
}, },
}} }}
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : heartRegular)} icon={
new QIcon(
isFavorite(currentTrack?.id ?? "")
? heart
: heartRegular,
)
}
/> />
<IconButton <IconButton
style={openLyrics ? "background-color: green;" : ""} style={openLyrics ? "background-color: green;" : ""}
icon={new QIcon(musicNode)} icon={new QIcon(musicNode)}
on={{ clicked: () => currentTrack && setOpenLyrics(!openLyrics) }} on={{
clicked: () => currentTrack && setOpenLyrics(!openLyrics),
}}
/>
<Slider
minSize={{ height: 20, width: 80 }}
maxSize={{ height: 20, width: 100 }}
hasTracking
sliderPosition={volume}
on={volumeHandler}
orientation={Orientation.Horizontal}
/> />
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} />
</BoxView> </BoxView>
</GridColumn> </GridColumn>
</GridRow> </GridRow>

View File

@ -30,7 +30,7 @@ function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProp
})(); })();
}, },
}, },
[currentTrack, totalDuration, trackTime] [currentTrack, totalDuration, trackTime],
); );
useEffect(() => { useEffect(() => {
@ -50,10 +50,22 @@ function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProp
}; };
}); });
const playbackPercentage = totalDuration > 0 ? (trackTime * 100) / totalDuration : 0; const playbackPercentage = totalDuration > 0 ? (trackTime * 100) / totalDuration : 0;
const playbackTime = new Date(trackTime * 1000).toISOString().substr(14, 5) + "/" + new Date(totalDuration * 1000).toISOString().substr(14, 5) const playbackTime =
new Date(trackTime * 1000).toISOString().substr(14, 5) +
"/" +
new Date(totalDuration * 1000).toISOString().substr(14, 5);
return ( return (
<BoxView direction={Direction.LeftToRight} style={`padding: 20px 0px; flex-direction: row;`}> <BoxView
<Slider enabled={!!currentTrack || trackTime > 0} on={trackSliderEvents} sliderPosition={playbackPercentage} hasTracking orientation={Orientation.Horizontal} /> direction={Direction.LeftToRight}
style={`padding: 20px 0px; flex-direction: row;`}
>
<Slider
enabled={!!currentTrack || trackTime > 0}
on={trackSliderEvents}
sliderPosition={playbackPercentage}
hasTracking
orientation={Orientation.Horizontal}
/>
<Text>{playbackTime}</Text> <Text>{playbackTime}</Text>
</BoxView> </BoxView>
); );

View File

@ -1,6 +1,7 @@
import { QAbstractButtonSignals } from "@nodegui/nodegui"; import { QAbstractButtonSignals } from "@nodegui/nodegui";
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui"; import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React from "react"; import React from "react";
import { RefetchOptions } from "react-query";
import { useLocation, useParams } from "react-router"; import { useLocation, useParams } from "react-router";
import { QueryCacheKeys } from "../conf"; import { QueryCacheKeys } from "../conf";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery"; import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
@ -11,7 +12,17 @@ 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: pagedPlaylists, isError, isLoading, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>>( const {
data: pagedPlaylists,
isError,
isLoading,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useSpotifyInfiniteQuery<
SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>
>(
[QueryCacheKeys.genrePlaylists, id], [QueryCacheKeys.genrePlaylists, id],
async (spotifyApi, { pageParam }) => { async (spotifyApi, { pageParam }) => {
const option = { limit: 20, offset: pageParam }; const option = { limit: 20, offset: pageParam };
@ -29,7 +40,7 @@ function PlaylistGenreView() {
return lastPage.offset + lastPage.limit; return lastPage.offset + lastPage.limit;
} }
}, },
} },
); );
const playlists = pagedPlaylists?.pages const playlists = pagedPlaylists?.pages
@ -59,10 +70,18 @@ interface GenreViewProps {
isLoadable?: boolean; isLoadable?: boolean;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
refetch: Function; refetch: (options?: RefetchOptions | undefined) => Promise<unknown>;
} }
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: 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';
@ -89,11 +108,24 @@ export function GenreView({ heading, playlists, loadMore, isLoadable, isError, i
<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`} /> <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} />
);
})} })}
{loadMore && <Button text="Load more" on={{ clicked: loadMore }} enabled={isLoadable} />} {loadMore && (
<Button
text="Load more"
on={{ clicked: loadMore }}
enabled={isLoadable}
/>
)}
</View> </View>
</ScrollArea> </ScrollArea>
</View> </View>

View File

@ -20,17 +20,27 @@ export interface PlaylistTrackRes {
} }
const PlaylistView: FC = () => { const PlaylistView: FC = () => {
const { setCurrentTrack, currentPlaylist, 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();
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) => const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>(
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body) [QueryCacheKeys.categoryPlaylists, params.id],
(spotifyApi) =>
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body),
); );
const { data: tracks, isSuccess, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>( const {
data: tracks,
isSuccess,
isError,
isLoading,
refetch,
} = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
[QueryCacheKeys.playlistTracks, params.id], [QueryCacheKeys.playlistTracks, params.id],
(spotifyApi) => spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items), (spotifyApi) =>
{ initialData: [] } spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
{ initialData: [] },
); );
const handlePlaylistPlayPause = () => { const handlePlaylistPlayPause = () => {
@ -38,7 +48,9 @@ const PlaylistView: FC = () => {
setCurrentPlaylist({ ...params, ...location.state, tracks }); setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[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);
setCurrentPlaylist(undefined); setCurrentPlaylist(undefined);
} }
@ -52,14 +64,28 @@ const PlaylistView: FC = () => {
isActive={currentPlaylist?.id === params.id} isActive={currentPlaylist?.id === params.id}
isFavorite={isFavorite(params.id)} isFavorite={isFavorite(params.id)}
/> />
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text> <Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(
1,
)}</h2></center>`}</Text>
{<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;`}>
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} /> <PlaceholderApplet
error={isError}
loading={isLoading}
reload={refetch}
message={`Failed retrieving ${location.state.name} tracks`}
/>
{tracks?.map(({ track }, index) => { {tracks?.map(({ track }, index) => {
if (track) { if (track) {
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />; return (
<TrackButton
key={index + track.id}
track={track}
index={index}
playlist={playlist}
/>
);
} }
})} })}
</View> </View>
@ -88,11 +114,21 @@ interface PlaylistSimpleControlsProps {
isFavorite?: boolean; isFavorite?: boolean;
} }
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) { export function PlaylistSimpleControls({
handlePlaylistPlayPause,
isActive,
handlePlaylistReact,
isFavorite,
}: PlaylistSimpleControlsProps) {
return ( return (
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}> <View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
<BackButton /> <BackButton />
{isFavorite !== undefined && <IconButton icon={new QIcon(isFavorite ? heart : heartRegular)} on={{ clicked: handlePlaylistReact }} />} {isFavorite !== undefined && (
<IconButton
icon={new QIcon(isFavorite ? heart : heartRegular)}
on={{ clicked: handlePlaylistReact }}
/>
)}
<IconButton <IconButton
style={`background-color: #00be5f; color: white;`} style={`background-color: #00be5f; color: white;`}
on={{ on={{

View File

@ -15,10 +15,18 @@ 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, refetch, isError, isLoading } = 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) =>
{ enabled: false } spotifyApi
.search(searchQuery, ["playlist", "track"], { limit: 4 })
.then((res) => res.body),
{ enabled: false },
); );
async function handleSearch() { async function handleSearch() {
@ -29,7 +37,14 @@ function Search() {
} }
} }
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />; 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>
@ -40,16 +55,21 @@ function Search() {
textChanged(t) { textChanged(t) {
setSearchQuery(t); setSearchQuery(t);
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
KeyRelease(native: any) { KeyRelease(native: any) {
const key = new QKeyEvent(native); const key = new QKeyEvent(native);
const isEnter = key.key() === 16777220; const isEnter = key.key() === 16777220;
if (isEnter) { if (isEnter) {
handleSearch(); 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 }}
/>
</View> </View>
<ScrollArea style="flex: 1;"> <ScrollArea style="flex: 1;">
<View style="flex-direction: 'column'; flex: 1;"> <View style="flex-direction: 'column'; flex: 1;">
@ -57,32 +77,51 @@ function Search() {
<Text <Text
cursor={CursorShape.PointingHandCursor} cursor={CursorShape.PointingHandCursor}
on={{ on={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) { MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) { if (
new QMouseEvent(native).button() === 1 &&
searchResults?.tracks
) {
history.push("/search/songs", { searchQuery }); history.push("/search/songs", { searchQuery });
} }
}, },
}}>{`<h2>Songs</h2>`}</Text> }}
>{`<h2>Songs</h2>`}</Text>
<TrackTableIndex /> <TrackTableIndex />
{placeholder} {placeholder}
{searchResults?.tracks?.items.map((track, index) => ( {searchResults?.tracks?.items.map((track, index) => (
<TrackButton key={index + track.id} index={index} track={track} /> <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} cursor={CursorShape.PointingHandCursor}
on={{ on={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) { MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) { if (
history.push("/search/playlists", { searchQuery }); new QMouseEvent(native).button() === 1 &&
searchResults?.playlists
) {
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} {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}
/>
))} ))}
</View> </View>
</View> </View>

View File

@ -6,16 +6,33 @@ import { GenreView } from "./PlaylistGenreView";
function SearchResultPlaylistCollection() { function SearchResultPlaylistCollection() {
const location = useLocation<{ searchQuery: string }>(); const location = useLocation<{ searchQuery: string }>();
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = 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) => {
if (lastPage.playlists?.next) { if (lastPage.playlists?.next) {
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0); return (
(lastPage.playlists?.offset ?? 0) +
(lastPage.playlists?.limit ?? 0)
);
} }
}, },
} },
); );
return ( return (
<GenreView <GenreView

View File

@ -9,16 +9,30 @@ import { TrackButton } from "./shared/TrackButton";
function SearchResultSongsCollection() { function SearchResultSongsCollection() {
const location = useLocation<{ searchQuery: string }>(); const location = useLocation<{ searchQuery: string }>();
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>( const {
data: searchResults,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.searchSongs, QueryCacheKeys.searchSongs,
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body), (spotifyApi, { pageParam }) =>
spotifyApi
.searchTracks(location.state.searchQuery, {
limit: 20,
offset: pageParam,
})
.then((res) => res.body),
{ {
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
if (lastPage.tracks?.next) { if (lastPage.tracks?.next) {
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0); return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
} }
}, },
} },
); );
return ( return (
<View style="flex: 1; flex-direction: 'column';"> <View style="flex: 1; flex-direction: 'column';">
@ -30,12 +44,26 @@ function SearchResultSongsCollection() {
<TrackTableIndex /> <TrackTableIndex />
<ScrollArea style="flex: 1; border: none;"> <ScrollArea style="flex: 1; border: none;">
<View style="flex: 1; flex-direction: 'column';"> <View style="flex: 1; flex-direction: 'column';">
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} /> <PlaceholderApplet
error={isError}
loading={isLoading || isFetchingNextPage}
message="Failed querying spotify"
reload={refetch}
/>
{searchResults?.pages {searchResults?.pages
.map((searchResult) => searchResult.tracks?.items) .map((searchResult) => searchResult.tracks?.items)
.filter(Boolean) .filter(Boolean)
.flat(1) .flat(1)
.map((track, index) => track && <TrackButton key={index + track.id} index={index} track={track} />)} .map(
(track, index) =>
track && (
<TrackButton
key={index + track.id}
index={index}
track={track}
/>
),
)}
{hasNextPage && ( {hasNextPage && (
<Button <Button

View File

@ -4,7 +4,7 @@ import preferencesContext from "../context/preferencesContext";
import Switch, { SwitchProps } from "./shared/Switch"; import Switch, { SwitchProps } from "./shared/Switch";
function Settings() { function Settings() {
const { setPreferences, ...preferences } = useContext(preferencesContext) const { setPreferences, ...preferences } = useContext(preferencesContext);
return ( return (
<View style="flex: 1; flex-direction: 'column'; justify-content: 'flex-start';"> <View style="flex: 1; flex-direction: 'column'; justify-content: 'flex-start';">
<Text>{`<center><h2>Settings</h2></center>`}</Text> <Text>{`<center><h2>Settings</h2></center>`}</Text>
@ -13,7 +13,9 @@ function Settings() {
checked={preferences.playlistImages} checked={preferences.playlistImages}
title="Use images instead of colors for playlist" title="Use images instead of colors for playlist"
subtitle="This will increase memory usage" subtitle="This will increase memory usage"
onChange={(checked) => setPreferences({ ...preferences, playlistImages: checked })} onChange={(checked) =>
setPreferences({ ...preferences, playlistImages: checked })
}
/> />
</View> </View>
</View> </View>
@ -29,7 +31,12 @@ interface SettingsCheckTileProps {
onChange?: SwitchProps["onChange"]; onChange?: SwitchProps["onChange"];
} }
export function SettingsCheckTile({ title, subtitle = "", onChange, checked }: SettingsCheckTileProps) { export function SettingsCheckTile({
title,
subtitle = "",
onChange,
checked,
}: SettingsCheckTileProps) {
return ( return (
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';"> <View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';">
<Text> <Text>

View File

@ -55,7 +55,7 @@ export interface TabMenuItemProps {
icon?: string; icon?: string;
} }
export function TabMenuItem({ icon, title, url }: TabMenuItemProps) { export function TabMenuItem({ title, url }: TabMenuItemProps) {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
@ -63,5 +63,15 @@ export function TabMenuItem({ icon, title, url }: TabMenuItemProps) {
history.push(url); history.push(url);
} }
return <Button on={{ clicked }} id={location.pathname.replace("/", " ").startsWith(url.replace("/", " ")) ? "tabmenu-active-item" : `tabmenu-item`} text={title} />; return (
<Button
on={{ clicked }}
id={
location.pathname.replace("/", " ").startsWith(url.replace("/", " "))
? "tabmenu-active-item"
: `tabmenu-item`
}
text={title}
/>
);
} }

View File

@ -34,7 +34,10 @@ function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
useEffect(() => { useEffect(() => {
if (imageBuffer) { if (imageBuffer) {
pixmap.loadFromData(imageBuffer); pixmap.loadFromData(imageBuffer);
pixmap.scaled((size ?? maxSize)?.height ?? 100, (size ?? maxSize)?.width ?? 100); pixmap.scaled(
(size ?? maxSize)?.height ?? 100,
(size ?? maxSize)?.width ?? 100,
);
labelRef.current?.setPixmap(pixmap); labelRef.current?.setPixmap(pixmap);
} }
}, [imageBuffer]); }, [imageBuffer]);
@ -42,7 +45,11 @@ function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
return !imageProcessError && imageBuffer ? ( return !imageProcessError && imageBuffer ? (
<Text ref={labelRef} {...props} /> <Text ref={labelRef} {...props} />
) : alt ? ( ) : alt ? (
<View style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${((maxSize ?? size)?.width || 10) / 2.5}px;`}> <View
style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${
((maxSize ?? size)?.width || 10) / 2.5
}px;`}
>
<Text>{alt}</Text> <Text>{alt}</Text>
</View> </View>
) : ( ) : (

View File

@ -37,6 +37,7 @@ const categoryStylesheet = `
`; `;
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => { const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
const history = useHistory(); const history = useHistory();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function goToGenre(native: any) { function goToGenre(native: any) {
const mouse = new QMouseEvent(native); const mouse = new QMouseEvent(native);
if (mouse.button() === 1) { if (mouse.button() === 1) {
@ -48,7 +49,12 @@ const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url })
} }
return ( return (
<View id="container" styleSheet={categoryStylesheet}> <View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} /> <Button
id="anchor-heading"
cursor={CursorShape.PointingHandCursor}
on={{ MouseButtonRelease: goToGenre }}
text={name}
/>
<View id="child-view"> <View id="child-view">
{playlists.map((playlist, index) => { {playlists.map((playlist, index) => {
return <PlaylistCard key={index + playlist.id} playlist={playlist} />; return <PlaylistCard key={index + playlist.id} playlist={playlist} />;

View File

@ -1,19 +1,19 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import {QGraphicsDropShadowEffect, QPushButton} from "@nodegui/nodegui" import { QGraphicsDropShadowEffect, QPushButton } from "@nodegui/nodegui";
import { Button } from "@nodegui/react-nodegui"; import { Button } from "@nodegui/react-nodegui";
import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton"; import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton";
interface IconButtonProps extends Omit<ButtonProps, "text"> {} type IconButtonProps = Omit<ButtonProps, "text">;
function IconButton({ style, ...props }: IconButtonProps) { function IconButton({ style, ...props }: IconButtonProps) {
const iconBtnRef = useRef<QPushButton>() const iconBtnRef = useRef<QPushButton>();
const shadowGfx = new QGraphicsDropShadowEffect(); const shadowGfx = new QGraphicsDropShadowEffect();
useEffect(() => { useEffect(() => {
shadowGfx.setBlurRadius(5); shadowGfx.setBlurRadius(5);
shadowGfx.setXOffset(0); shadowGfx.setXOffset(0);
shadowGfx.setYOffset(0); shadowGfx.setYOffset(0);
iconBtnRef.current?.setGraphicsEffect(shadowGfx); iconBtnRef.current?.setGraphicsEffect(shadowGfx);
}, []) }, []);
const iconButtonStyleSheet = ` const iconButtonStyleSheet = `
#icon-btn{ #icon-btn{
background-color: rgba(255, 255, 255, 0.055); background-color: rgba(255, 255, 255, 0.055);
@ -32,7 +32,15 @@ function IconButton({ style, ...props }: IconButtonProps) {
} }
`; `;
return <Button ref={iconBtnRef} id="icon-btn" size={{ height: 30, width: 30, fixed: true }} styleSheet={iconButtonStyleSheet} {...props} />; return (
<Button
ref={iconBtnRef}
id="icon-btn"
size={{ height: 30, width: 30, fixed: true }}
styleSheet={iconButtonStyleSheet}
{...props}
/>
);
} }
export default IconButton; export default IconButton;

View File

@ -1,5 +1,5 @@
import { View, Button, Text } from "@nodegui/react-nodegui"; import { View, Button, Text } from "@nodegui/react-nodegui";
import { QLabel, QMovie, } from "@nodegui/nodegui"; import { QLabel, QMovie } from "@nodegui/nodegui";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { loadingSpinner } from "../../icons"; import { loadingSpinner } from "../../icons";
@ -7,7 +7,7 @@ interface ErrorAppletProps {
error: boolean; error: boolean;
loading: boolean; loading: boolean;
message?: string; message?: string;
reload: Function; reload: () => void;
helps?: boolean; helps?: boolean;
} }

View File

@ -21,14 +21,20 @@ interface PlaylistCardProps {
const PlaylistCard = ({ playlist }: PlaylistCardProps) => { const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
const preferences = useContext(preferencesContext); const preferences = useContext(preferencesContext);
const thumbnail = playlist.images[0].url; const thumbnail = playlist.images[0].url;
const { id, description, name, images } = playlist; const { id, description, name } = playlist;
const history = useHistory(); const history = useHistory();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext); const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), { useContext(playerContext);
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
[QueryCacheKeys.playlistTracks, id],
(spotifyApi) =>
spotifyApi.getPlaylistTracks(id).then((track) => track.body.items),
{
initialData: [], initialData: [],
enabled: false, enabled: false,
}); },
);
const { reactToPlaylist, isFavorite } = usePlaylistReaction(); const { reactToPlaylist, isFavorite } = usePlaylistReaction();
const handlePlaylistPlayPause = async () => { const handlePlaylistPlayPause = async () => {
@ -47,6 +53,7 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
} }
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function gotoPlaylist(native?: any) { function gotoPlaylist(native?: any) {
const key = new QMouseEvent(native); const key = new QMouseEvent(native);
if (key.button() === 1) { if (key.button() === 1) {
@ -121,10 +128,22 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
on={{ on={{
MouseButtonRelease: gotoPlaylist, MouseButtonRelease: gotoPlaylist,
...hovers, ...hovers,
}}> }}
{preferences.playlistImages && <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />} >
{preferences.playlistImages && (
<CachedImage
src={thumbnail}
maxSize={{ height: 150, width: 150 }}
scaledContents
alt={name}
/>
)}
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}> <Text
style={`color: ${color};`}
wordWrap
on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}
>
{` {`
<center> <center>
<h3>${name}</h3> <h3>${name}</h3>
@ -133,11 +152,14 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
`} `}
</Text> </Text>
{(hovered || currentPlaylist?.id === id) && !preferences.playlistImages && playlistActions} {(hovered || currentPlaylist?.id === id) &&
{preferences.playlistImages && !preferences.playlistImages &&
<View style="flex: 1; justify-content: 'space-around';">{playlistActions} playlistActions}
{preferences.playlistImages && (
<View style="flex: 1; justify-content: 'space-around';">
{playlistActions}
</View> </View>
} )}
</View> </View>
); );
}; };

View File

@ -29,6 +29,7 @@ function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
valueChanged(value) { valueChanged(value) {
onChange && onChange(value === 1); onChange && onChange(value === 1);
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) { MouseButtonRelease(native: any) {
const mouse = new QMouseEvent(native); const mouse = new QMouseEvent(native);
if (mouse.button() === 1) { if (mouse.button() === 1) {

View File

@ -9,7 +9,9 @@ import IconButton from "./IconButton";
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject { export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
follower?: SpotifyApi.FollowersObject; follower?: SpotifyApi.FollowersObject;
tracks: SpotifyApi.PagingObject<SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject>; tracks: SpotifyApi.PagingObject<
SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject
>;
} }
export interface TrackButtonProps { export interface TrackButtonProps {
@ -20,10 +22,16 @@ export interface TrackButtonProps {
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => { export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
const { reactToTrack, isFavorite } = useTrackReaction(); const { reactToTrack, isFavorite } = useTrackReaction();
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext); const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } =
useContext(playerContext);
const handlePlaylistPlayPause = (index: number) => { const handlePlaylistPlayPause = (index: number) => {
if (playlist && currentPlaylist?.id !== playlist.id) { if (playlist && currentPlaylist?.id !== playlist.id) {
const globalPlaylistObj = { id: playlist.id, name: playlist.name, thumbnail: playlist.images[0].url, tracks: playlist.tracks.items }; const globalPlaylistObj = {
id: playlist.id,
name: playlist.name,
thumbnail: playlist.images[0].url,
tracks: playlist.tracks.items,
};
setCurrentPlaylist(globalPlaylistObj); setCurrentPlaylist(globalPlaylistObj);
setCurrentTrack(playlist.tracks.items[index].track); setCurrentTrack(playlist.tracks.items[index].track);
} }
@ -34,18 +42,22 @@ export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) =>
}; };
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []); const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
const active = (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) || currentTrack?.id === track.id; const active =
(currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) ||
currentTrack?.id === track.id;
return ( return (
<View <View
id={active ? "active" : "track-button"} id={active ? "active" : "track-button"}
styleSheet={trackButtonStyle} styleSheet={trackButtonStyle}
on={{ on={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) { MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1 && playlist) { if (new QMouseEvent(native).button() === 1 && playlist) {
handlePlaylistPlayPause(index); handlePlaylistPlayPause(index);
} }
}, },
}}> }}
>
<Text style="padding: 5px;">{index + 1}</Text> <Text style="padding: 5px;">{index + 1}</Text>
<View style="flex-direction: 'column'; width: '35%';"> <View style="flex-direction: 'column'; width: '35%';">
<Text>{`<h3>${track.name}</h3>`}</Text> <Text>{`<h3>${track.name}</h3>`}</Text>

View File

@ -2,7 +2,7 @@ import dotenv from "dotenv";
import { homedir } from "os"; import { homedir } from "os";
import { join } from "path"; import { join } from "path";
const env = dotenv.config({ path: join(process.cwd(), ".env") }).parsed as any; dotenv.config({ path: join(process.cwd(), ".env") }).parsed;
export const clientId = ""; export const clientId = "";
export const trace = process.argv.find((arg) => arg === "--trace") ?? false; export const trace = process.argv.find((arg) => arg === "--trace") ?? false;
export const redirectURI = "http://localhost:4304/auth/spotify/callback"; export const redirectURI = "http://localhost:4304/auth/spotify/callback";
@ -20,6 +20,7 @@ export enum QueryCacheKeys {
search = "search", search = "search",
searchPlaylist = "searchPlaylist", searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs", searchSongs = "searchSongs",
followedArtists = "followed-artists",
} }
export enum LocalStorageKeys { export enum LocalStorageKeys {
@ -28,5 +29,5 @@ export enum LocalStorageKeys {
preferences = "user-preferences", preferences = "user-preferences",
volume = "volume", volume = "volume",
cachedPlaylist = "cached-playlist", cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track" cachedTrack = "cached-track",
} }

View File

@ -7,18 +7,24 @@ export interface AuthContext {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
access_token: string; access_token: string;
setCredentials: Dispatch<SetStateAction<Credentials>> setCredentials: Dispatch<SetStateAction<Credentials>>;
setAccess_token: Dispatch<SetStateAction<string>>; setAccess_token: Dispatch<SetStateAction<string>>;
} }
const authContext = React.createContext<AuthContext>({ const authContext = React.createContext<AuthContext>({
isLoggedIn: false, isLoggedIn: false,
setIsLoggedIn() {}, setIsLoggedIn() {
return;
},
access_token: "", access_token: "",
clientId: "", clientId: "",
clientSecret: "", clientSecret: "",
setCredentials(){}, setCredentials() {
setAccess_token() {}, return;
},
setAccess_token() {
return;
},
}); });
export default authContext; export default authContext;

View File

@ -2,7 +2,12 @@ import React, { Dispatch, SetStateAction } from "react";
export type CurrentTrack = SpotifyApi.TrackObjectFull; export type CurrentTrack = SpotifyApi.TrackObjectFull;
export type CurrentPlaylist = { tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[]; id: string; name: string; thumbnail: string }; export type CurrentPlaylist = {
tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[];
id: string;
name: string;
thumbnail: string;
};
export interface PlayerContext { export interface PlayerContext {
currentPlaylist?: CurrentPlaylist; currentPlaylist?: CurrentPlaylist;
@ -11,6 +16,13 @@ export interface PlayerContext {
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>; setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
} }
const playerContext = React.createContext<PlayerContext>({ setCurrentPlaylist() {}, setCurrentTrack() {} }); const playerContext = React.createContext<PlayerContext>({
setCurrentPlaylist() {
return;
},
setCurrentTrack() {
return;
},
});
export default playerContext; export default playerContext;

View File

@ -9,7 +9,9 @@ export interface PreferencesContext extends PreferencesContextProperties {
const preferencesContext = React.createContext<PreferencesContext>({ const preferencesContext = React.createContext<PreferencesContext>({
playlistImages: false, playlistImages: false,
setPreferences() { } setPreferences() {
return;
},
}); });
export default preferencesContext; export default preferencesContext;

View File

@ -1,10 +1,18 @@
import color from "color"; import color from "color";
export function generateRandomColor(lightness: number=70): string { export function generateRandomColor(lightness = 70): string {
return "hsl(" + 360 * Math.random() + "," + (25 + 70 * Math.random()) + "%," + (lightness + 10 * Math.random()) + "%)"; return (
"hsl(" +
360 * Math.random() +
"," +
(25 + 70 * Math.random()) +
"%," +
(lightness + 10 * Math.random()) +
"%)"
);
} }
export function getDarkenForeground(hslcolor: string): string { export function getDarkenForeground(hslcolor: string): string {
const adjustedColor = color(hslcolor); const adjustedColor = color(hslcolor);
return adjustedColor.darken(.5).hex(); return adjustedColor.darken(0.5).hex();
} }

View File

@ -1,36 +1,67 @@
import axios from "axios"; import axios from "axios";
import htmlToText from "html-to-text"; import htmlToText from "html-to-text";
import showError from "./showError"; import showError from "./showError";
const delim1 = '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">'; const delim1 =
const delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">'; '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">';
const delim2 =
'</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
const url = "https://www.google.com/search?q="; const url = "https://www.google.com/search?q=";
export default async function fetchLyrics(artists: string, title: string) { export default async function fetchLyrics(artists: string, title: string) {
let lyrics; let lyrics;
try { try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+lyrics`); console.log(
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+lyrics`, { responseType: "text" })).data; "[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
{ responseType: "text" },
)
).data;
[, lyrics] = lyrics.split(delim1); [, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2); [lyrics] = lyrics.split(delim2);
} catch (err) { } catch (err) {
showError(err, "[Lyric Query Error]: "); showError(err, "[Lyric Query Error]: ");
try { try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`); console.log(
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`)).data; "[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
)
).data;
[, lyrics] = lyrics.split(delim1); [, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2); [lyrics] = lyrics.split(delim2);
} catch (err_1) { } catch (err_1) {
showError(err_1, "[Lyric Query Error]: "); showError(err_1, "[Lyric Query Error]: ");
try { try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song`); console.log(
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song`)).data; "[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}+song`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}+song`,
)
).data;
[, lyrics] = lyrics.split(delim1); [, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2); [lyrics] = lyrics.split(delim2);
} catch (err_2) { } catch (err_2) {
showError(err_2, "[Lyric Query Error]: "); showError(err_2, "[Lyric Query Error]: ");
try { try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}`); console.log(
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}`)).data; "[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}`,
)
).data;
[, lyrics] = lyrics.split(delim1); [, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2); [lyrics] = lyrics.split(delim2);
} catch (err_3) { } catch (err_3) {

View File

@ -15,7 +15,10 @@ interface ImageDimensions {
const fsm = fs.promises; const fsm = fs.promises;
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> { export async function getCachedImageBuffer(
name: string,
dims?: ImageDimensions,
): Promise<Buffer> {
try { try {
const MB_5 = 5000000; //5 Megabytes const MB_5 = 5000000; //5 Megabytes
const cacheImgFolder = path.join(cacheDir, "images"); const cacheImgFolder = path.join(cacheDir, "images");
@ -33,20 +36,34 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
// if the dimensions are changed then the previously cached // if the dimensions are changed then the previously cached
// images are removed and replaced with a new one // images are removed and replaced with a new one
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) { if (
dims &&
(cachedImgMeta.height !== dims.height ||
cachedImgMeta.width !== dims?.width)
) {
fs.unlinkSync(cacheImgPath); fs.unlinkSync(cacheImgPath);
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims }); return await imageResizeAndWrite(cachedImg, {
cacheFolder: cacheImgFolder,
cacheName,
dims,
});
} }
return cachedImg; return cachedImg;
} else { } else {
// finding no cache image fetching it through axios // finding no cache image fetching it through axios
const { data: imgData } = await axios.get<Stream>(name, { responseType: "stream" }); const { data: imgData } = await axios.get<Stream>(name, {
responseType: "stream",
});
// converting axios stream to buffer // converting axios stream to buffer
const resImgBuf = await streamToBuffer(imgData); const resImgBuf = await streamToBuffer(imgData);
// creating cache_dir // creating cache_dir
await fsm.mkdir(cacheImgFolder, { recursive: true }); await fsm.mkdir(cacheImgFolder, { recursive: true });
if (dims) { if (dims) {
return await imageResizeAndWrite(resImgBuf, { cacheFolder: cacheImgFolder, cacheName, dims }); return await imageResizeAndWrite(resImgBuf, {
cacheFolder: cacheImgFolder,
cacheName,
dims,
});
} }
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf); await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
return resImgBuf; return resImgBuf;
@ -57,7 +74,14 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
} }
} }
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> { async function imageResizeAndWrite(
img: Buffer,
{
cacheFolder,
cacheName,
dims,
}: { dims: ImageDimensions; cacheFolder: string; cacheName: string },
): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height) // caching the images by resizing if the max/fixed (Width/Height)
// is available in the args // is available in the args
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height); const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);

View File

@ -10,7 +10,10 @@ import { CurrentTrack } from "../context/playerContext";
* @param {(Array<string | number>)} matches * @param {(Array<string | number>)} matches
* @return {*} {number} * @return {*} {number}
*/ */
export function includePercentage(src: string | Array<string | number>, matches: Array<string | number>): number { export function includePercentage(
src: string | Array<string | number>,
matches: Array<string | number>,
): number {
let count = 0; let count = 0;
matches.forEach((match) => { matches.forEach((match) => {
if (src.includes(match.toString())) count++; if (src.includes(match.toString())) count++;
@ -22,27 +25,51 @@ export interface YoutubeTrack extends CurrentTrack {
youtube_uri: string; youtube_uri: string;
} }
export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promise<YoutubeTrack> { export async function getYoutubeTrack(
track: SpotifyApi.TrackObjectFull,
): Promise<YoutubeTrack> {
try { try {
const artistsName = track.artists.map((ar) => ar.name); const artistsName = track.artists.map((ar) => ar.name);
const queryString = `${artistsName[0]} - ${track.name}${artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``}`; const queryString = `${artistsName[0]} - ${track.name}${
artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``
}`;
console.log("Youtube Query String:", queryString); console.log("Youtube Query String:", queryString);
const result = await scrapYt.search(queryString, { limit: 7, type: "video" }); const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
const tracksWithRelevance = result const tracksWithRelevance = result
.map((video) => { .map((video) => {
// percentage of matched track {name, artists} matched with // percentage of matched track {name, artists} matched with
// title of the youtube search results // title of the youtube search results
const matchPercentage = includePercentage(video.title, [track.name, ...artistsName]); const matchPercentage = includePercentage(video.title, [
track.name,
...artistsName,
]);
// keeps only those tracks which are from the same artist channel // keeps only those tracks which are from the same artist channel
const sameChannel = video.channel.name.includes(artistsName[0]) || artistsName[0].includes(video.channel.name); const sameChannel =
return { url: `http://www.youtube.com/watch?v=${video.id}`, matchPercentage, sameChannel, id: track.id }; video.channel.name.includes(artistsName[0]) ||
artistsName[0].includes(video.channel.name);
return {
url: `http://www.youtube.com/watch?v=${video.id}`,
matchPercentage,
sameChannel,
id: track.id,
};
}) })
.sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1)); .sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1));
const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel); const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel);
const rarestTrack = result.map((res) => ({ url: `http://www.youtube.com/watch?v=${res.id}`, id: res.id })); const rarestTrack = result.map((res) => ({
url: `http://www.youtube.com/watch?v=${res.id}`,
id: res.id,
}));
const finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance.length > 0 ? tracksWithRelevance : rarestTrack)[0].url }; const finalTrack = {
...track,
youtube_uri: (sameChannelTracks.length > 0
? sameChannelTracks
: tracksWithRelevance.length > 0
? tracksWithRelevance
: rarestTrack)[0].url,
};
return finalTrack; return finalTrack;
} catch (error) { } catch (error) {
console.error("Failed to resolve track's youtube url: ", error); console.error("Failed to resolve track's youtube url: ", error);

View File

@ -1,5 +1,5 @@
export function msToMinAndSec(ms: number) { export function msToMinAndSec(ms: number) {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
const seconds: number = parseInt(((ms % 60000) / 1000).toFixed(0)); const seconds: number = parseInt(((ms % 60000) / 1000).toFixed(0));
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
} }

View File

@ -1,7 +1,7 @@
import { trace } from "../conf"; import { trace } from "../conf";
import chalk from "chalk"; import chalk from "chalk";
function showError(error: any, message: any="[Error]: ") { function showError(error: Error | TypeError, message = "[Error]: ") {
console.error(chalk.red(message), trace ? error : error.message); console.error(chalk.red(message), trace ? error : error.message);
} }

View File

@ -1,7 +1,7 @@
import { Stream } from "stream"; import { Stream } from "stream";
export function streamToBuffer(stream: Stream): Promise<Buffer> { export function streamToBuffer(stream: Stream): Promise<Buffer> {
let buffArr: any[] = []; const buffArr: Uint8Array[] = [];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on("data", (data) => { stream.on("data", (data) => {
buffArr.push(data); buffArr.push(data);

View File

@ -40,9 +40,9 @@ function useDownloadQueue() {
`${el.name} - ${el.artists `${el.name} - ${el.artists
.map((x) => x.name) .map((x) => x.name)
.join(", ") .join(", ")
.trim()}.mp3` .trim()}.mp3`,
) ),
) ),
) )
.on("error", (err) => { .on("error", (err) => {
showError(err, `[failed to download ${el.name}]: `); showError(err, `[failed to download ${el.name}]: `);

View File

@ -5,17 +5,25 @@ import useSpotifyMutation from "./useSpotifyMutation";
function usePlaylistReaction() { function usePlaylistReaction() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: favoritePagedPlaylists } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(QueryCacheKeys.userPlaylists, (spotifyApi, { pageParam }) => const { data: favoritePagedPlaylists } =
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => { useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
QueryCacheKeys.userPlaylists,
(spotifyApi, { pageParam }) =>
spotifyApi
.getUserPlaylists({ limit: 20, offset: pageParam })
.then((userPlaylists) => {
return userPlaylists.body; return userPlaylists.body;
}) }),
); );
const favoritePlaylists = favoritePagedPlaylists?.pages const favoritePlaylists = favoritePagedPlaylists?.pages
.map((playlist) => playlist.items) .map((playlist) => playlist.items)
.filter(Boolean) .filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]; .flat(1) as SpotifyApi.PlaylistObjectSimplified[];
function updateFunction(playlist: SpotifyApi.PlaylistObjectSimplified, old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> { function updateFunction(
playlist: SpotifyApi.PlaylistObjectSimplified,
old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>,
): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
const obj: typeof old = { const obj: typeof old = {
pageParams: old?.pageParams ?? [], pageParams: old?.pageParams ?? [],
pages: pages:
@ -25,22 +33,35 @@ function usePlaylistReaction() {
if (index === 0 && !isPlaylistFavorite) { if (index === 0 && !isPlaylistFavorite) {
return { ...oldPage, items: [...oldPage.items, playlist] }; return { ...oldPage, items: [...oldPage.items, playlist] };
} else if (isPlaylistFavorite) { } else if (isPlaylistFavorite) {
return { ...oldPage, items: oldPage.items.filter((oldPlaylist) => oldPlaylist.id !== playlist.id) }; return {
...oldPage,
items: oldPage.items.filter(
(oldPlaylist) => oldPlaylist.id !== playlist.id,
),
};
} }
return oldPage; return oldPage;
} },
) ?? [], ) ?? [],
}; };
return obj; return obj;
} }
const { mutate: reactToPlaylist } = useSpotifyMutation<{}, SpotifyApi.PlaylistObjectSimplified>( const { mutate: reactToPlaylist } = useSpotifyMutation<
(spotifyApi, { id }) => spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then((res) => res.body), unknown,
SpotifyApi.PlaylistObjectSimplified
>(
(spotifyApi, { id }) =>
spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then(
(res) => res.body,
),
{ {
onSuccess(_, playlist) { onSuccess(_, playlist) {
queryClient.setQueryData<InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old)); queryClient.setQueryData<
InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>
>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
},
}, },
}
); );
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id); const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);

View File

@ -1,4 +1,3 @@
import chalk from "chalk";
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { LocalStorageKeys } from "../conf"; import { LocalStorageKeys } from "../conf";
import authContext from "../context/authContext"; import authContext from "../context/authContext";
@ -6,13 +5,8 @@ import showError from "../helpers/showError";
import spotifyApi from "../initializations/spotifyApi"; import spotifyApi from "../initializations/spotifyApi";
function useSpotifyApi() { function useSpotifyApi() {
const { const { access_token, clientId, clientSecret, isLoggedIn, setAccess_token } =
access_token, useContext(authContext);
clientId,
clientSecret,
isLoggedIn,
setAccess_token,
} = useContext(authContext);
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token); const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
useEffect(() => { useEffect(() => {

View File

@ -6,13 +6,17 @@ import showError from "../helpers/showError";
function useSpotifyApiError(spotifyApi: SpotifyWebApi) { function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
const { setAccess_token, isLoggedIn } = useContext(authContext); const { setAccess_token, isLoggedIn } = useContext(authContext);
return async (error: any | Error | TypeError) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
return async (error: SpotifyApi.ErrorObject | any) => {
const isUnauthorized = error.message === "Unauthorized"; const isUnauthorized = error.message === "Unauthorized";
const status401 = error.status === 401; const status401 = error.status === 401;
const bodyStatus401 = error.body.error.status === 401; const bodyStatus401 = error.body.error.status === 401;
const noToken = error.body.error.message === "No token provided"; const noToken = error.body.error.message === "No token provided";
const expiredToken = error.body.error.message === "The access token expired"; const expiredToken = error.body.error.message === "The access token expired";
if ((isUnauthorized && isLoggedIn && status401) || ((noToken || expiredToken) && bodyStatus401)) { if (
(isUnauthorized && isLoggedIn && status401) ||
((noToken || expiredToken) && bodyStatus401)
) {
try { try {
console.log(chalk.bgYellow.blackBright("Refreshing Access token")); console.log(chalk.bgYellow.blackBright("Refreshing Access token"));
const { const {

View File

@ -1,19 +1,32 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { QueryFunctionContext, QueryKey, useInfiniteQuery, UseInfiniteQueryOptions, UseInfiniteQueryResult } from "react-query"; import {
QueryFunctionContext,
QueryKey,
useInfiniteQuery,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
} from "react-query";
import SpotifyWebApi from "spotify-web-api-node"; import SpotifyWebApi from "spotify-web-api-node";
import useSpotifyApi from "./useSpotifyApi"; import useSpotifyApi from "./useSpotifyApi";
import useSpotifyApiError from "./useSpotifyApiError"; import useSpotifyApiError from "./useSpotifyApiError";
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi, pageArgs: QueryFunctionContext) => Promise<TQueryData>; type SpotifyQueryFn<TQueryData> = (
spotifyApi: SpotifyWebApi,
pageArgs: QueryFunctionContext,
) => Promise<TQueryData>;
function useSpotifyInfiniteQuery<TQueryData = unknown>( function useSpotifyInfiniteQuery<TQueryData = unknown>(
queryKey: QueryKey, queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>, queryHandler: SpotifyQueryFn<TQueryData>,
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {} options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> { ): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> {
const spotifyApi = useSpotifyApi(); const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi); const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, (pageArgs) => queryHandler(spotifyApi, pageArgs), options); const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(
queryKey,
(pageArgs) => queryHandler(spotifyApi, pageArgs),
options,
);
const { isError, error } = query; const { isError, error } = query;
useEffect(() => { useEffect(() => {

View File

@ -4,12 +4,21 @@ import SpotifyWebApi from "spotify-web-api-node";
import useSpotifyApi from "./useSpotifyApi"; import useSpotifyApi from "./useSpotifyApi";
import useSpotifyApiError from "./useSpotifyApiError"; import useSpotifyApiError from "./useSpotifyApiError";
type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (spotifyApi: SpotifyWebApi, variables: TVariables) => Promise<TData>; type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (
spotifyApi: SpotifyWebApi,
variables: TVariables,
) => Promise<TData>;
function useSpotifyMutation<TData = unknown, TVariable = unknown>(mutationFn: SpotifyMutationFn<TData, TVariable>, options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>) { function useSpotifyMutation<TData = unknown, TVariable = unknown>(
mutationFn: SpotifyMutationFn<TData, TVariable>,
options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>,
) {
const spotifyApi = useSpotifyApi(); const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi); const handleSpotifyError = useSpotifyApiError(spotifyApi);
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>((arg) => mutationFn(spotifyApi, arg), options); const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>(
(arg) => mutationFn(spotifyApi, arg),
options,
);
const { isError, error } = mutation; const { isError, error } = mutation;
useEffect(() => { useEffect(() => {

View File

@ -9,14 +9,17 @@ type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryD
function useSpotifyQuery<TQueryData = unknown>( function useSpotifyQuery<TQueryData = unknown>(
queryKey: QueryKey, queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>, queryHandler: SpotifyQueryFn<TQueryData>,
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {} options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> { ): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
const spotifyApi = useSpotifyApi(); const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi); const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, ()=>queryHandler(spotifyApi), options); const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(
queryKey,
() => queryHandler(spotifyApi),
options,
);
const { isError, error } = query; const { isError, error } = query;
useEffect(() => { useEffect(() => {
if (isError && error) { if (isError && error) {
handleSpotifyError(error); handleSpotifyError(error);

View File

@ -5,40 +5,59 @@ import useSpotifyMutation from "./useSpotifyMutation";
function useTrackReaction() { function useTrackReaction() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: userSavedTracks } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(QueryCacheKeys.userSavedTracks, (spotifyApi, { pageParam }) => const { data: userSavedTracks } =
spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body) useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) =>
spotifyApi
.getMySavedTracks({ limit: 50, offset: pageParam })
.then((res) => res.body),
); );
const favoriteTracks = userSavedTracks?.pages const favoriteTracks = userSavedTracks?.pages
?.map((page) => page.items) ?.map((page) => page.items)
.filter(Boolean) .filter(Boolean)
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined; .flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
function updateFunction(track: SpotifyApi.SavedTrackObject, old?: InfiniteData<SpotifyApi.UsersSavedTracksResponse>): InfiniteData<SpotifyApi.UsersSavedTracksResponse> { function updateFunction(
track: SpotifyApi.SavedTrackObject,
old?: InfiniteData<SpotifyApi.UsersSavedTracksResponse>,
): InfiniteData<SpotifyApi.UsersSavedTracksResponse> {
const obj: typeof old = { const obj: typeof old = {
pageParams: old?.pageParams ?? [], pageParams: old?.pageParams ?? [],
pages: pages:
old?.pages.map( old?.pages.map((oldPage, index): SpotifyApi.UsersSavedTracksResponse => {
(oldPage, index): SpotifyApi.UsersSavedTracksResponse => {
const isTrackFavorite = isFavorite(track.track.id); const isTrackFavorite = isFavorite(track.track.id);
if (index === 0 && !isTrackFavorite) { if (index === 0 && !isTrackFavorite) {
return { ...oldPage, items: [...oldPage.items, track] }; return { ...oldPage, items: [...oldPage.items, track] };
} else if (isTrackFavorite) { } else if (isTrackFavorite) {
return { ...oldPage, items: oldPage.items.filter((oldTrack) => oldTrack.track.id !== track.track.id) }; return {
...oldPage,
items: oldPage.items.filter(
(oldTrack) => oldTrack.track.id !== track.track.id,
),
};
} }
return oldPage; return oldPage;
} }) ?? [],
) ?? [],
}; };
return obj; return obj;
} }
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>( const { mutate: reactToTrack } = useSpotifyMutation<
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body), unknown,
SpotifyApi.SavedTrackObject
>(
(spotifyApi, { track }) =>
spotifyApi[
isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"
]([track.id]).then((res) => res.body),
{ {
onSuccess(_, track) { onSuccess(_, track) {
queryClient.setQueryData<InfiniteData<SpotifyApi.UsersSavedTracksResponse>>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old)); queryClient.setQueryData<
InfiniteData<SpotifyApi.UsersSavedTracksResponse>
>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old));
},
}, },
}
); );
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id); const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);

View File

@ -1,18 +1,18 @@
import _play from "../assets/play-solid.svg"; import _play from "../assets/play-solid.svg";
import _pause from "../assets/pause-solid.svg" import _pause from "../assets/pause-solid.svg";
import _angleLeft from "../assets/angle-left-solid.svg" import _angleLeft from "../assets/angle-left-solid.svg";
import _backward from "../assets/backward-solid.svg" import _backward from "../assets/backward-solid.svg";
import _forward from "../assets/forward-solid.svg" import _forward from "../assets/forward-solid.svg";
import _heartRegular from "../assets/heart-regular.svg" import _heartRegular from "../assets/heart-regular.svg";
import _heart from "../assets/heart-solid.svg" 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"; import _loadingSpinner from "../assets/loading-spinner.gif";
import _settingsCog from "../assets/setting-cog.svg" import _settingsCog from "../assets/setting-cog.svg";
import _times from "../assets/times-solid.svg" import _times from "../assets/times-solid.svg";
import _musicNode from "../assets/music-solid.svg" import _musicNode from "../assets/music-solid.svg";
import _download from "../assets/download-solid.svg" import _download from "../assets/download-solid.svg";
export const play = _play; export const play = _play;
export const pause = _pause; export const pause = _pause;

View File

@ -12,6 +12,7 @@ import Search from "./components/Search";
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection"; import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
import SearchResultSongsCollection from "./components/SearchResultSongsCollection"; import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
import Settings from "./components/Settings"; import Settings from "./components/Settings";
import Artist from "./components/Artist";
function Routes() { function Routes() {
const { isLoggedIn } = useContext(authContext); const { isLoggedIn } = useContext(authContext);
@ -42,6 +43,9 @@ function Routes() {
<Route path="/library"> <Route path="/library">
<Library /> <Library />
</Route> </Route>
<Route path="/artist">
<Artist />
</Route>
<Route exact path="/search"> <Route exact path="/search">
<Search /> <Search />
</Route> </Route>

File diff suppressed because it is too large Load Diff