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,11 +23,13 @@ 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;
clientSecret: string; clientSecret: string;
} }
const minSize = { width: 700, height: 750 }; const minSize = { width: 700, height: 750 };
@ -29,183 +38,244 @@ const localStorageDir = path.join(confDir, "local");
fs.mkdirSync(localStorageDir, { recursive: true }); fs.mkdirSync(localStorageDir, { recursive: true });
global.localStorage = new LocalStorage(localStorageDir); global.localStorage = new LocalStorage(localStorageDir);
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
onError(error) { onError(error) {
showError(error); showError(error);
}, },
},
}, },
},
}); });
const initialPreferences: PreferencesContextProperties = { const initialPreferences: PreferencesContextProperties = {
playlistImages: false, playlistImages: false,
}; };
const initialCredentials: Credentials = { clientId: "", clientSecret: "" }; const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
//* Application start //* Application start
function RootApp() { function RootApp() {
const windowRef = useRef<QMainWindow>(); const windowRef = useRef<QMainWindow>();
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>(); const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
// cache // cache
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences); const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials); const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
// state // state
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false); const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [credentials, setCredentials] = useState<Credentials>(() => { const [credentials, setCredentials] = useState<Credentials>(() => {
if (cachedCredentials) { if (cachedCredentials) {
return JSON.parse(cachedCredentials); return JSON.parse(cachedCredentials);
}
return initialCredentials;
});
const [preferences, setPreferences] = useState<PreferencesContextProperties>(() => {
if (cachedPreferences) {
return JSON.parse(cachedPreferences);
}
return initialPreferences;
});
const [access_token, setAccess_token] = useState<string>("");
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
useEffect(() => {
const parsedCredentials: Credentials = JSON.parse(cachedCredentials ?? "{}");
setIsLoggedIn(!!(parsedCredentials.clientId && parsedCredentials.clientSecret));
}, []);
// for user code login
useEffect(() => {
// saving changed credentials to storage
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
if (credentials.clientId && credentials.clientSecret && !localStorage.getItem(LocalStorageKeys.refresh_token)) {
const app = express();
app.use(express.json());
app.get<null, null, null, { code: string }>("/auth/spotify/callback", async (req, res) => {
try {
spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret);
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
setAccess_token(authRes.access_token);
localStorage.setItem(LocalStorageKeys.refresh_token, authRes.refresh_token);
setIsLoggedIn(true);
return res.end();
} catch (error) {
console.error("Failed to fullfil code grant flow: ", error);
} }
}); return initialCredentials;
});
const server = app.listen(4304, () => { const [preferences, setPreferences] = useState<PreferencesContextProperties>(() => {
console.log("Server is running"); if (cachedPreferences) {
spotifyApi.setClientId(credentials.clientId); return JSON.parse(cachedPreferences);
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) =>
console.error("Opening IPC connection with browser failed: ", e)
);
});
return () => {
server.close(() => console.log("Closed server"));
};
}
}, [credentials]);
// just saves the preferences
useEffect(() => {
localStorage.setItem(LocalStorageKeys.preferences, JSON.stringify(preferences));
}, [preferences]);
// window event listeners
useEffect(() => {
const onWindowClose = () => {
if (audioPlayer.isRunning()) {
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e));
}
};
windowRef.current?.addEventListener(WidgetEventTypes.Close, onWindowClose);
return () => {
windowRef.current?.removeEventListener(WidgetEventTypes.Close, onWindowClose);
};
});
let spaceShortcut: QShortcut | null;
let rightShortcut: QShortcut | null;
let leftShortcut: QShortcut | null;
// short cut effect
useEffect(() => {
if (windowRef.current) {
spaceShortcut = new QShortcut(windowRef.current);
rightShortcut = new QShortcut(windowRef.current);
leftShortcut = new QShortcut(windowRef.current);
spaceShortcut.setKey(new QKeySequence("SPACE"));
rightShortcut.setKey(new QKeySequence("RIGHT"));
leftShortcut.setKey(new QKeySequence("LEFT"));
async function spaceAction() {
try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause();
console.log("You pressed SPACE");
} catch (error) {
showError(error, "[Failed to play/pause audioPlayer]: ");
} }
} return initialPreferences;
async function rightAction() { });
try { const [access_token, setAccess_token] = useState<string>("");
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5)); const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
console.log("You pressed RIGHT");
} catch (error) { useEffect(() => {
showError(error, "[Failed to seek audioPlayer]: "); const parsedCredentials: Credentials = JSON.parse(cachedCredentials ?? "{}");
setIsLoggedIn(!!(parsedCredentials.clientId && parsedCredentials.clientSecret));
}, []);
// for user code login
useEffect(() => {
// saving changed credentials to storage
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
if (
credentials.clientId &&
credentials.clientSecret &&
!localStorage.getItem(LocalStorageKeys.refresh_token)
) {
const app = express();
app.use(express.json());
app.get<null, null, null, { code: string }>(
"/auth/spotify/callback",
async (req, res) => {
try {
spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret);
const { body: authRes } = await spotifyApi.authorizationCodeGrant(
req.query.code,
);
setAccess_token(authRes.access_token);
localStorage.setItem(
LocalStorageKeys.refresh_token,
authRes.refresh_token,
);
setIsLoggedIn(true);
return res.end();
} catch (error) {
console.error("Failed to fullfil code grant flow: ", error);
}
},
);
const server = app.listen(4304, () => {
console.log("Server is running");
spotifyApi.setClientId(credentials.clientId);
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) =>
console.error("Opening IPC connection with browser failed: ", e),
);
});
return () => {
server.close(() => console.log("Closed server"));
};
} }
} }, [credentials]);
async function leftAction() {
try { // just saves the preferences
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5)); useEffect(() => {
console.log("You pressed LEFT"); localStorage.setItem(LocalStorageKeys.preferences, JSON.stringify(preferences));
} catch (error) { }, [preferences]);
showError(error, "[Failed to seek audioPlayer]: ");
// window event listeners
useEffect(() => {
const onWindowClose = () => {
if (audioPlayer.isRunning()) {
audioPlayer
.stop()
.catch((e) => console.error("Failed to quit MPV player: ", e));
}
};
windowRef.current?.addEventListener(WidgetEventTypes.Close, onWindowClose);
return () => {
windowRef.current?.removeEventListener(WidgetEventTypes.Close, onWindowClose);
};
});
let spaceShortcut: QShortcut | null;
let rightShortcut: QShortcut | null;
let leftShortcut: QShortcut | null;
// short cut effect
useEffect(() => {
if (windowRef.current) {
spaceShortcut = new QShortcut(windowRef.current);
rightShortcut = new QShortcut(windowRef.current);
leftShortcut = new QShortcut(windowRef.current);
spaceShortcut.setKey(new QKeySequence("SPACE"));
rightShortcut.setKey(new QKeySequence("RIGHT"));
leftShortcut.setKey(new QKeySequence("LEFT"));
async function spaceAction() {
try {
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isPaused())
? await audioPlayer.play()
: await audioPlayer.pause();
console.log("You pressed SPACE");
} catch (error) {
showError(error, "[Failed to play/pause audioPlayer]: ");
}
}
async function rightAction() {
try {
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(+5));
console.log("You pressed RIGHT");
} catch (error) {
showError(error, "[Failed to seek audioPlayer]: ");
}
}
async function leftAction() {
try {
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(-5));
console.log("You pressed LEFT");
} catch (error) {
showError(error, "[Failed to seek audioPlayer]: ");
}
}
spaceShortcut.addEventListener("activated", spaceAction);
rightShortcut.addEventListener("activated", rightAction);
leftShortcut.addEventListener("activated", leftAction);
return () => {
spaceShortcut?.removeEventListener("activated", spaceAction);
rightShortcut?.removeEventListener("activated", rightAction);
leftShortcut?.removeEventListener("activated", leftAction);
spaceShortcut = null;
rightShortcut = null;
leftShortcut = null;
};
} }
} });
spaceShortcut.addEventListener("activated", spaceAction); return (
rightShortcut.addEventListener("activated", rightAction); <Window
leftShortcut.addEventListener("activated", leftAction); ref={windowRef}
windowState={WindowState.WindowMaximized}
return () => { windowIcon={winIcon}
spaceShortcut?.removeEventListener("activated", spaceAction); windowTitle="Spotube"
rightShortcut?.removeEventListener("activated", rightAction); minSize={minSize}
leftShortcut?.removeEventListener("activated", leftAction); >
spaceShortcut = null; <MemoryRouter>
rightShortcut = null; <authContext.Provider
leftShortcut = null; value={{
}; isLoggedIn,
} setIsLoggedIn,
}); access_token,
setAccess_token,
return ( ...credentials,
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}> setCredentials,
<MemoryRouter> }}
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}> >
<preferencesContext.Provider value={{ ...preferences, setPreferences }}> <preferencesContext.Provider
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}> value={{ ...preferences, setPreferences }}
<QueryClientProvider client={queryClient}> >
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}> <playerContext.Provider
<Routes /> value={{
{isLoggedIn && <Player />} currentPlaylist,
</View> currentTrack,
</QueryClientProvider> setCurrentPlaylist,
</playerContext.Provider> setCurrentTrack,
</preferencesContext.Provider> }}
</authContext.Provider> >
</MemoryRouter> <QueryClientProvider client={queryClient}>
</Window> <View
); style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}
>
<Routes />
{isLoggedIn && <Player />}
</View>
</QueryClientProvider>
</playerContext.Provider>
</preferencesContext.Provider>
</authContext.Provider>
</MemoryRouter>
</Window>
);
} }
class App extends React.Component { class App extends React.Component {
render() { render() {
return <RootApp />; return <RootApp />;
} }
} }
export default hot(App); export default hot(App);

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

@ -5,9 +5,15 @@ import { angleLeft } from "../icons";
import IconButton from "./shared/IconButton"; 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

@ -5,31 +5,39 @@ import { TrackTableIndex } from "./PlaylistView";
import { TrackButton } from "./shared/TrackButton"; import { TrackButton } from "./shared/TrackButton";
function CurrentPlaylist() { 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 (
<View style="flex: 1; flex-direction: 'column';"> <View style="flex: 1; flex-direction: 'column';">
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text> <Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
<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;`}>
{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>
</ScrollArea>
</View> </View>
</ScrollArea> );
</View>
);
} }
export default CurrentPlaylist; export default CurrentPlaylist;

View File

@ -7,58 +7,96 @@ 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 {
QueryCacheKeys.categories, data: pagedCategories,
(spotifyApi, { pageParam }) => spotifyApi.getCategories({ country: "US", limit: 10, offset: pageParam }).then((categoriesReceived) => categoriesReceived.body.categories), isError,
{ refetch,
getNextPageParam(lastPage) { isLoading,
if (lastPage.next) { hasNextPage,
return lastPage.offset + lastPage.limit; isFetchingNextPage,
} fetchNextPage,
}, } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>(
} QueryCacheKeys.categories,
); (spotifyApi, { pageParam }) =>
spotifyApi
.getCategories({ country: "US", limit: 10, offset: pageParam })
.then((categoriesReceived) => categoriesReceived.body.categories),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
},
);
const categories = pagedCategories?.pages const categories = pagedCategories?.pages
.map((page) => page.items) .map((page) => page.items)
.filter(Boolean) .filter(Boolean)
.flat(1); .flat(1);
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" }); categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
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
{categories?.map((category, index) => { error={isError}
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />; message="Failed to query genres"
})} reload={refetch}
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />} helps
</View> loading={isLoading}
</ScrollArea> />
); {categories?.map((category, index) => {
return (
<CategoryCard
key={index + category.id}
id={category.id}
name={category.name}
/>
);
})}
{hasNextPage && (
<Button
on={{ clicked: () => fetchNextPage() }}
text="Load More"
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
);
} }
export default Home; export default Home;
interface CategoryCardProps { interface CategoryCardProps {
id: string; id: string;
name: string; name: string;
} }
const CategoryCard = ({ id, name }: CategoryCardProps) => { const CategoryCard = ({ id, name }: CategoryCardProps) => {
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>( const { data: playlists, isError } = useSpotifyQuery<
[QueryCacheKeys.categoryPlaylists, id], SpotifyApi.PlaylistObjectSimplified[]
async (spotifyApi) => { >(
const option = { limit: 4 }; [QueryCacheKeys.categoryPlaylists, id],
let res; async (spotifyApi) => {
if (id === "featured") { const option = { limit: 4 };
res = await spotifyApi.getFeaturedPlaylists(option); let res;
} else { if (id === "featured") {
res = await spotifyApi.getPlaylistsForCategory(id, option); res = await spotifyApi.getFeaturedPlaylists(option);
} } else {
return res.body.playlists.items; res = await spotifyApi.getPlaylistsForCategory(id, option);
}, }
{ initialData: [] } return res.body.playlists.items;
); },
{ 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,139 +7,279 @@ 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";
function Library() { function Library() {
return ( return (
<View style="flex: 1; flex-direction: 'column';"> <View style="flex: 1; flex-direction: 'column';">
<Redirect from="/library" to="/library/saved-tracks" /> <Redirect from="/library" to="/library/saved-tracks" />
<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" />
</View> <TabMenuItem title="Artists" url="/library/followed-artists" />
<Route exact path="/library/saved-tracks"> </View>
<UserSavedTracks /> <Route exact path="/library/saved-tracks">
</Route> <UserSavedTracks />
<Route exact path="/library/playlists"> </Route>
<UserPlaylists /> <Route exact path="/library/playlists">
</Route> <UserPlaylists />
</View> </Route>
); <Route exact path="/library/followed-artists">
<FollowedArtists />
</Route>
</View>
);
} }
export default Library; export default Library;
function UserPlaylists() { function UserPlaylists() {
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>( const {
QueryCacheKeys.userPlaylists, data: userPagedPlaylists,
(spotifyApi, { pageParam }) => isError,
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => { isLoading,
return userPlaylists.body; refetch,
}), isFetchingNextPage,
{ hasNextPage,
getNextPageParam(lastPage) { fetchNextPage,
if (lastPage.next) { } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
return lastPage.offset + lastPage.limit; QueryCacheKeys.userPlaylists,
} (spotifyApi, { pageParam }) =>
}, spotifyApi
} .getUserPlaylists({ limit: 20, offset: pageParam })
); .then((userPlaylists) => {
return userPlaylists.body;
}),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
},
);
const userPlaylists = userPagedPlaylists?.pages const userPlaylists = userPagedPlaylists?.pages
?.map((playlist) => playlist.items) ?.map((playlist) => playlist.items)
.filter(Boolean) .filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]; .flat(1) as SpotifyApi.PlaylistObjectSimplified[];
return ( return (
<GenreView <GenreView
heading="User Playlists" heading="User Playlists"
isError={isError} isError={isError}
isLoading={isLoading} isLoading={isLoading}
playlists={userPlaylists ?? []} playlists={userPlaylists ?? []}
isLoadable={!isFetchingNextPage} isLoadable={!isFetchingNextPage}
refetch={refetch} refetch={refetch}
loadMore={hasNextPage ? ()=>fetchNextPage() : undefined} loadMore={hasNextPage ? () => fetchNextPage() : undefined}
/> />
); );
} }
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 {
QueryCacheKeys.userSavedTracks, data: userSavedTracks,
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body), fetchNextPage,
{ hasNextPage,
getNextPageParam(lastPage) { isFetchingNextPage,
if (lastPage.next) { isError,
return lastPage.offset + lastPage.limit; isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) =>
spotifyApi
.getMySavedTracks({ limit: 50, offset: pageParam })
.then((res) => res.body),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
},
);
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } =
useContext(playerContext);
const userTracks = userSavedTracks?.pages
?.map((page) => page.items)
.filter(Boolean)
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
function handlePlaylistPlayPause(index?: number) {
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
setCurrentPlaylist({
id: userSavedPlaylistId,
name: "Liked Tracks",
thumbnail:
"https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg",
tracks: userTracks,
});
setCurrentTrack(userTracks[index ?? 0].track);
} else {
setCurrentPlaylist(undefined);
setCurrentTrack(undefined);
} }
},
} }
);
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext);
const userTracks = userSavedTracks?.pages const playlist: TrackButtonPlaylistObject = {
?.map((page) => page.items) collaborative: false,
.filter(Boolean) description: "User Playlist",
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined; tracks: {
items: userTracks ?? [],
function handlePlaylistPlayPause(index?: number) { limit: 20,
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) { href: "",
setCurrentPlaylist({ id: userSavedPlaylistId, name: "Liked Tracks", thumbnail: "https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg", tracks: userTracks }); next: "",
setCurrentTrack(userTracks[index ?? 0].track); offset: 0,
} else { previous: "",
setCurrentPlaylist(undefined); total: 20,
setCurrentTrack(undefined); },
} external_urls: { spotify: "" },
} href: "",
id: userSavedPlaylistId,
const playlist: TrackButtonPlaylistObject = { images: [{ url: "https://facebook.com/img.jpeg" }],
collaborative: false, name: "User saved track",
description: "User Playlist", owner: {
tracks: { external_urls: { spotify: "" },
items: userTracks ?? [], href: "",
limit: 20, id: "Me",
href: "", type: "user",
next: "", uri: "spotify:user:me",
offset: 0, display_name: "User",
previous: "", followers: { href: null, total: 0 },
total: 20, },
}, public: false,
external_urls: { spotify: "" }, snapshot_id: userSavedPlaylistId + "snapshot",
href: "", type: "playlist",
id: userSavedPlaylistId, uri: "spotify:user:me:saved-tracks",
images: [{ url: "https://facebook.com/img.jpeg" }], };
name: "User saved track", return (
owner: { external_urls: { spotify: "" }, href: "", id: "Me", type: "user", uri: "spotify:user:me", display_name: "User", followers: { href: null, total: 0 } }, <View style="flex: 1; flex-direction: 'column';">
public: false, <PlaylistSimpleControls
snapshot_id: userSavedPlaylistId + "snapshot", handlePlaylistPlayPause={handlePlaylistPlayPause}
type: "playlist", isActive={currentPlaylist?.id === userSavedPlaylistId}
uri: "spotify:user:me:saved-tracks",
};
return (
<View style="flex: 1; flex-direction: 'column';">
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
<TrackTableIndex />
<ScrollArea style="flex: 1; border: none;">
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)}
{hasNextPage && (
<Button
style="flex-grow: 0; align-self: 'center';"
text="Load more"
on={{
clicked() {
fetchNextPage();
},
}}
enabled={!isFetchingNextPage}
/> />
)} <TrackTableIndex />
<ScrollArea style="flex: 1; border: none;">
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
<PlaceholderApplet
error={isError}
loading={isLoading || isFetchingNextPage}
message="Failed querying spotify"
reload={refetch}
/>
{userTracks?.map(
({ track }, index) =>
track && (
<TrackButton
key={index + track.id}
track={track}
index={index}
playlist={playlist}
/>
),
)}
{hasNextPage && (
<Button
style="flex-grow: 0; align-self: 'center';"
text="Load more"
on={{
clicked() {
fetchNextPage();
},
}}
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
</View> </View>
</ScrollArea> );
</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

@ -3,63 +3,63 @@ import { LineEdit, Text, Button, View } from "@nodegui/react-nodegui";
import authContext from "../context/authContext"; import authContext from "../context/authContext";
function Login() { function Login() {
const { setCredentials: setGlobalCredentials } = useContext(authContext); const { setCredentials: setGlobalCredentials } = useContext(authContext);
const [credentials, setCredentials] = useState({ const [credentials, setCredentials] = useState({
clientId: "", clientId: "",
clientSecret: "", clientSecret: "",
}); });
const [touched, setTouched] = useState({ const [touched, setTouched] = useState({
clientId: false, clientId: false,
clientSecret: false, clientSecret: false,
}); });
type fieldNames = "clientId" | "clientSecret"; type fieldNames = "clientId" | "clientSecret";
function textChanged(text: string, fieldName: fieldNames) { function textChanged(text: string, fieldName: fieldNames) {
setCredentials({ ...credentials, [fieldName]: text }); setCredentials({ ...credentials, [fieldName]: text });
}
function textEdited(name: fieldNames) {
if (!touched[name]) {
setTouched({ ...touched, [name]: true });
} }
}
return ( function textEdited(name: fieldNames) {
<View style={`flex: 1; flex-direction: 'column';`}> if (!touched[name]) {
<Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text> setTouched({ ...touched, [name]: true });
<Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text> }
<LineEdit }
on={{
textChanged: (t) => textChanged(t, "clientId"), return (
textEdited() { <View style={`flex: 1; flex-direction: 'column';`}>
textEdited("clientId"); <Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text>
}, <Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
}} <LineEdit
text={credentials.clientId} on={{
placeholderText="spotify clientId" textChanged: (t) => textChanged(t, "clientId"),
/> textEdited() {
<LineEdit textEdited("clientId");
on={{ },
textChanged: (t) => textChanged(t, "clientSecret"), }}
textEdited() { text={credentials.clientId}
textEdited("clientSecret"); placeholderText="spotify clientId"
}, />
}} <LineEdit
text={credentials.clientSecret} on={{
placeholderText="spotify clientSecret" textChanged: (t) => textChanged(t, "clientSecret"),
/> textEdited() {
<Button textEdited("clientSecret");
on={{ },
clicked: () => { }}
setGlobalCredentials(credentials); text={credentials.clientSecret}
}, placeholderText="spotify clientSecret"
}} />
text="Add" <Button
/> on={{
</View> clicked: () => {
); setGlobalCredentials(credentials);
},
}}
text="Add"
/>
</View>
);
} }
export default Login; export default Login;

View File

@ -1,89 +1,97 @@
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();
const scrollArea = new QScrollArea(); const scrollArea = new QScrollArea();
const titleLabel = new QLabel(); const titleLabel = new QLabel();
const lyricLabel = new QLabel(); const lyricLabel = new QLabel();
const [lyricNotFound, setLyricNotFound] = useState<boolean>(false); const [lyricNotFound, setLyricNotFound] = useState<boolean>(false);
const [lyrics, setLyrics] = useState<string>(""); const [lyrics, setLyrics] = useState<string>("");
const artists = track.artists.map((artist) => artist.name).join(", "); const artists = track.artists.map((artist) => artist.name).join(", ");
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) {
showError(error, `[Finding lyrics for ${track.name} failed]: `); showError(error, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)"); setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true); setLyricNotFound(true);
}
} }
}
useEffect(() => { useEffect(() => {
// title label // title label
titleLabel.setText(` titleLabel.setText(`
<center> <center>
<h2>${track.name}</h2> <h2>${track.name}</h2>
<p>- ${artists}</p> <p>- ${artists}</p>
</center> </center>
`); `);
// lyric label // lyric label
lyricLabel.setText(lyrics); lyricLabel.setText(lyrics);
lyricLabel.setTextFormat(TextFormat.PlainText); lyricLabel.setTextFormat(TextFormat.PlainText);
// area container // area container
areaContainer.setLayout(new FlexLayout()); areaContainer.setLayout(new FlexLayout());
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;"); areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
areaContainer.layout?.addWidget(titleLabel); areaContainer.layout?.addWidget(titleLabel);
areaContainer.layout?.addWidget(lyricLabel); areaContainer.layout?.addWidget(lyricLabel);
areaContainer.layout?.addWidget(retryButton); areaContainer.layout?.addWidget(retryButton);
// scroll area // scroll area
scrollArea.setInlineStyle("flex: 1;"); scrollArea.setInlineStyle("flex: 1;");
scrollArea.setWidget(areaContainer); scrollArea.setWidget(areaContainer);
// reload button // reload button
retryButton.setText("Retry"); retryButton.setText("Retry");
retryButton.addEventListener("clicked", handleBtnClick); retryButton.addEventListener("clicked", handleBtnClick);
// dialog // dialog
dialog.setWindowTitle("Lyrics"); dialog.setWindowTitle("Lyrics");
dialog.setLayout(new FlexLayout()); dialog.setLayout(new FlexLayout());
dialog.layout?.addWidget(scrollArea); dialog.layout?.addWidget(scrollArea);
open ? dialog.open() : dialog.close(); open ? dialog.open() : dialog.close();
open && open &&
fetchLyrics(artists, track.name) fetchLyrics(artists, track.name)
.then((lyrics: string) => { .then((lyrics: string) => {
setLyrics(lyrics); setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found"); setLyricNotFound(lyrics === "Not Found");
}) })
.catch((e: Error) => { .catch((e: Error) => {
showError(e, `[Finding lyrics for ${track.name} failed]: `); showError(e, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)"); setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true); setLyricNotFound(true);
}); });
return () => { return () => {
retryButton.removeEventListener("clicked", handleBtnClick); retryButton.removeEventListener("clicked", handleBtnClick);
dialog.hide(); dialog.hide();
}; };
}, [open, track, lyrics]); }, [open, track, lyrics]);
useEffect(() => { useEffect(() => {
retryButton.setEnabled(!lyricNotFound); retryButton.setEnabled(!lyricNotFound);
}, [lyricNotFound]); }, [lyricNotFound]);
return <></>; return <></>;
} }
export default ManualLyricDialog; export default ManualLyricDialog;

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";
@ -15,259 +34,347 @@ import { LocalStorageKeys } from "../conf";
import useDownloadQueue from "../hooks/useDownloadQueue"; import useDownloadQueue from "../hooks/useDownloadQueue";
export const audioPlayer = new NodeMpv( export const audioPlayer = new NodeMpv(
{ {
audio_only: true, audio_only: true,
auto_restart: true, auto_restart: true,
time_update: 1, time_update: 1,
binary: process.env.MPV_EXECUTABLE, binary: process.env.MPV_EXECUTABLE,
// 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 } =
const { reactToTrack, isFavorite } = useTrackReaction(); useContext(playerContext);
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>(() =>
const [totalDuration, setTotalDuration] = useState<number>(0); cachedVolume ? parseFloat(cachedVolume) : 55,
const [shuffle, setShuffle] = useState<boolean>(false); );
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]); const [totalDuration, setTotalDuration] = useState<number>(0);
const [isStopped, setIsStopped] = useState<boolean>(false); const [shuffle, setShuffle] = useState<boolean>(false);
const [openLyrics, setOpenLyrics] = useState<boolean>(false); const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>(); const [isStopped, setIsStopped] = useState<boolean>(false);
const { addToQueue, isActiveDownloading, isFinishedDownloading } = useDownloadQueue(); const [openLyrics, setOpenLyrics] = useState<boolean>(false);
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>();
const { addToQueue, isActiveDownloading, isFinishedDownloading } = useDownloadQueue();
const playlistTracksIds = currentPlaylist?.tracks?.map((t) => t.track.id) ?? []; const playlistTracksIds = currentPlaylist?.tracks?.map((t) => t.track.id) ?? [];
const volumeHandler = useEventHandler<QAbstractSliderSignals>( const volumeHandler = useEventHandler<QAbstractSliderSignals>(
{ {
sliderMoved: (value) => { sliderMoved: (value) => {
setVolume(value); setVolume(value);
}, },
sliderReleased: () => { sliderReleased: () => {
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);
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack); const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
// initial Effect // initial Effect
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
if (!playerRunning) { if (!playerRunning) {
await audioPlayer.start(); await audioPlayer.start();
await audioPlayer.volume(volume); await audioPlayer.volume(volume);
}
} catch (error) {
showError(error, "[Failed starting audio player]: ");
}
})().then(() => {
if (cachedPlaylist && !currentPlaylist) {
setCurrentPlaylist(JSON.parse(cachedPlaylist));
}
if (cachedTrack && !currentTrack) {
setCurrentTrack(JSON.parse(cachedTrack));
}
});
return () => {
if (playerRunning) {
audioPlayer.quit().catch((e: unknown) => console.log(e));
}
};
}, []);
// track change effect
useEffect(() => {
// caching current track
if (!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
else
localStorage.setItem(
LocalStorageKeys.cachedTrack,
JSON.stringify(currentTrack),
);
(async () => {
try {
if (currentTrack && playerRunning) {
const youtubeTrack = await getYoutubeTrack(currentTrack);
setCurrentYtTrack(youtubeTrack);
await audioPlayer.load(youtubeTrack.youtube_uri, "replace");
await audioPlayer.play();
setIsPaused(false);
}
setIsStopped(false);
} catch (error) {
if (error.errcode !== 5) {
setIsStopped(true);
setIsPaused(true);
}
showError(error, "[Failure at track change]: ");
}
})();
}, [currentTrack]);
// changing shuffle to default
useEffect(() => {
setShuffle(false);
// caching playlist
if (!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
else
localStorage.setItem(
LocalStorageKeys.cachedPlaylist,
JSON.stringify(currentPlaylist),
);
}, [currentPlaylist]);
useEffect(() => {
if (playerRunning) {
audioPlayer.volume(volume);
}
}, [volume]);
// for monitoring shuffle playlist
useEffect(() => {
if (currentPlaylist) {
if (shuffle && realPlaylist.length === 0) {
const shuffledTracks = shuffleArray(currentPlaylist.tracks);
setRealPlaylist(currentPlaylist.tracks);
setCurrentPlaylist({ ...currentPlaylist, tracks: shuffledTracks });
} else if (!shuffle && realPlaylist.length > 0) {
setCurrentPlaylist({ ...currentPlaylist, tracks: realPlaylist });
}
}
}, [shuffle]);
// live Effect
useEffect(() => {
if (playerRunning) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const statusListener = (status: { property: string; value: any }) => {
if (status?.property === "duration") {
setTotalDuration(status.value ?? 0);
}
};
const stopListener = () => {
setIsStopped(true);
setIsPaused(true);
// go to next track
if (
currentTrack &&
playlistTracksIds &&
currentPlaylist?.tracks.length !== 0
) {
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
setCurrentTrack(
currentPlaylist?.tracks[
index > playlistTracksIds.length - 1 ? 0 : index
].track,
);
}
};
const pauseListener = () => {
setIsPaused(true);
};
const resumeListener = () => {
setIsPaused(false);
};
audioPlayer.on("status", statusListener);
audioPlayer.on("stopped", stopListener);
audioPlayer.on("paused", pauseListener);
audioPlayer.on("resumed", resumeListener);
return () => {
audioPlayer.off("status", statusListener);
audioPlayer.off("stopped", stopListener);
audioPlayer.off("paused", pauseListener);
audioPlayer.off("resumed", resumeListener);
};
} }
} catch (error) {
showError(error, "[Failed starting audio player]: ");
}
})().then(() => {
if (cachedPlaylist && !currentPlaylist) {
setCurrentPlaylist(JSON.parse(cachedPlaylist));
}
if (cachedTrack && !currentTrack) {
setCurrentTrack(JSON.parse(cachedTrack));
}
}); });
return () => { const handlePlayPause = async () => {
if (playerRunning) { try {
audioPlayer.quit().catch((e: unknown) => console.log(e)); if ((await audioPlayer.isPaused()) && playerRunning) {
} await audioPlayer.play();
setIsStopped(false);
setIsPaused(false);
} else {
await audioPlayer.pause();
setIsStopped(true);
setIsPaused(true);
}
} catch (error) {
showError(error, "[Track control failed]: ");
}
}; };
}, []);
// track change effect const prevOrNext = (constant: number) => {
useEffect(() => { if (currentTrack && playlistTracksIds && currentPlaylist) {
// caching current track const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
if(!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack); setCurrentTrack(
else localStorage.setItem(LocalStorageKeys.cachedTrack, JSON.stringify(currentTrack)); currentPlaylist.tracks[
index > playlistTracksIds?.length - 1
(async () => { ? 0
try { : index < 0
if (currentTrack && playerRunning) { ? playlistTracksIds.length - 1
const youtubeTrack = await getYoutubeTrack(currentTrack); : index
setCurrentYtTrack(youtubeTrack); ].track,
await audioPlayer.load(youtubeTrack.youtube_uri, "replace"); );
await audioPlayer.play();
setIsPaused(false);
} }
setIsStopped(false); };
} catch (error) {
if (error.errcode !== 5) { async function stopPlayback() {
setIsStopped(true); try {
setIsPaused(true); if (playerRunning) {
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
await audioPlayer.stop();
}
} catch (error) {
showError(error, "[Failed at audio-player stop]: ");
} }
showError(error, "[Failure at track change]: ");
}
})();
}, [currentTrack]);
// changing shuffle to default
useEffect(() => {
setShuffle(false);
// caching playlist
if(!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
else localStorage.setItem(LocalStorageKeys.cachedPlaylist, JSON.stringify(currentPlaylist));
}, [currentPlaylist]);
useEffect(() => {
if (playerRunning) {
audioPlayer.volume(volume);
} }
}, [volume]);
// for monitoring shuffle playlist const artistsNames = currentTrack?.artists?.map((x) => x.name);
useEffect(() => { return (
if (currentPlaylist) { <GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
if (shuffle && realPlaylist.length === 0) { <GridRow>
const shuffledTracks = shuffleArray(currentPlaylist.tracks); <GridColumn width={2}>
setRealPlaylist(currentPlaylist.tracks); <Text wordWrap openExternalLinks>
setCurrentPlaylist({ ...currentPlaylist, tracks: shuffledTracks }); {artistsNames && currentTrack
} else if (!shuffle && realPlaylist.length > 0) { ? `
setCurrentPlaylist({ ...currentPlaylist, tracks: realPlaylist }); <p><b><a href="${currentYtTrack?.youtube_uri}"}>${
} currentTrack.name
} }</a></b> - ${artistsNames[0]} ${
}, [shuffle]); artistsNames.length > 1
? "feat. " + artistsNames.slice(1).join(", ")
// live Effect : ""
useEffect(() => { }</p>
if (playerRunning) {
const statusListener = (status: { property: string; value: any }) => {
if (status?.property === "duration") {
setTotalDuration(status.value ?? 0);
}
};
const stopListener = () => {
setIsStopped(true);
setIsPaused(true);
// go to next track
if (currentTrack && playlistTracksIds && currentPlaylist?.tracks.length !== 0) {
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
setCurrentTrack(currentPlaylist?.tracks[index > playlistTracksIds.length - 1 ? 0 : index].track);
}
};
const pauseListener = () => {
setIsPaused(true);
};
const resumeListener = () => {
setIsPaused(false);
};
audioPlayer.on("status", statusListener);
audioPlayer.on("stopped", stopListener);
audioPlayer.on("paused", pauseListener);
audioPlayer.on("resumed", resumeListener);
return () => {
audioPlayer.off("status", statusListener);
audioPlayer.off("stopped", stopListener);
audioPlayer.off("paused", pauseListener);
audioPlayer.off("resumed", resumeListener);
};
}
});
const handlePlayPause = async () => {
try {
if ((await audioPlayer.isPaused()) && playerRunning) {
await audioPlayer.play();
setIsStopped(false);
setIsPaused(false);
} else {
await audioPlayer.pause();
setIsStopped(true);
setIsPaused(true);
}
} catch (error) {
showError(error, "[Track control failed]: ");
}
};
const prevOrNext = (constant: number) => {
if (currentTrack && playlistTracksIds && currentPlaylist) {
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track);
}
};
async function stopPlayback() {
try {
if (playerRunning) {
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
await audioPlayer.stop();
}
} catch (error) {
showError(error, "[Failed at audio-player stop]: ");
}
}
const artistsNames = currentTrack?.artists?.map((x) => x.name);
return (
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
<GridRow>
<GridColumn width={2}>
<Text wordWrap openExternalLinks>
{artistsNames && currentTrack
? `
<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) }}
</BoxView> icon={new QIcon(shuffleIcon)}
</BoxView> />
</GridColumn> <IconButton
<GridColumn width={2}> on={{ clicked: () => prevOrNext(-1) }}
<BoxView> icon={new QIcon(backward)}
<IconButton />
style={isActiveDownloading() && !isFinishedDownloading() ? "background-color: green;" : ""} <IconButton
enabled={!!currentYtTrack} on={{ clicked: handlePlayPause }}
icon={new QIcon(download)} icon={
on={{ new QIcon(
clicked() { isStopped || isPaused || !currentTrack
currentYtTrack && addToQueue(currentYtTrack); ? play
}, : pause,
}} )
/> }
<IconButton />
on={{ <IconButton
clicked() { on={{ clicked: () => prevOrNext(1) }}
if (currentTrack) { icon={new QIcon(forward)}
reactToTrack({ added_at: Date.now().toString(), track: currentTrack }); />
} <IconButton
}, icon={new QIcon(stop)}
}} on={{ clicked: stopPlayback }}
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : heartRegular)} />
/> </BoxView>
<IconButton </BoxView>
style={openLyrics ? "background-color: green;" : ""} </GridColumn>
icon={new QIcon(musicNode)} <GridColumn width={2}>
on={{ clicked: () => currentTrack && setOpenLyrics(!openLyrics) }} <BoxView>
/> <IconButton
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} /> style={
</BoxView> isActiveDownloading() && !isFinishedDownloading()
</GridColumn> ? "background-color: green;"
</GridRow> : ""
</GridView> }
); enabled={!!currentYtTrack}
icon={new QIcon(download)}
on={{
clicked() {
currentYtTrack && addToQueue(currentYtTrack);
},
}}
/>
<IconButton
on={{
clicked() {
if (currentTrack) {
reactToTrack({
added_at: Date.now().toString(),
track: currentTrack,
});
}
},
}}
icon={
new QIcon(
isFavorite(currentTrack?.id ?? "")
? heart
: heartRegular,
)
}
/>
<IconButton
style={openLyrics ? "background-color: green;" : ""}
icon={new QIcon(musicNode)}
on={{
clicked: () => currentTrack && setOpenLyrics(!openLyrics),
}}
/>
<Slider
minSize={{ height: 20, width: 80 }}
maxSize={{ height: 20, width: 100 }}
hasTracking
sliderPosition={volume}
on={volumeHandler}
orientation={Orientation.Horizontal}
/>
</BoxView>
</GridColumn>
</GridRow>
</GridView>
);
} }
export default Player; export default Player;

View File

@ -5,58 +5,70 @@ import React, { useContext, useEffect, useState } from "react";
import playerContext from "../context/playerContext"; import playerContext from "../context/playerContext";
interface PlayerProgressBarProps { interface PlayerProgressBarProps {
audioPlayer: NodeMpv; audioPlayer: NodeMpv;
totalDuration: number; totalDuration: number;
} }
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) { function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
const { currentTrack } = useContext(playerContext); const { currentTrack } = useContext(playerContext);
const [trackTime, setTrackTime] = useState<number>(0); const [trackTime, setTrackTime] = useState<number>(0);
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>( const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
{ {
sliderMoved: (value) => { sliderMoved: (value) => {
if (audioPlayer.isRunning() && currentTrack) { if (audioPlayer.isRunning() && currentTrack) {
const newPosition = (totalDuration * value) / 100; const newPosition = (totalDuration * value) / 100;
setTrackTime(parseInt(newPosition.toString())); setTrackTime(parseInt(newPosition.toString()));
} }
}, },
sliderReleased: () => { sliderReleased: () => {
(async () => { (async () => {
try { try {
await audioPlayer.goToPosition(trackTime); await audioPlayer.goToPosition(trackTime);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
})(); })();
}, },
}, },
[currentTrack, totalDuration, trackTime] [currentTrack, totalDuration, trackTime],
); );
useEffect(() => { useEffect(() => {
const progressListener = (seconds: number) => { const progressListener = (seconds: number) => {
setTrackTime(seconds); setTrackTime(seconds);
}; };
const statusListener = ({ property }: import("node-mpv").StatusObject): void => { const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
if (property === "filename") { if (property === "filename") {
setTrackTime(0); setTrackTime(0);
} }
}; };
audioPlayer.on("status", statusListener); audioPlayer.on("status", statusListener);
audioPlayer.on("timeposition", progressListener); audioPlayer.on("timeposition", progressListener);
return () => { return () => {
audioPlayer.off("status", statusListener); audioPlayer.off("status", statusListener);
audioPlayer.off("timeposition", progressListener); audioPlayer.off("timeposition", progressListener);
}; };
}); });
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 =
return ( new Date(trackTime * 1000).toISOString().substr(14, 5) +
<BoxView direction={Direction.LeftToRight} style={`padding: 20px 0px; flex-direction: row;`}> "/" +
<Slider enabled={!!currentTrack || trackTime > 0} on={trackSliderEvents} sliderPosition={playbackPercentage} hasTracking orientation={Orientation.Horizontal} /> new Date(totalDuration * 1000).toISOString().substr(14, 5);
<Text>{playbackTime}</Text> return (
</BoxView> <BoxView
); 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>
</BoxView>
);
} }
export default PlayerProgressBar; export default PlayerProgressBar;

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";
@ -9,61 +10,79 @@ import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard"; 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 {
[QueryCacheKeys.genrePlaylists, id], data: pagedPlaylists,
async (spotifyApi, { pageParam }) => { isError,
const option = { limit: 20, offset: pageParam }; isLoading,
let res; refetch,
if (id === "featured") { hasNextPage,
res = await spotifyApi.getFeaturedPlaylists(option); isFetchingNextPage,
} else { fetchNextPage,
res = await spotifyApi.getPlaylistsForCategory(id, option); } = useSpotifyInfiniteQuery<
} SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>
return res.body.playlists; >(
}, [QueryCacheKeys.genrePlaylists, id],
{ async (spotifyApi, { pageParam }) => {
getNextPageParam(lastPage) { const option = { limit: 20, offset: pageParam };
if (lastPage.next) { let res;
return lastPage.offset + lastPage.limit; if (id === "featured") {
} res = await spotifyApi.getFeaturedPlaylists(option);
}, } else {
} res = await spotifyApi.getPlaylistsForCategory(id, option);
); }
return res.body.playlists;
},
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
},
);
const playlists = pagedPlaylists?.pages const playlists = pagedPlaylists?.pages
.map((page) => page.items) .map((page) => page.items)
.filter(Boolean) .filter(Boolean)
.flat(1); .flat(1);
return ( return (
<GenreView <GenreView
isError={isError} isError={isError}
isLoading={isLoading || isFetchingNextPage} isLoading={isLoading || isFetchingNextPage}
refetch={refetch} refetch={refetch}
heading={location.state.name} heading={location.state.name}
playlists={playlists ?? []} playlists={playlists ?? []}
loadMore={hasNextPage ? () => fetchNextPage() : undefined} loadMore={hasNextPage ? () => fetchNextPage() : undefined}
isLoadable={!isFetchingNextPage} isLoadable={!isFetchingNextPage}
/> />
); );
} }
export default PlaylistGenreView; export default PlaylistGenreView;
interface GenreViewProps { interface GenreViewProps {
heading: string; heading: string;
playlists: SpotifyApi.PlaylistObjectSimplified[]; playlists: SpotifyApi.PlaylistObjectSimplified[];
loadMore?: QAbstractButtonSignals["clicked"]; loadMore?: QAbstractButtonSignals["clicked"];
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({
const playlistGenreViewStylesheet = ` heading,
playlists,
loadMore,
isLoadable,
isError,
isLoading,
refetch,
}: GenreViewProps) {
const playlistGenreViewStylesheet = `
#genre-container{ #genre-container{
flex-direction: 'column'; flex-direction: 'column';
flex: 1; flex: 1;
@ -83,19 +102,32 @@ export function GenreView({ heading, playlists, loadMore, isLoadable, isError, i
width: 330px; width: 330px;
} }
`; `;
return ( return (
<View id="genre-container" styleSheet={playlistGenreViewStylesheet}> <View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
<BackButton /> <BackButton />
<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
{playlists?.map((playlist, index) => { error={isError}
return <PlaylistCard key={index + playlist.id} playlist={playlist} />; loading={isLoading}
})} reload={refetch}
{loadMore && <Button text="Load more" on={{ clicked: loadMore }} enabled={isLoadable} />} message={`Failed loading ${heading}'s playlists`}
/>
{playlists?.map((playlist, index) => {
return (
<PlaylistCard key={index + playlist.id} playlist={playlist} />
);
})}
{loadMore && (
<Button
text="Load more"
on={{ clicked: loadMore }}
enabled={isLoadable}
/>
)}
</View>
</ScrollArea>
</View> </View>
</ScrollArea> );
</View>
);
} }

View File

@ -14,94 +14,130 @@ import { TrackButton } from "./shared/TrackButton";
import PlaceholderApplet from "./shared/PlaceholderApplet"; import PlaceholderApplet from "./shared/PlaceholderApplet";
export interface PlaylistTrackRes { export interface PlaylistTrackRes {
name: string; name: string;
artists: string; artists: string;
url: string; url: string;
} }
const PlaylistView: FC = () => { const PlaylistView: FC = () => {
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext); const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
const params = useParams<{ id: string }>(); useContext(playerContext);
const location = useLocation<{ name: string; thumbnail: string }>(); const params = useParams<{ id: string }>();
const { isFavorite, reactToPlaylist } = usePlaylistReaction(); const location = useLocation<{ name: string; thumbnail: string }>();
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) => const { isFavorite, reactToPlaylist } = usePlaylistReaction();
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body) const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>(
); [QueryCacheKeys.categoryPlaylists, params.id],
const { data: tracks, isSuccess, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>( (spotifyApi) =>
[QueryCacheKeys.playlistTracks, params.id], spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body),
(spotifyApi) => spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items), );
{ initialData: [] } const {
); data: tracks,
isSuccess,
isError,
isLoading,
refetch,
} = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
[QueryCacheKeys.playlistTracks, params.id],
(spotifyApi) =>
spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
{ initialData: [] },
);
const handlePlaylistPlayPause = () => { const handlePlaylistPlayPause = () => {
if (currentPlaylist?.id !== params.id && isSuccess && tracks) { if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
setCurrentPlaylist({ ...params, ...location.state, tracks }); setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[0].track); setCurrentTrack(tracks[0].track);
} else { } else {
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error)); audioPlayer
setCurrentTrack(undefined); .stop()
setCurrentPlaylist(undefined); .catch((error) => console.error("Failed to stop audio player: ", error));
} setCurrentTrack(undefined);
}; setCurrentPlaylist(undefined);
}
};
return ( return (
<View style={`flex: 1; flex-direction: 'column';`}> <View style={`flex: 1; flex-direction: 'column';`}>
<PlaylistSimpleControls <PlaylistSimpleControls
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)} handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
handlePlaylistPlayPause={handlePlaylistPlayPause} handlePlaylistPlayPause={handlePlaylistPlayPause}
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(
{<TrackTableIndex />} 1,
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}> )}</h2></center>`}</Text>
<View style={`flex-direction:column; flex: 1;`}> {<TrackTableIndex />}
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} /> <ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
{tracks?.map(({ track }, index) => { <View style={`flex-direction:column; flex: 1;`}>
if (track) { <PlaceholderApplet
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />; error={isError}
} loading={isLoading}
})} reload={refetch}
message={`Failed retrieving ${location.state.name} tracks`}
/>
{tracks?.map(({ track }, index) => {
if (track) {
return (
<TrackButton
key={index + track.id}
track={track}
index={index}
playlist={playlist}
/>
);
}
})}
</View>
</ScrollArea>
</View> </View>
</ScrollArea> );
</View>
);
}; };
export default PlaylistView; export default PlaylistView;
export function TrackTableIndex() { export function TrackTableIndex() {
return ( return (
<View> <View>
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text> <Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text> <Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text> <Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text> <Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text> <Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
</View> </View>
); );
} }
interface PlaylistSimpleControlsProps { interface PlaylistSimpleControlsProps {
handlePlaylistPlayPause: (index?: number) => void; handlePlaylistPlayPause: (index?: number) => void;
handlePlaylistReact?: () => void; handlePlaylistReact?: () => void;
isActive: boolean; isActive: boolean;
isFavorite?: boolean; isFavorite?: boolean;
} }
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) { export function PlaylistSimpleControls({
return ( handlePlaylistPlayPause,
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}> isActive,
<BackButton /> handlePlaylistReact,
{isFavorite !== undefined && <IconButton icon={new QIcon(isFavorite ? heart : heartRegular)} on={{ clicked: handlePlaylistReact }} />} isFavorite,
<IconButton }: PlaylistSimpleControlsProps) {
style={`background-color: #00be5f; color: white;`} return (
on={{ <View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
clicked() { <BackButton />
handlePlaylistPlayPause(); {isFavorite !== undefined && (
}, <IconButton
}} icon={new QIcon(isFavorite ? heart : heartRegular)}
icon={new QIcon(isActive ? stop : play)} on={{ clicked: handlePlaylistReact }}
/> />
</View> )}
); <IconButton
style={`background-color: #00be5f; color: white;`}
on={{
clicked() {
handlePlaylistPlayPause();
},
}}
icon={new QIcon(isActive ? stop : play)}
/>
</View>
);
} }

View File

@ -13,83 +13,122 @@ import PlaylistCard from "./shared/PlaylistCard";
import { TrackButton } from "./shared/TrackButton"; 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 {
QueryCacheKeys.search, data: searchResults,
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body), refetch,
{ enabled: false } isError,
); isLoading,
} = useSpotifyQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.search,
(spotifyApi) =>
spotifyApi
.search(searchQuery, ["playlist", "track"], { limit: 4 })
.then((res) => res.body),
{ enabled: false },
);
async function handleSearch() { async function handleSearch() {
try { try {
await refetch(); await refetch();
} catch (error) { } catch (error) {
showError(error, "[Failed to search through spotify]: "); showError(error, "[Failed to search through spotify]: ");
}
} }
}
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />; const placeholder = (
return ( <PlaceholderApplet
<View style="flex: 1; flex-direction: 'column'; padding: 5px;"> error={isError}
<View> loading={isLoading}
<LineEdit message="Failed querying spotify"
style="width: '65%'; margin: 5px;" reload={refetch}
placeholderText="Search spotify"
on={{
textChanged(t) {
setSearchQuery(t);
},
KeyRelease(native: any) {
const key = new QKeyEvent(native);
const isEnter = key.key() === 16777220;
if (isEnter) {
handleSearch();
}
}
}}
/> />
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} /> );
</View> return (
<ScrollArea style="flex: 1;"> <View style="flex: 1; flex-direction: 'column'; padding: 5px;">
<View style="flex-direction: 'column'; flex: 1;"> <View>
<View style="flex: 1; flex-direction: 'column';"> <LineEdit
<Text style="width: '65%'; margin: 5px;"
cursor={CursorShape.PointingHandCursor} placeholderText="Search spotify"
on={{ on={{
MouseButtonRelease(native: any) { textChanged(t) {
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) { setSearchQuery(t);
history.push("/search/songs", { searchQuery }); },
} // eslint-disable-next-line @typescript-eslint/no-explicit-any
}, KeyRelease(native: any) {
}}>{`<h2>Songs</h2>`}</Text> const key = new QKeyEvent(native);
<TrackTableIndex /> const isEnter = key.key() === 16777220;
{placeholder} if (isEnter) {
{searchResults?.tracks?.items.map((track, index) => ( handleSearch();
<TrackButton key={index + track.id} index={index} track={track} /> }
))} },
</View> }}
<View style="flex: 1; flex-direction: 'column';"> />
<Text <IconButton
cursor={CursorShape.PointingHandCursor} enabled={searchQuery.length > 0}
on={{ icon={new QIcon(search)}
MouseButtonRelease(native: any) { on={{ clicked: handleSearch }}
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) { />
history.push("/search/playlists", { searchQuery });
}
},
}}>{`<h2>Playlists</h2>`}</Text>
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
{placeholder}
{searchResults?.playlists?.items.map((playlist, index) => (
<PlaylistCard key={index + playlist.id} playlist={playlist} />
))}
</View> </View>
</View> <ScrollArea style="flex: 1;">
<View style="flex-direction: 'column'; flex: 1;">
<View style="flex: 1; flex-direction: 'column';">
<Text
cursor={CursorShape.PointingHandCursor}
on={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) {
if (
new QMouseEvent(native).button() === 1 &&
searchResults?.tracks
) {
history.push("/search/songs", { searchQuery });
}
},
}}
>{`<h2>Songs</h2>`}</Text>
<TrackTableIndex />
{placeholder}
{searchResults?.tracks?.items.map((track, index) => (
<TrackButton
key={index + track.id}
index={index}
track={track}
/>
))}
</View>
<View style="flex: 1; flex-direction: 'column';">
<Text
cursor={CursorShape.PointingHandCursor}
on={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) {
if (
new QMouseEvent(native).button() === 1 &&
searchResults?.playlists
) {
history.push("/search/playlists", {
searchQuery,
});
}
},
}}
>{`<h2>Playlists</h2>`}</Text>
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
{placeholder}
{searchResults?.playlists?.items.map((playlist, index) => (
<PlaylistCard
key={index + playlist.id}
playlist={playlist}
/>
))}
</View>
</View>
</View>
</ScrollArea>
</View> </View>
</ScrollArea> );
</View>
);
} }
export default Search; export default Search;

View File

@ -5,34 +5,51 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import { GenreView } from "./PlaylistGenreView"; 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 {
QueryCacheKeys.searchPlaylist, data: searchResults,
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body), fetchNextPage,
{ hasNextPage,
getNextPageParam: (lastPage) => { isFetchingNextPage,
if (lastPage.playlists?.next) { isError,
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0); isLoading,
} refetch,
}, } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
} QueryCacheKeys.searchPlaylist,
); (spotifyApi, { pageParam }) =>
return ( spotifyApi
<GenreView .searchPlaylists(location.state.searchQuery, {
isError={isError} limit: 20,
isLoading={isLoading || isFetchingNextPage} offset: pageParam,
refetch={refetch} })
heading={"Search: " + location.state.searchQuery} .then((res) => res.body),
playlists={ {
(searchResults?.pages getNextPageParam: (lastPage) => {
?.map((page) => page.playlists?.items) if (lastPage.playlists?.next) {
.filter(Boolean) return (
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? [] (lastPage.playlists?.offset ?? 0) +
} (lastPage.playlists?.limit ?? 0)
loadMore={hasNextPage ? () => fetchNextPage() : undefined} );
isLoadable={!isFetchingNextPage} }
/> },
); },
);
return (
<GenreView
isError={isError}
isLoading={isLoading || isFetchingNextPage}
refetch={refetch}
heading={"Search: " + location.state.searchQuery}
playlists={
(searchResults?.pages
?.map((page) => page.playlists?.items)
.filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
}
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
isLoadable={!isFetchingNextPage}
/>
);
} }
export default SearchResultPlaylistCollection; export default SearchResultPlaylistCollection;

View File

@ -8,51 +8,79 @@ import PlaceholderApplet from "./shared/PlaceholderApplet";
import { TrackButton } from "./shared/TrackButton"; 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 {
QueryCacheKeys.searchSongs, data: searchResults,
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body), fetchNextPage,
{ hasNextPage,
getNextPageParam: (lastPage) => { isFetchingNextPage,
if (lastPage.tracks?.next) { isError,
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0); isLoading,
} refetch,
}, } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
} QueryCacheKeys.searchSongs,
); (spotifyApi, { pageParam }) =>
return ( spotifyApi
<View style="flex: 1; flex-direction: 'column';"> .searchTracks(location.state.searchQuery, {
<Text>{` limit: 20,
offset: pageParam,
})
.then((res) => res.body),
{
getNextPageParam: (lastPage) => {
if (lastPage.tracks?.next) {
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
}
},
},
);
return (
<View style="flex: 1; flex-direction: 'column';">
<Text>{`
<center> <center>
<h2>Search: ${location.state.searchQuery}</h2> <h2>Search: ${location.state.searchQuery}</h2>
</center> </center>
`}</Text> `}</Text>
<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
{searchResults?.pages error={isError}
.map((searchResult) => searchResult.tracks?.items) loading={isLoading || isFetchingNextPage}
.filter(Boolean) message="Failed querying spotify"
.flat(1) reload={refetch}
.map((track, index) => track && <TrackButton key={index + track.id} index={index} track={track} />)} />
{searchResults?.pages
.map((searchResult) => searchResult.tracks?.items)
.filter(Boolean)
.flat(1)
.map(
(track, index) =>
track && (
<TrackButton
key={index + track.id}
index={index}
track={track}
/>
),
)}
{hasNextPage && ( {hasNextPage && (
<Button <Button
style="flex-grow: 0; align-self: 'center';" style="flex-grow: 0; align-self: 'center';"
text="Load more" text="Load more"
on={{ on={{
clicked() { clicked() {
fetchNextPage(); fetchNextPage();
}, },
}} }}
enabled={!isFetchingNextPage} enabled={!isFetchingNextPage}
/> />
)} )}
</View>
</ScrollArea>
</View> </View>
</ScrollArea> );
</View>
);
} }
export default SearchResultSongsCollection; export default SearchResultSongsCollection;

View File

@ -4,41 +4,48 @@ 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>
<View style="width: '100%'; flex-direction: 'column'; justify-content: 'flex-start';"> <View style="width: '100%'; flex-direction: 'column'; justify-content: 'flex-start';">
<SettingsCheckTile <SettingsCheckTile
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>
);
} }
export default Settings; export default Settings;
interface SettingsCheckTileProps { interface SettingsCheckTileProps {
title: string; title: string;
subtitle?: string; subtitle?: string;
checked: boolean; checked: boolean;
onChange?: SwitchProps["onChange"]; onChange?: SwitchProps["onChange"];
} }
export function SettingsCheckTile({ title, subtitle = "", onChange, checked }: SettingsCheckTileProps) { export function SettingsCheckTile({
return ( title,
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';"> subtitle = "",
<Text> onChange,
{` checked,
}: SettingsCheckTileProps) {
return (
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';">
<Text>
{`
<b>${title}</b> <b>${title}</b>
<p>${subtitle}</p> <p>${subtitle}</p>
`} `}
</Text> </Text>
<Switch checked={checked} onChange={onChange} /> <Switch checked={checked} onChange={onChange} />
</View> </View>
); );
} }

View File

@ -6,27 +6,27 @@ import IconButton from "./shared/IconButton";
import { QIcon } from "@nodegui/nodegui"; import { QIcon } from "@nodegui/nodegui";
function TabMenu() { function TabMenu() {
const history = useHistory(); const history = useHistory();
return ( return (
<View id="tabmenu" styleSheet={tabBarStylesheet}> <View id="tabmenu" styleSheet={tabBarStylesheet}>
<View> <View>
<Text>{`<h1>Spotube</h1>`}</Text> <Text>{`<h1>Spotube</h1>`}</Text>
</View> </View>
<TabMenuItem url="/home" title="Browse" /> <TabMenuItem url="/home" title="Browse" />
<TabMenuItem url="/library" title="Library" /> <TabMenuItem url="/library" title="Library" />
<TabMenuItem url="/currently" title="Currently Playing" /> <TabMenuItem url="/currently" title="Currently Playing" />
<TabMenuItem url="/search" title="Search" /> <TabMenuItem url="/search" title="Search" />
<IconButton <IconButton
icon={new QIcon(settingsCog)} icon={new QIcon(settingsCog)}
on={{ on={{
clicked() { clicked() {
history.push("/settings"); history.push("/settings");
}, },
}} }}
/> />
</View> </View>
); );
} }
export const tabBarStylesheet = ` export const tabBarStylesheet = `
@ -47,21 +47,31 @@ export const tabBarStylesheet = `
export default TabMenu; export default TabMenu;
export interface TabMenuItemProps { export interface TabMenuItemProps {
title: string; title: string;
url: string; url: string;
/** /**
* path to the icon in string * path to the icon in string
*/ */
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();
function clicked() { function clicked() {
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

@ -6,48 +6,55 @@ import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
import showError from "../../helpers/showError"; import showError from "../../helpers/showError";
interface CachedImageProps extends Omit<ImageProps, "buffer"> { interface CachedImageProps extends Omit<ImageProps, "buffer"> {
src: string; src: string;
alt?: string; alt?: string;
} }
function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) { function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
const labelRef = useRef<QLabel>(); const labelRef = useRef<QLabel>();
const [imageBuffer, setImageBuffer] = useState<Buffer>(); const [imageBuffer, setImageBuffer] = useState<Buffer>();
const [imageProcessError, setImageProcessError] = useState<boolean>(false); const [imageProcessError, setImageProcessError] = useState<boolean>(false);
const pixmap = new QPixmap(); const pixmap = new QPixmap();
useEffect(() => { useEffect(() => {
if (imageBuffer === undefined) { if (imageBuffer === undefined) {
getCachedImageBuffer(src, maxSize ?? size) getCachedImageBuffer(src, maxSize ?? size)
.then((buffer) => setImageBuffer(buffer)) .then((buffer) => setImageBuffer(buffer))
.catch((error) => { .catch((error) => {
setImageProcessError(false); setImageProcessError(false);
showError(error, "[Cached Image Error]: "); showError(error, "[Cached Image Error]: ");
}); });
} }
return () => { return () => {
labelRef.current?.close(); labelRef.current?.close();
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
if (imageBuffer) { if (imageBuffer) {
pixmap.loadFromData(imageBuffer); pixmap.loadFromData(imageBuffer);
pixmap.scaled((size ?? maxSize)?.height ?? 100, (size ?? maxSize)?.width ?? 100); pixmap.scaled(
labelRef.current?.setPixmap(pixmap); (size ?? maxSize)?.height ?? 100,
} (size ?? maxSize)?.width ?? 100,
}, [imageBuffer]); );
labelRef.current?.setPixmap(pixmap);
}
}, [imageBuffer]);
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
<Text>{alt}</Text> style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${
</View> ((maxSize ?? size)?.width || 10) / 2.5
) : ( }px;`}
<></> >
); <Text>{alt}</Text>
</View>
) : (
<></>
);
} }
export default CachedImage; export default CachedImage;

View File

@ -5,10 +5,10 @@ import { useHistory } from "react-router";
import PlaylistCard from "./PlaylistCard"; import PlaylistCard from "./PlaylistCard";
interface CategoryCardProps { interface CategoryCardProps {
url: string; url: string;
name: string; name: string;
isError: boolean; isError: boolean;
playlists: SpotifyApi.PlaylistObjectSimplified[]; playlists: SpotifyApi.PlaylistObjectSimplified[];
} }
const categoryStylesheet = ` const categoryStylesheet = `
@ -36,26 +36,32 @@ const categoryStylesheet = `
} }
`; `;
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => { const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
const history = useHistory(); const history = useHistory();
function goToGenre(native: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mouse = new QMouseEvent(native); function goToGenre(native: any) {
if (mouse.button() === 1) { const mouse = new QMouseEvent(native);
history.push(url, { name }); if (mouse.button() === 1) {
history.push(url, { name });
}
} }
} if (isError) {
if (isError) { return <></>;
return <></>; }
} return (
return ( <View id="container" styleSheet={categoryStylesheet}>
<View id="container" styleSheet={categoryStylesheet}> <Button
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} /> id="anchor-heading"
<View id="child-view"> cursor={CursorShape.PointingHandCursor}
{playlists.map((playlist, index) => { on={{ MouseButtonRelease: goToGenre }}
return <PlaylistCard key={index + playlist.id} playlist={playlist} />; text={name}
})} />
</View> <View id="child-view">
</View> {playlists.map((playlist, index) => {
); return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
})}
</View>
</View>
);
}; };
export default CategoryCard; export default CategoryCard;

View File

@ -1,20 +1,20 @@
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);
border-width: 1px; border-width: 1px;
@ -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,56 +1,56 @@
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";
interface ErrorAppletProps { interface ErrorAppletProps {
error: boolean; error: boolean;
loading: boolean; loading: boolean;
message?: string; message?: string;
reload: Function; reload: () => void;
helps?: boolean; helps?: boolean;
} }
function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) { function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) {
const textRef = useRef<QLabel>(); const textRef = useRef<QLabel>();
const movie = new QMovie(); const movie = new QMovie();
useEffect(() => { useEffect(() => {
movie.setFileName(loadingSpinner); movie.setFileName(loadingSpinner);
textRef.current?.setMovie(movie); textRef.current?.setMovie(movie);
textRef.current?.show(); textRef.current?.show();
movie.start(); movie.start();
}, []); }, []);
if (loading) { if (loading) {
return ( return (
<View style="flex: 1; justify-content: 'center'; align-items: 'center';"> <View style="flex: 1; justify-content: 'center'; align-items: 'center';">
<Text ref={textRef} /> <Text ref={textRef} />
</View> </View>
); );
} else if (error) { } else if (error) {
return ( return (
<View style="flex-direction: 'column'; align-items: 'center';"> <View style="flex-direction: 'column'; align-items: 'center';">
<Text openExternalLinks>{` <Text openExternalLinks>{`
<h3>${message ? message : "An error occured"}</h3> <h3>${message ? message : "An error occured"}</h3>
${ ${
helps helps
? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a> ? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
</p>` </p>`
: `` : ``
} }
`}</Text> `}</Text>
<Button <Button
on={{ on={{
clicked() { clicked() {
reload(); reload();
}, },
}} }}
text="Reload" text="Reload"
/> />
</View> </View>
); );
} }
return <></>; return <></>;
} }
export default PlaceholderApplet; export default PlaceholderApplet;

View File

@ -15,49 +15,56 @@ import CachedImage from "./CachedImage";
import IconButton from "./IconButton"; import IconButton from "./IconButton";
interface PlaylistCardProps { interface PlaylistCardProps {
playlist: SpotifyApi.PlaylistObjectSimplified; playlist: SpotifyApi.PlaylistObjectSimplified;
} }
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);
initialData: [], const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
enabled: false, [QueryCacheKeys.playlistTracks, id],
}); (spotifyApi) =>
const { reactToPlaylist, isFavorite } = usePlaylistReaction(); spotifyApi.getPlaylistTracks(id).then((track) => track.body.items),
{
initialData: [],
enabled: false,
},
);
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
const handlePlaylistPlayPause = async () => { const handlePlaylistPlayPause = async () => {
try { try {
const { data: tracks, isSuccess } = await refetch(); const { data: tracks, isSuccess } = await refetch();
if (currentPlaylist?.id !== id && isSuccess && tracks) { if (currentPlaylist?.id !== id && isSuccess && tracks) {
setCurrentPlaylist({ tracks, id, name, thumbnail }); setCurrentPlaylist({ tracks, id, name, thumbnail });
setCurrentTrack(tracks[0].track); setCurrentTrack(tracks[0].track);
} else { } else {
await audioPlayer.stop(); await audioPlayer.stop();
setCurrentTrack(undefined); setCurrentTrack(undefined);
setCurrentPlaylist(undefined); setCurrentPlaylist(undefined);
} }
} catch (error) { } catch (error) {
showError(error, "[Failed adding playlist to queue]: "); showError(error, "[Failed adding playlist to queue]: ");
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function gotoPlaylist(native?: any) {
const key = new QMouseEvent(native);
if (key.button() === 1) {
history.push(`/playlist/${id}`, { name, thumbnail });
}
} }
};
function gotoPlaylist(native?: any) { const bgColor1 = useMemo(() => generateRandomColor(), []);
const key = new QMouseEvent(native); const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
if (key.button() === 1) {
history.push(`/playlist/${id}`, { name, thumbnail });
}
}
const bgColor1 = useMemo(() => generateRandomColor(), []); const playlistStyleSheet = `
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
const playlistStyleSheet = `
#playlist-container, #img-container{ #playlist-container, #img-container{
width: 150px; width: 150px;
padding: 10px; padding: 10px;
@ -76,70 +83,85 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
border: 5px solid green; border: 5px solid green;
} }
`; `;
const playlistAction = ` const playlistAction = `
position: absolute; position: absolute;
bottom: 30px; bottom: 30px;
background-color: ${color}; background-color: ${color};
`; `;
const playlistActions = ( const playlistActions = (
<> <>
<IconButton <IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"} style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"}
icon={new QIcon(isFavorite(id) ? heart : heartRegular)} icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
on={{ on={{
clicked() { clicked() {
reactToPlaylist(playlist); reactToPlaylist(playlist);
}, },
}} }}
/> />
<IconButton <IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"} style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"}
icon={new QIcon(currentPlaylist?.id === id ? pause : play)} icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
on={{ on={{
clicked() { clicked() {
handlePlaylistPlayPause(); handlePlaylistPlayPause();
}, },
}} }}
/> />
</> </>
); );
const hovers = { const hovers = {
HoverEnter() { HoverEnter() {
setHovered(true); setHovered(true);
}, },
HoverLeave() { HoverLeave() {
setHovered(false); setHovered(false);
}, },
}; };
return ( return (
<View <View
id={preferences.playlistImages ? "img-container" : "playlist-container"} id={preferences.playlistImages ? "img-container" : "playlist-container"}
cursor={CursorShape.PointingHandCursor} cursor={CursorShape.PointingHandCursor}
styleSheet={playlistStyleSheet} styleSheet={playlistStyleSheet}
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>
<p>${description}</p> <p>${description}</p>
</center> </center>
`} `}
</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>
);
}; };
export default PlaylistCard; export default PlaylistCard;

View File

@ -4,41 +4,42 @@ import { CheckBoxProps } from "@nodegui/react-nodegui/dist/components/CheckBox/R
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> { export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> {
onChange?(checked: boolean): void; onChange?(checked: boolean): void;
} }
function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) { function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
const [checked, setChecked] = useState<boolean>(false); const [checked, setChecked] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (derivedChecked) { if (derivedChecked) {
setChecked(derivedChecked); setChecked(derivedChecked);
} }
}, []); }, []);
return ( return (
<Slider <Slider
value={checked ? 1 : 0} value={checked ? 1 : 0}
hasTracking hasTracking
mouseTracking mouseTracking
orientation={Orientation.Horizontal} orientation={Orientation.Horizontal}
maximum={1} maximum={1}
minimum={0} minimum={0}
maxSize={{ width: 30, height: 20 }} maxSize={{ width: 30, height: 20 }}
on={{ on={{
valueChanged(value) { valueChanged(value) {
onChange && onChange(value===1); onChange && onChange(value === 1);
}, },
MouseButtonRelease(native: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mouse = new QMouseEvent(native); MouseButtonRelease(native: any) {
if (mouse.button() === 1) { const mouse = new QMouseEvent(native);
setChecked(!checked); if (mouse.button() === 1) {
} setChecked(!checked);
}, }
}} },
{...props} }}
/> {...props}
); />
);
} }
export default Switch; export default Switch;

View File

@ -8,71 +8,83 @@ import { heart, heartRegular, pause, play } from "../../icons";
import IconButton from "./IconButton"; 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 {
track: SpotifyApi.TrackObjectFull; track: SpotifyApi.TrackObjectFull;
playlist?: TrackButtonPlaylistObject; playlist?: TrackButtonPlaylistObject;
index: number; index: number;
} }
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 } =
const handlePlaylistPlayPause = (index: number) => { useContext(playerContext);
if (playlist && currentPlaylist?.id !== playlist.id) { const handlePlaylistPlayPause = (index: number) => {
const globalPlaylistObj = { id: playlist.id, name: playlist.name, thumbnail: playlist.images[0].url, tracks: playlist.tracks.items }; if (playlist && currentPlaylist?.id !== playlist.id) {
setCurrentPlaylist(globalPlaylistObj); const globalPlaylistObj = {
setCurrentTrack(playlist.tracks.items[index].track); id: playlist.id,
} name: playlist.name,
}; thumbnail: playlist.images[0].url,
tracks: playlist.tracks.items,
};
setCurrentPlaylist(globalPlaylistObj);
setCurrentTrack(playlist.tracks.items[index].track);
}
};
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => { const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
setCurrentTrack(track); setCurrentTrack(track);
}; };
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 =
return ( (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) ||
<View currentTrack?.id === track.id;
id={active ? "active" : "track-button"} return (
styleSheet={trackButtonStyle} <View
on={{ id={active ? "active" : "track-button"}
MouseButtonRelease(native: any) { styleSheet={trackButtonStyle}
if (new QMouseEvent(native).button() === 1 && playlist) { on={{
handlePlaylistPlayPause(index); // eslint-disable-next-line @typescript-eslint/no-explicit-any
} MouseButtonRelease(native: any) {
}, if (new QMouseEvent(native).button() === 1 && playlist) {
}}> handlePlaylistPlayPause(index);
<Text style="padding: 5px;">{index + 1}</Text> }
<View style="flex-direction: 'column'; width: '35%';"> },
<Text>{`<h3>${track.name}</h3>`}</Text> }}
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text> >
</View> <Text style="padding: 5px;">{index + 1}</Text>
<Text style="width: '25%';">{track.album.name}</Text> <View style="flex-direction: 'column'; width: '35%';">
<Text style="width: '15%';">{duration}</Text> <Text>{`<h3>${track.name}</h3>`}</Text>
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';"> <Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
<IconButton </View>
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)} <Text style="width: '25%';">{track.album.name}</Text>
on={{ <Text style="width: '15%';">{duration}</Text>
clicked() { <View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
reactToTrack({ track, added_at: "" }); <IconButton
}, icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
}} on={{
/> clicked() {
<IconButton reactToTrack({ track, added_at: "" });
icon={new QIcon(active ? pause : play)} },
on={{ }}
clicked() { />
trackClickHandler(track); <IconButton
}, icon={new QIcon(active ? pause : play)}
}} on={{
/> clicked() {
</View> trackClickHandler(track);
</View> },
); }}
/>
</View>
</View>
);
}; };
const trackButtonStyle = ` const trackButtonStyle = `

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";
@ -10,23 +10,24 @@ export const confDir = join(homedir(), ".config", "spotube");
export const cacheDir = join(homedir(), ".cache", "spotube"); export const cacheDir = join(homedir(), ".cache", "spotube");
export enum QueryCacheKeys { export enum QueryCacheKeys {
categories = "categories", categories = "categories",
categoryPlaylists = "categoryPlaylists", categoryPlaylists = "categoryPlaylists",
featuredPlaylists = "featuredPlaylists", featuredPlaylists = "featuredPlaylists",
genrePlaylists = "genrePlaylists", genrePlaylists = "genrePlaylists",
playlistTracks = "playlistTracks", playlistTracks = "playlistTracks",
userPlaylists = "user-palylists", userPlaylists = "user-palylists",
userSavedTracks = "user-saved-tracks", userSavedTracks = "user-saved-tracks",
search = "search", search = "search",
searchPlaylist = "searchPlaylist", searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs", searchSongs = "searchSongs",
followedArtists = "followed-artists",
} }
export enum LocalStorageKeys { export enum LocalStorageKeys {
credentials = "credentials", credentials = "credentials",
refresh_token = "refresh_token", refresh_token = "refresh_token",
preferences = "user-preferences", preferences = "user-preferences",
volume = "volume", volume = "volume",
cachedPlaylist = "cached-playlist", cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track" cachedTrack = "cached-track",
} }

View File

@ -2,23 +2,29 @@ import React, { Dispatch, SetStateAction } from "react";
import { Credentials } from "../app"; import { Credentials } from "../app";
export interface AuthContext { export interface AuthContext {
isLoggedIn: boolean; isLoggedIn: boolean;
setIsLoggedIn: Dispatch<SetStateAction<boolean>>; setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
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() {
access_token: "", return;
clientId: "", },
clientSecret: "", access_token: "",
setCredentials(){}, clientId: "",
setAccess_token() {}, clientSecret: "",
setCredentials() {
return;
},
setAccess_token() {
return;
},
}); });
export default authContext; export default authContext;

View File

@ -2,15 +2,27 @@ 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;
currentTrack?: CurrentTrack; currentTrack?: CurrentTrack;
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>; setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
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

@ -1,15 +1,17 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
export interface PreferencesContextProperties { export interface PreferencesContextProperties {
playlistImages: boolean; playlistImages: boolean;
} }
export interface PreferencesContext extends PreferencesContextProperties { export interface PreferencesContext extends PreferencesContextProperties {
setPreferences: Dispatch<SetStateAction<PreferencesContextProperties>>; setPreferences: Dispatch<SetStateAction<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,49 +1,80 @@
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 {
console.log("[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(delim2);
} catch (err) {
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]:",
[, lyrics] = lyrics.split(delim1); `${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
[lyrics] = lyrics.split(delim2); );
} catch (err_1) { lyrics = (
showError(err_1, "[Lyric Query Error]: "); await axios.get<string>(
try { `${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song`); { responseType: "text" },
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song`)).data; )
).data;
[, lyrics] = lyrics.split(delim1); [, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2); [lyrics] = lyrics.split(delim2);
} catch (err_2) { } catch (err) {
showError(err_2, "[Lyric Query Error]: "); showError(err, "[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]:",
[, lyrics] = lyrics.split(delim1); `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
[lyrics] = lyrics.split(delim2); );
} catch (err_3) { lyrics = (
showError(err_3, "[Lyric Query Error]: "); await axios.get<string>(
lyrics = "Not Found"; `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
)
).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (err_1) {
showError(err_1, "[Lyric Query Error]: ");
try {
console.log(
"[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(delim2);
} catch (err_2) {
showError(err_2, "[Lyric Query Error]: ");
try {
console.log(
"[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}`,
)
).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (err_3) {
showError(err_3, "[Lyric Query Error]: ");
lyrics = "Not Found";
}
}
} }
}
} }
} const rets = lyrics.split("\n");
const rets = lyrics.split("\n"); let final = "";
let final = ""; for (const ret of rets) {
for (const ret of rets) { final = `${final}${htmlToText.htmlToText(ret)}\n`;
final = `${final}${htmlToText.htmlToText(ret)}\n`; }
} return final.trim();
return final.trim();
} }

View File

@ -9,59 +9,83 @@ import du from "du";
import { cacheDir } from "../conf"; import { cacheDir } from "../conf";
interface ImageDimensions { interface ImageDimensions {
height: number; height: number;
width: number; width: number;
} }
const fsm = fs.promises; const fsm = fs.promises;
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> { export async function getCachedImageBuffer(
try { name: string,
const MB_5 = 5000000; //5 Megabytes dims?: ImageDimensions,
const cacheImgFolder = path.join(cacheDir, "images"); ): Promise<Buffer> {
// for clearing up the cache if it reaches out of the size try {
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`; const MB_5 = 5000000; //5 Megabytes
const cacheImgPath = path.join(cacheImgFolder, cacheName); const cacheImgFolder = path.join(cacheDir, "images");
// checking if the cached image already exists or not // for clearing up the cache if it reaches out of the size
if (fs.existsSync(cacheImgPath)) { const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
// automatically removing cache after a certain 50 MB oversize const cacheImgPath = path.join(cacheImgFolder, cacheName);
if ((await du(cacheImgFolder)) > MB_5) { // checking if the cached image already exists or not
fs.rmdirSync(cacheImgFolder, { recursive: true }); if (fs.existsSync(cacheImgPath)) {
} // automatically removing cache after a certain 50 MB oversize
const cachedImg = await fsm.readFile(cacheImgPath); if ((await du(cacheImgFolder)) > MB_5) {
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap; fs.rmdirSync(cacheImgFolder, { recursive: true });
}
const cachedImg = await fsm.readFile(cacheImgPath);
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
// 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 (
fs.unlinkSync(cacheImgPath); dims &&
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims }); (cachedImgMeta.height !== dims.height ||
} cachedImgMeta.width !== dims?.width)
return cachedImg; ) {
} else { fs.unlinkSync(cacheImgPath);
// finding no cache image fetching it through axios return await imageResizeAndWrite(cachedImg, {
const { data: imgData } = await axios.get<Stream>(name, { responseType: "stream" }); cacheFolder: cacheImgFolder,
// converting axios stream to buffer cacheName,
const resImgBuf = await streamToBuffer(imgData); dims,
// creating cache_dir });
await fsm.mkdir(cacheImgFolder, { recursive: true }); }
if (dims) { return cachedImg;
return await imageResizeAndWrite(resImgBuf, { cacheFolder: cacheImgFolder, cacheName, dims }); } else {
} // finding no cache image fetching it through axios
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf); const { data: imgData } = await axios.get<Stream>(name, {
return resImgBuf; responseType: "stream",
});
// converting axios stream to buffer
const resImgBuf = await streamToBuffer(imgData);
// creating cache_dir
await fsm.mkdir(cacheImgFolder, { recursive: true });
if (dims) {
return await imageResizeAndWrite(resImgBuf, {
cacheFolder: cacheImgFolder,
cacheName,
dims,
});
}
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
return resImgBuf;
}
} catch (error) {
console.error("[Error in Image Cache]: ", error);
throw error;
} }
} catch (error) {
console.error("[Error in Image Cache]: ", error);
throw error;
}
} }
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> { async function imageResizeAndWrite(
// caching the images by resizing if the max/fixed (Width/Height) img: Buffer,
// is available in the args {
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height); cacheFolder,
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime); cacheName,
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer); dims,
return resizedImgBuffer; }: { dims: ImageDimensions; cacheFolder: string; cacheName: string },
): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height)
// is available in the args
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
return resizedImgBuffer;
} }

View File

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

View File

@ -1,4 +1,4 @@
export function shuffleArray<T>(array:T[]):T[] { export function shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) { for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
const temp = array[i]; const temp = array[i];
@ -6,4 +6,4 @@ export function shuffleArray<T>(array:T[]):T[] {
array[j] = temp; array[j] = temp;
} }
return array; return array;
} }

View File

@ -1,16 +1,16 @@
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);
});
stream.on("end", async () => {
resolve(Buffer.concat(buffArr));
});
stream.on("error", (error) => {
reject(error);
});
}); });
stream.on("end", async () => {
resolve(Buffer.concat(buffArr));
});
stream.on("error", (error) => {
reject(error);
});
});
} }

View File

@ -8,52 +8,52 @@ import playerContext from "../context/playerContext";
import showError from "../helpers/showError"; import showError from "../helpers/showError";
function useDownloadQueue() { function useDownloadQueue() {
const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]); const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]);
const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]); const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]);
const { currentTrack } = useContext(playerContext); const { currentTrack } = useContext(playerContext);
function addToQueue(obj: YoutubeTrack) { function addToQueue(obj: YoutubeTrack) {
setDownloadQueue([...downloadQueue, obj]); setDownloadQueue([...downloadQueue, obj]);
} }
const completedTrackIds = completedQueue.map((x) => x.id); const completedTrackIds = completedQueue.map((x) => x.id);
const downloadingTrackIds = downloadQueue.map((x) => x.id); const downloadingTrackIds = downloadQueue.map((x) => x.id);
function isActiveDownloading() { function isActiveDownloading() {
return downloadingTrackIds.includes(currentTrack?.id ?? ""); return downloadingTrackIds.includes(currentTrack?.id ?? "");
} }
function isFinishedDownloading() { function isFinishedDownloading() {
return completedTrackIds.includes(currentTrack?.id ?? ""); return completedTrackIds.includes(currentTrack?.id ?? "");
} }
useEffect(() => { useEffect(() => {
downloadQueue.forEach(async (el) => { downloadQueue.forEach(async (el) => {
if (!completedTrackIds.includes(el.id)) { if (!completedTrackIds.includes(el.id)) {
ytdl(el.youtube_uri, { ytdl(el.youtube_uri, {
filter: "audioonly", filter: "audioonly",
}) })
.pipe( .pipe(
fs.createWriteStream( fs.createWriteStream(
join( join(
os.homedir(), os.homedir(),
"Music", "Music",
`${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}]: `);
}) })
.on("finish", () => { .on("finish", () => {
setCompletedQueue([...completedQueue, el]); setCompletedQueue([...completedQueue, el]);
}); });
} }
}); });
}, [downloadQueue]); }, [downloadQueue]);
return { addToQueue, isFinishedDownloading, isActiveDownloading }; return { addToQueue, isFinishedDownloading, isActiveDownloading };
} }
export default useDownloadQueue; export default useDownloadQueue;

View File

@ -4,51 +4,72 @@ import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
import useSpotifyMutation from "./useSpotifyMutation"; 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>(
return userPlaylists.body; QueryCacheKeys.userPlaylists,
}) (spotifyApi, { pageParam }) =>
); spotifyApi
const favoritePlaylists = favoritePagedPlaylists?.pages .getUserPlaylists({ limit: 20, offset: pageParam })
.map((playlist) => playlist.items) .then((userPlaylists) => {
.filter(Boolean) return userPlaylists.body;
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]; }),
);
const favoritePlaylists = favoritePagedPlaylists?.pages
.map((playlist) => playlist.items)
.filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
function updateFunction(playlist: SpotifyApi.PlaylistObjectSimplified, old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> { function updateFunction(
const obj: typeof old = { playlist: SpotifyApi.PlaylistObjectSimplified,
pageParams: old?.pageParams ?? [], old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>,
pages: ): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
old?.pages.map( const obj: typeof old = {
(oldPage, index): SpotifyApi.ListOfUsersPlaylistsResponse => { pageParams: old?.pageParams ?? [],
const isPlaylistFavorite = isFavorite(playlist.id); pages:
if (index === 0 && !isPlaylistFavorite) { old?.pages.map(
return { ...oldPage, items: [...oldPage.items, playlist] }; (oldPage, index): SpotifyApi.ListOfUsersPlaylistsResponse => {
} else if (isPlaylistFavorite) { const isPlaylistFavorite = isFavorite(playlist.id);
return { ...oldPage, items: oldPage.items.filter((oldPlaylist) => oldPlaylist.id !== playlist.id) }; if (index === 0 && !isPlaylistFavorite) {
} return { ...oldPage, items: [...oldPage.items, playlist] };
return oldPage; } else if (isPlaylistFavorite) {
} return {
) ?? [], ...oldPage,
}; items: oldPage.items.filter(
return obj; (oldPlaylist) => oldPlaylist.id !== playlist.id,
} ),
};
const { mutate: reactToPlaylist } = useSpotifyMutation<{}, SpotifyApi.PlaylistObjectSimplified>( }
(spotifyApi, { id }) => spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then((res) => res.body), return oldPage;
{ },
onSuccess(_, playlist) { ) ?? [],
queryClient.setQueryData<InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old)); };
}, return obj;
} }
);
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);
function isFavorite(playlistId: string) { const { mutate: reactToPlaylist } = useSpotifyMutation<
return favoritePlaylistIds?.includes(playlistId); unknown,
} SpotifyApi.PlaylistObjectSimplified
>(
(spotifyApi, { id }) =>
spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then(
(res) => res.body,
),
{
onSuccess(_, playlist) {
queryClient.setQueryData<
InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>
>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
},
},
);
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);
return { reactToPlaylist, isFavorite, favoritePlaylists }; function isFavorite(playlistId: string) {
return favoritePlaylistIds?.includes(playlistId);
}
return { reactToPlaylist, isFavorite, favoritePlaylists };
} }
export default usePlaylistReaction; export default usePlaylistReaction;

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,35 +5,30 @@ 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, const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
clientSecret,
isLoggedIn,
setAccess_token,
} = useContext(authContext);
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
useEffect(() => { useEffect(() => {
if (isLoggedIn && clientId && clientSecret && refreshToken) { if (isLoggedIn && clientId && clientSecret && refreshToken) {
spotifyApi.setClientId(clientId); spotifyApi.setClientId(clientId);
spotifyApi.setClientSecret(clientSecret); spotifyApi.setClientSecret(clientSecret);
spotifyApi.setRefreshToken(refreshToken); spotifyApi.setRefreshToken(refreshToken);
if (!access_token) { if (!access_token) {
spotifyApi spotifyApi
.refreshAccessToken() .refreshAccessToken()
.then((token) => { .then((token) => {
setAccess_token(token.body.access_token); setAccess_token(token.body.access_token);
}) })
.catch((error) => { .catch((error) => {
showError(error); showError(error);
}); });
} }
spotifyApi.setAccessToken(access_token); spotifyApi.setAccessToken(access_token);
} }
}, [access_token, clientId, clientSecret, isLoggedIn]); }, [access_token, clientId, clientSecret, isLoggedIn]);
return spotifyApi; return spotifyApi;
} }
export default useSpotifyApi; export default useSpotifyApi;

View File

@ -5,25 +5,29 @@ import authContext from "../context/authContext";
import showError from "../helpers/showError"; 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
const isUnauthorized = error.message === "Unauthorized"; return async (error: SpotifyApi.ErrorObject | any) => {
const status401 = error.status === 401; const isUnauthorized = error.message === "Unauthorized";
const bodyStatus401 = error.body.error.status === 401; const status401 = error.status === 401;
const noToken = error.body.error.message === "No token provided"; const bodyStatus401 = error.body.error.status === 401;
const expiredToken = error.body.error.message === "The access token expired"; const noToken = error.body.error.message === "No token provided";
if ((isUnauthorized && isLoggedIn && status401) || ((noToken || expiredToken) && bodyStatus401)) { const expiredToken = error.body.error.message === "The access token expired";
try { if (
console.log(chalk.bgYellow.blackBright("Refreshing Access token")); (isUnauthorized && isLoggedIn && status401) ||
const { ((noToken || expiredToken) && bodyStatus401)
body: { access_token: refreshedAccessToken }, ) {
} = await spotifyApi.refreshAccessToken(); try {
setAccess_token(refreshedAccessToken); console.log(chalk.bgYellow.blackBright("Refreshing Access token"));
} catch (error) { const {
showError(error, "[Authorization Failure]: "); body: { access_token: refreshedAccessToken },
} } = await spotifyApi.refreshAccessToken();
} setAccess_token(refreshedAccessToken);
}; } catch (error) {
showError(error, "[Authorization Failure]: ");
}
}
};
} }
export default useSpotifyApiError; export default useSpotifyApiError;

View File

@ -1,28 +1,41 @@
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>(
const { isError, error } = query; queryKey,
(pageArgs) => queryHandler(spotifyApi, pageArgs),
options,
);
const { isError, error } = query;
useEffect(() => { useEffect(() => {
if (isError && error) { if (isError && error) {
handleSpotifyError(error); handleSpotifyError(error);
} }
}, [isError, error]); }, [isError, error]);
return query; return query;
} }
export default useSpotifyInfiniteQuery; export default useSpotifyInfiniteQuery;

View File

@ -4,21 +4,30 @@ 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>(
const spotifyApi = useSpotifyApi(); mutationFn: SpotifyMutationFn<TData, TVariable>,
const handleSpotifyError = useSpotifyApiError(spotifyApi); options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>,
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>((arg) => mutationFn(spotifyApi, arg), options); ) {
const { isError, error } = mutation; const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi);
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>(
(arg) => mutationFn(spotifyApi, arg),
options,
);
const { isError, error } = mutation;
useEffect(() => { useEffect(() => {
if (isError && error) { if (isError && error) {
handleSpotifyError(error); handleSpotifyError(error);
} }
}, [isError, error]); }, [isError, error]);
return mutation; return mutation;
} }
export default useSpotifyMutation; export default useSpotifyMutation;

View File

@ -7,23 +7,26 @@ import useSpotifyApiError from "./useSpotifyApiError";
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>; type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
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>(
const { isError, error } = query; queryKey,
() => queryHandler(spotifyApi),
options,
useEffect(() => { );
if (isError && error) { const { isError, error } = query;
handleSpotifyError(error);
}
}, [isError, error]);
return query; useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
return query;
} }
export default useSpotifyQuery; export default useSpotifyQuery;

View File

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

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;
@ -28,4 +28,4 @@ export const loadingSpinner = _loadingSpinner;
export const settingsCog = _settingsCog; export const settingsCog = _settingsCog;
export const times = _times; export const times = _times;
export const musicNode = _musicNode; export const musicNode = _musicNode;
export const download = _download; export const download = _download;

View File

@ -4,15 +4,15 @@ import App from "./app";
process.title = "Spotube"; process.title = "Spotube";
Renderer.render(<App />, { Renderer.render(<App />, {
onInit(reconciler) { onInit(reconciler) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
require("@nodegui/devtools").connectReactDevtools(reconciler); require("@nodegui/devtools").connectReactDevtools(reconciler);
} }
}, },
}); });
// This is for hot reloading (this will be stripped off in production by webpack) // This is for hot reloading (this will be stripped off in production by webpack)
if (module.hot) { if (module.hot) {
module.hot.accept(["./app"], function () { module.hot.accept(["./app"], function () {
Renderer.forceUpdate(); Renderer.forceUpdate();
}); });
} }

View File

@ -3,4 +3,4 @@ import { redirectURI } from "../conf";
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI }); const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
export default spotifyApi; export default spotifyApi;

View File

@ -12,50 +12,54 @@ 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);
return ( return (
<> <>
<Route path="/"> <Route path="/">
{isLoggedIn ? ( {isLoggedIn ? (
<> <>
<Redirect from="/" to="/home" /> <Redirect from="/" to="/home" />
<TabMenu /> <TabMenu />
<Route exact path="/home"> <Route exact path="/home">
<Home /> <Home />
</Route>
<Route exact path="/playlist/:id">
<PlaylistView />
</Route>
<Route exact path="/genre/playlists/:id">
<PlaylistGenreView />
</Route>
</>
) : (
<Login />
)}
</Route> </Route>
<Route exact path="/playlist/:id"> <Route path="/currently">
<PlaylistView /> <CurrentPlaylist />
</Route> </Route>
<Route exact path="/genre/playlists/:id"> <Route path="/library">
<PlaylistGenreView /> <Library />
</Route> </Route>
</> <Route path="/artist">
) : ( <Artist />
<Login /> </Route>
)} <Route exact path="/search">
</Route> <Search />
<Route path="/currently"> </Route>
<CurrentPlaylist /> <Route exact path="/search/playlists">
</Route> <SearchResultPlaylistCollection />
<Route path="/library"> </Route>
<Library /> <Route exact path="/search/songs">
</Route> <SearchResultSongsCollection />
<Route exact path="/search"> </Route>
<Search /> <Route exact path="/settings/">
</Route> <Settings />
<Route exact path="/search/playlists"> </Route>
<SearchResultPlaylistCollection /> </>
</Route> );
<Route exact path="/search/songs">
<SearchResultSongsCollection />
</Route>
<Route exact path="/settings/">
<Settings />
</Route>
</>
);
} }
export default Routes; export default Routes;

File diff suppressed because it is too large Load Diff