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,
"scripts": {
"build": "webpack --mode=production",
"dev": "nodemon -w src/** -e ts,tsx -x 'node esbuild.config.mjs'",
"check-types": "tsc --noEmit --watch",
"dev": "nodemon -w src/ -e ts,tsx -x 'node esbuild.config.mjs'",
"check-types": "nodemon --quiet -e tsx,ts -w src/ -x tsc --noEmit --pretty",
"start": "cd dist && qode index.js",
"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\"",
@ -60,16 +60,26 @@
"@types/spotify-web-api-node": "^5.0.0",
"@types/uuid": "^8.3.0",
"@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",
"clean-webpack-plugin": "^3.0.0",
"concurrently": "^6.0.0",
"cross-env": "^7.0.3",
"esbuild": "^0.12.8",
"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",
"fork-ts-checker-webpack-plugin": "^6.2.0",
"native-addon-loader": "^2.0.1",
"nodemon": "^2.0.7",
"prettier": "^2.3.1",
"typescript": "^4.2.3",
"webpack": "^5.27.0",
"webpack-cli": "^4.4.0"

View File

@ -1,6 +1,13 @@
import React, { useState, useEffect, useRef } from "react";
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 Routes from "./routes";
import { LocalStorage } from "node-localstorage";
@ -16,11 +23,13 @@ import fs from "fs";
import path from "path";
import { confDir, LocalStorageKeys } from "./conf";
import spotubeIcon from "../assets/icon.svg";
import preferencesContext, { PreferencesContextProperties } from "./context/preferencesContext";
import preferencesContext, {
PreferencesContextProperties,
} from "./context/preferencesContext";
export interface Credentials {
clientId: string;
clientSecret: string;
clientId: string;
clientSecret: string;
}
const minSize = { width: 700, height: 750 };
@ -29,183 +38,244 @@ const localStorageDir = path.join(confDir, "local");
fs.mkdirSync(localStorageDir, { recursive: true });
global.localStorage = new LocalStorage(localStorageDir);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError(error) {
showError(error);
},
defaultOptions: {
queries: {
onError(error) {
showError(error);
},
},
},
},
});
const initialPreferences: PreferencesContextProperties = {
playlistImages: false,
playlistImages: false,
};
const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
//* Application start
function RootApp() {
const windowRef = useRef<QMainWindow>();
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
// cache
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
// state
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const windowRef = useRef<QMainWindow>();
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
// cache
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
// state
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
const [credentials, setCredentials] = useState<Credentials>(() => {
if (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);
const [credentials, setCredentials] = useState<Credentials>(() => {
if (cachedCredentials) {
return JSON.parse(cachedCredentials);
}
});
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]);
// 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 initialCredentials;
});
const [preferences, setPreferences] = useState<PreferencesContextProperties>(() => {
if (cachedPreferences) {
return JSON.parse(cachedPreferences);
}
}
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]: ");
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);
}
},
);
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"));
};
}
}
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]: ");
}, [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]: ");
}
}
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);
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;
};
}
});
return (
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
<MemoryRouter>
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}>
<preferencesContext.Provider value={{ ...preferences, setPreferences }}>
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
<QueryClientProvider client={queryClient}>
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}>
<Routes />
{isLoggedIn && <Player />}
</View>
</QueryClientProvider>
</playerContext.Provider>
</preferencesContext.Provider>
</authContext.Provider>
</MemoryRouter>
</Window>
);
return (
<Window
ref={windowRef}
windowState={WindowState.WindowMaximized}
windowIcon={winIcon}
windowTitle="Spotube"
minSize={minSize}
>
<MemoryRouter>
<authContext.Provider
value={{
isLoggedIn,
setIsLoggedIn,
access_token,
setAccess_token,
...credentials,
setCredentials,
}}
>
<preferencesContext.Provider
value={{ ...preferences, setPreferences }}
>
<playerContext.Provider
value={{
currentPlaylist,
currentTrack,
setCurrentPlaylist,
setCurrentTrack,
}}
>
<QueryClientProvider client={queryClient}>
<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 {
render() {
return <RootApp />;
}
render() {
return <RootApp />;
}
}
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";
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;

View File

@ -5,31 +5,39 @@ import { TrackTableIndex } from "./PlaylistView";
import { TrackButton } from "./shared/TrackButton";
function CurrentPlaylist() {
const { currentPlaylist, currentTrack } = useContext(playerContext);
const { currentPlaylist, currentTrack } = useContext(playerContext);
if (!currentPlaylist && !currentTrack) {
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
}
if (!currentPlaylist && !currentTrack) {
return (
<Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>
);
}
if (currentTrack && !currentPlaylist) {
<View style="flex: 1;">
<TrackButton track={currentTrack} index={0}/>
</View>
}
if (currentTrack && !currentPlaylist) {
<View style="flex: 1;">
<TrackButton track={currentTrack} index={0} />
</View>;
}
return (
<View style="flex: 1; flex-direction: 'column';">
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
<TrackTableIndex />
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
{currentPlaylist?.tracks.map(({ track }, index) => {
return <TrackButton key={index + track.id} track={track} index={index} />;
})}
return (
<View style="flex: 1; flex-direction: 'column';">
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
<TrackTableIndex />
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
{currentPlaylist?.tracks.map(({ track }, index) => {
return (
<TrackButton
key={index + track.id}
track={track}
index={index}
/>
);
})}
</View>
</ScrollArea>
</View>
</ScrollArea>
</View>
);
);
}
export default CurrentPlaylist;

View File

@ -7,58 +7,96 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import CategoryCardView from "./shared/CategoryCardView";
function Home() {
const { data: pagedCategories, isError, refetch, isLoading, hasNextPage, 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 {
data: pagedCategories,
isError,
refetch,
isLoading,
hasNextPage,
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
.map((page) => page.items)
.filter(Boolean)
.flat(1);
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
return (
<ScrollArea style={`flex-grow: 1; border: none;`}>
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
<PlaceholderApplet error={isError} message="Failed to query genres" reload={refetch} helps loading={isLoading} />
{categories?.map((category, index) => {
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
})}
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />}
</View>
</ScrollArea>
);
const categories = pagedCategories?.pages
.map((page) => page.items)
.filter(Boolean)
.flat(1);
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
return (
<ScrollArea style={`flex-grow: 1; border: none;`}>
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
<PlaceholderApplet
error={isError}
message="Failed to query genres"
reload={refetch}
helps
loading={isLoading}
/>
{categories?.map((category, index) => {
return (
<CategoryCard
key={index + category.id}
id={category.id}
name={category.name}
/>
);
})}
{hasNextPage && (
<Button
on={{ clicked: () => fetchNextPage() }}
text="Load More"
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
);
}
export default Home;
interface CategoryCardProps {
id: string;
name: string;
id: string;
name: string;
}
const CategoryCard = ({ id, name }: CategoryCardProps) => {
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
[QueryCacheKeys.categoryPlaylists, id],
async (spotifyApi) => {
const option = { limit: 4 };
let res;
if (id === "featured") {
res = await spotifyApi.getFeaturedPlaylists(option);
} else {
res = await spotifyApi.getPlaylistsForCategory(id, option);
}
return res.body.playlists.items;
},
{ initialData: [] }
);
const { data: playlists, isError } = useSpotifyQuery<
SpotifyApi.PlaylistObjectSimplified[]
>(
[QueryCacheKeys.categoryPlaylists, id],
async (spotifyApi) => {
const option = { limit: 4 };
let res;
if (id === "featured") {
res = await spotifyApi.getFeaturedPlaylists(option);
} else {
res = await spotifyApi.getPlaylistsForCategory(id, option);
}
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 { Redirect, Route } from "react-router";
import { QueryCacheKeys } from "../conf";
@ -6,139 +7,279 @@ import playerContext from "../context/playerContext";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import { GenreView } from "./PlaylistGenreView";
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
import CachedImage from "./shared/CachedImage";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton";
import { TabMenuItem } from "./TabMenu";
function Library() {
return (
<View style="flex: 1; flex-direction: 'column';">
<Redirect from="/library" to="/library/saved-tracks" />
<View style="max-width: 350px; justify-content: 'space-evenly'">
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
<TabMenuItem title="Playlists" url="/library/playlists" />
</View>
<Route exact path="/library/saved-tracks">
<UserSavedTracks />
</Route>
<Route exact path="/library/playlists">
<UserPlaylists />
</Route>
</View>
);
return (
<View style="flex: 1; flex-direction: 'column';">
<Redirect from="/library" to="/library/saved-tracks" />
<View style="max-width: 350px; justify-content: 'space-evenly'">
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
<TabMenuItem title="Playlists" url="/library/playlists" />
<TabMenuItem title="Artists" url="/library/followed-artists" />
</View>
<Route exact path="/library/saved-tracks">
<UserSavedTracks />
</Route>
<Route exact path="/library/playlists">
<UserPlaylists />
</Route>
<Route exact path="/library/followed-artists">
<FollowedArtists />
</Route>
</View>
);
}
export default Library;
function UserPlaylists() {
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
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 {
data: userPagedPlaylists,
isError,
isLoading,
refetch,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
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
?.map((playlist) => playlist.items)
.filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
const userPlaylists = userPagedPlaylists?.pages
?.map((playlist) => playlist.items)
.filter(Boolean)
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
return (
<GenreView
heading="User Playlists"
isError={isError}
isLoading={isLoading}
playlists={userPlaylists ?? []}
isLoadable={!isFetchingNextPage}
refetch={refetch}
loadMore={hasNextPage ? ()=>fetchNextPage() : undefined}
/>
);
return (
<GenreView
heading="User Playlists"
isError={isError}
isLoading={isLoading}
playlists={userPlaylists ?? []}
isLoadable={!isFetchingNextPage}
refetch={refetch}
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
/>
);
}
function UserSavedTracks() {
const userSavedPlaylistId = "user-saved-tracks";
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
const userSavedPlaylistId = "user-saved-tracks";
const {
data: userSavedTracks,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) =>
spotifyApi
.getMySavedTracks({ limit: 50, offset: pageParam })
.then((res) => res.body),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
},
);
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } =
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
?.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 playlist: TrackButtonPlaylistObject = {
collaborative: false,
description: "User Playlist",
tracks: {
items: userTracks ?? [],
limit: 20,
href: "",
next: "",
offset: 0,
previous: "",
total: 20,
},
external_urls: { spotify: "" },
href: "",
id: userSavedPlaylistId,
images: [{ url: "https://facebook.com/img.jpeg" }],
name: "User saved track",
owner: { external_urls: { spotify: "" }, href: "", id: "Me", type: "user", uri: "spotify:user:me", display_name: "User", followers: { href: null, total: 0 } },
public: false,
snapshot_id: userSavedPlaylistId + "snapshot",
type: "playlist",
uri: "spotify:user:me:saved-tracks",
};
return (
<View style="flex: 1; flex-direction: 'column';">
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
<TrackTableIndex />
<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}
const playlist: TrackButtonPlaylistObject = {
collaborative: false,
description: "User Playlist",
tracks: {
items: userTracks ?? [],
limit: 20,
href: "",
next: "",
offset: 0,
previous: "",
total: 20,
},
external_urls: { spotify: "" },
href: "",
id: userSavedPlaylistId,
images: [{ url: "https://facebook.com/img.jpeg" }],
name: "User saved track",
owner: {
external_urls: { spotify: "" },
href: "",
id: "Me",
type: "user",
uri: "spotify:user:me",
display_name: "User",
followers: { href: null, total: 0 },
},
public: false,
snapshot_id: userSavedPlaylistId + "snapshot",
type: "playlist",
uri: "spotify:user:me:saved-tracks",
};
return (
<View style="flex: 1; flex-direction: 'column';">
<PlaylistSimpleControls
handlePlaylistPlayPause={handlePlaylistPlayPause}
isActive={currentPlaylist?.id === userSavedPlaylistId}
/>
)}
<TrackTableIndex />
<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>
</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";
function Login() {
const { setCredentials: setGlobalCredentials } = useContext(authContext);
const [credentials, setCredentials] = useState({
clientId: "",
clientSecret: "",
});
const { setCredentials: setGlobalCredentials } = useContext(authContext);
const [credentials, setCredentials] = useState({
clientId: "",
clientSecret: "",
});
const [touched, setTouched] = useState({
clientId: false,
clientSecret: false,
});
const [touched, setTouched] = useState({
clientId: false,
clientSecret: false,
});
type fieldNames = "clientId" | "clientSecret";
type fieldNames = "clientId" | "clientSecret";
function textChanged(text: string, fieldName: fieldNames) {
setCredentials({ ...credentials, [fieldName]: text });
}
function textEdited(name: fieldNames) {
if (!touched[name]) {
setTouched({ ...touched, [name]: true });
function textChanged(text: string, fieldName: fieldNames) {
setCredentials({ ...credentials, [fieldName]: text });
}
}
return (
<View style={`flex: 1; flex-direction: 'column';`}>
<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
on={{
textChanged: (t) => textChanged(t, "clientId"),
textEdited() {
textEdited("clientId");
},
}}
text={credentials.clientId}
placeholderText="spotify clientId"
/>
<LineEdit
on={{
textChanged: (t) => textChanged(t, "clientSecret"),
textEdited() {
textEdited("clientSecret");
},
}}
text={credentials.clientSecret}
placeholderText="spotify clientSecret"
/>
<Button
on={{
clicked: () => {
setGlobalCredentials(credentials);
},
}}
text="Add"
/>
</View>
);
function textEdited(name: fieldNames) {
if (!touched[name]) {
setTouched({ ...touched, [name]: true });
}
}
return (
<View style={`flex: 1; flex-direction: 'column';`}>
<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
on={{
textChanged: (t) => textChanged(t, "clientId"),
textEdited() {
textEdited("clientId");
},
}}
text={credentials.clientId}
placeholderText="spotify clientId"
/>
<LineEdit
on={{
textChanged: (t) => textChanged(t, "clientSecret"),
textEdited() {
textEdited("clientSecret");
},
}}
text={credentials.clientSecret}
placeholderText="spotify clientSecret"
/>
<Button
on={{
clicked: () => {
setGlobalCredentials(credentials);
},
}}
text="Add"
/>
</View>
);
}
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 showError from "../helpers/showError";
import fetchLyrics from "../helpers/fetchLyrics";
interface ManualLyricDialogProps extends PropsWithChildren<{}> {
open: boolean;
onClose?: (closed: boolean) => void;
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
interface ManualLyricDialogProps extends PropsWithChildren<unknown> {
open: boolean;
onClose?: (closed: boolean) => void;
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
}
function ManualLyricDialog({ open, track, onClose }: ManualLyricDialogProps) {
const dialog = new QDialog();
const areaContainer = new QWidget();
const retryButton = new QPushButton();
const scrollArea = new QScrollArea();
const titleLabel = new QLabel();
const lyricLabel = new QLabel();
const [lyricNotFound, setLyricNotFound] = useState<boolean>(false);
const [lyrics, setLyrics] = useState<string>("");
const artists = track.artists.map((artist) => artist.name).join(", ");
function ManualLyricDialog({ open, track }: ManualLyricDialogProps) {
const dialog = new QDialog();
const areaContainer = new QWidget();
const retryButton = new QPushButton();
const scrollArea = new QScrollArea();
const titleLabel = new QLabel();
const lyricLabel = new QLabel();
const [lyricNotFound, setLyricNotFound] = useState<boolean>(false);
const [lyrics, setLyrics] = useState<string>("");
const artists = track.artists.map((artist) => artist.name).join(", ");
async function handleBtnClick() {
try {
const lyrics = await fetchLyrics(artists, track.name);
console.log('lyrics:', lyrics)
setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found");
} catch (error) {
showError(error, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
async function handleBtnClick() {
try {
const lyrics = await fetchLyrics(artists, track.name);
console.log("lyrics:", lyrics);
setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found");
} catch (error) {
showError(error, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
}
}
}
useEffect(() => {
// title label
titleLabel.setText(`
useEffect(() => {
// title label
titleLabel.setText(`
<center>
<h2>${track.name}</h2>
<p>- ${artists}</p>
</center>
`);
// lyric label
lyricLabel.setText(lyrics);
lyricLabel.setTextFormat(TextFormat.PlainText);
// area container
areaContainer.setLayout(new FlexLayout());
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
areaContainer.layout?.addWidget(titleLabel);
areaContainer.layout?.addWidget(lyricLabel);
areaContainer.layout?.addWidget(retryButton);
// scroll area
scrollArea.setInlineStyle("flex: 1;");
scrollArea.setWidget(areaContainer);
// lyric label
lyricLabel.setText(lyrics);
lyricLabel.setTextFormat(TextFormat.PlainText);
// area container
areaContainer.setLayout(new FlexLayout());
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
areaContainer.layout?.addWidget(titleLabel);
areaContainer.layout?.addWidget(lyricLabel);
areaContainer.layout?.addWidget(retryButton);
// scroll area
scrollArea.setInlineStyle("flex: 1;");
scrollArea.setWidget(areaContainer);
// reload button
retryButton.setText("Retry");
retryButton.addEventListener("clicked", handleBtnClick);
// dialog
dialog.setWindowTitle("Lyrics");
dialog.setLayout(new FlexLayout());
dialog.layout?.addWidget(scrollArea);
open ? dialog.open() : dialog.close();
open &&
fetchLyrics(artists, track.name)
.then((lyrics: string) => {
setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found");
})
.catch((e: Error) => {
showError(e, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
});
return () => {
retryButton.removeEventListener("clicked", handleBtnClick);
dialog.hide();
};
}, [open, track, lyrics]);
// reload button
retryButton.setText("Retry");
retryButton.addEventListener("clicked", handleBtnClick);
// dialog
dialog.setWindowTitle("Lyrics");
dialog.setLayout(new FlexLayout());
dialog.layout?.addWidget(scrollArea);
open ? dialog.open() : dialog.close();
open &&
fetchLyrics(artists, track.name)
.then((lyrics: string) => {
setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found");
})
.catch((e: Error) => {
showError(e, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
});
return () => {
retryButton.removeEventListener("clicked", handleBtnClick);
dialog.hide();
};
}, [open, track, lyrics]);
useEffect(() => {
retryButton.setEnabled(!lyricNotFound);
}, [lyricNotFound]);
useEffect(() => {
retryButton.setEnabled(!lyricNotFound);
}, [lyricNotFound]);
return <></>;
return <></>;
}
export default ManualLyricDialog;

View File

@ -1,12 +1,31 @@
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 playerContext, { CurrentPlaylist } from "../context/playerContext";
import { shuffleArray } from "../helpers/shuffleArray";
import NodeMpv from "node-mpv";
import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack";
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 showError from "../helpers/showError";
import useTrackReaction from "../hooks/useTrackReaction";
@ -15,259 +34,347 @@ import { LocalStorageKeys } from "../conf";
import useDownloadQueue from "../hooks/useDownloadQueue";
export const audioPlayer = new NodeMpv(
{
audio_only: true,
auto_restart: true,
time_update: 1,
binary: process.env.MPV_EXECUTABLE,
// debug: true,
// verbose: true,
},
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"]
{
audio_only: true,
auto_restart: true,
time_update: 1,
binary: process.env.MPV_EXECUTABLE,
// debug: true,
// verbose: true,
},
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"],
);
function Player(): ReactElement {
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
const { reactToTrack, isFavorite } = useTrackReaction();
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } =
useContext(playerContext);
const { reactToTrack, isFavorite } = useTrackReaction();
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
const [isPaused, setIsPaused] = useState(true);
const [volume, setVolume] = useState<number>(() => (cachedVolume ? parseFloat(cachedVolume) : 55));
const [totalDuration, setTotalDuration] = useState<number>(0);
const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
const [isStopped, setIsStopped] = useState<boolean>(false);
const [openLyrics, setOpenLyrics] = useState<boolean>(false);
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>();
const { addToQueue, isActiveDownloading, isFinishedDownloading } = useDownloadQueue();
const [isPaused, setIsPaused] = useState(true);
const [volume, setVolume] = useState<number>(() =>
cachedVolume ? parseFloat(cachedVolume) : 55,
);
const [totalDuration, setTotalDuration] = useState<number>(0);
const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
const [isStopped, setIsStopped] = useState<boolean>(false);
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 volumeHandler = useEventHandler<QAbstractSliderSignals>(
{
sliderMoved: (value) => {
setVolume(value);
},
sliderReleased: () => {
localStorage.setItem(LocalStorageKeys.volume, volume.toString());
},
},
[volume]
);
const playerRunning = audioPlayer.isRunning();
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
const playlistTracksIds = currentPlaylist?.tracks?.map((t) => t.track.id) ?? [];
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
{
sliderMoved: (value) => {
setVolume(value);
},
sliderReleased: () => {
localStorage.setItem(LocalStorageKeys.volume, volume.toString());
},
},
[volume],
);
const playerRunning = audioPlayer.isRunning();
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
// initial Effect
useEffect(() => {
(async () => {
try {
if (!playerRunning) {
await audioPlayer.start();
await audioPlayer.volume(volume);
// initial Effect
useEffect(() => {
(async () => {
try {
if (!playerRunning) {
await audioPlayer.start();
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 () => {
if (playerRunning) {
audioPlayer.quit().catch((e: unknown) => console.log(e));
}
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]: ");
}
};
}, []);
// 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);
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,
);
}
setIsStopped(false);
} catch (error) {
if (error.errcode !== 5) {
setIsStopped(true);
setIsPaused(true);
};
async function stopPlayback() {
try {
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
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) {
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>
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>`}
</Text>
</GridColumn>
<GridColumn width={4}>
<BoxView direction={Direction.TopToBottom} style={`max-width: 600px; min-width: 380px;`}>
{currentTrack && <ManualLyricDialog open={openLyrics} track={currentTrack} />}
<PlayerProgressBar audioPlayer={audioPlayer} totalDuration={totalDuration} />
: `<b>Oh, dear don't waste time</b>`}
</Text>
</GridColumn>
<GridColumn width={4}>
<BoxView
direction={Direction.TopToBottom}
style={`max-width: 600px; min-width: 380px;`}
>
{currentTrack && (
<ManualLyricDialog open={openLyrics} track={currentTrack} />
)}
<PlayerProgressBar
audioPlayer={audioPlayer}
totalDuration={totalDuration}
/>
<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 on={{ clicked: () => prevOrNext(-1) }} icon={new QIcon(backward)} />
<IconButton on={{ clicked: handlePlayPause }} icon={new QIcon(isStopped || isPaused || !currentTrack ? play : pause)} />
<IconButton on={{ clicked: () => prevOrNext(1) }} icon={new QIcon(forward)} />
<IconButton icon={new QIcon(stop)} on={{ clicked: stopPlayback }} />
</BoxView>
</BoxView>
</GridColumn>
<GridColumn width={2}>
<BoxView>
<IconButton
style={isActiveDownloading() && !isFinishedDownloading() ? "background-color: green;" : ""}
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>
);
<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
on={{ clicked: () => prevOrNext(-1) }}
icon={new QIcon(backward)}
/>
<IconButton
on={{ clicked: handlePlayPause }}
icon={
new QIcon(
isStopped || isPaused || !currentTrack
? play
: pause,
)
}
/>
<IconButton
on={{ clicked: () => prevOrNext(1) }}
icon={new QIcon(forward)}
/>
<IconButton
icon={new QIcon(stop)}
on={{ clicked: stopPlayback }}
/>
</BoxView>
</BoxView>
</GridColumn>
<GridColumn width={2}>
<BoxView>
<IconButton
style={
isActiveDownloading() && !isFinishedDownloading()
? "background-color: green;"
: ""
}
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;

View File

@ -5,58 +5,70 @@ import React, { useContext, useEffect, useState } from "react";
import playerContext from "../context/playerContext";
interface PlayerProgressBarProps {
audioPlayer: NodeMpv;
totalDuration: number;
audioPlayer: NodeMpv;
totalDuration: number;
}
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
const { currentTrack } = useContext(playerContext);
const [trackTime, setTrackTime] = useState<number>(0);
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
{
sliderMoved: (value) => {
if (audioPlayer.isRunning() && currentTrack) {
const newPosition = (totalDuration * value) / 100;
setTrackTime(parseInt(newPosition.toString()));
}
},
sliderReleased: () => {
(async () => {
try {
await audioPlayer.goToPosition(trackTime);
} catch (error) {
console.error(error);
}
})();
},
},
[currentTrack, totalDuration, trackTime]
);
const { currentTrack } = useContext(playerContext);
const [trackTime, setTrackTime] = useState<number>(0);
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
{
sliderMoved: (value) => {
if (audioPlayer.isRunning() && currentTrack) {
const newPosition = (totalDuration * value) / 100;
setTrackTime(parseInt(newPosition.toString()));
}
},
sliderReleased: () => {
(async () => {
try {
await audioPlayer.goToPosition(trackTime);
} catch (error) {
console.error(error);
}
})();
},
},
[currentTrack, totalDuration, trackTime],
);
useEffect(() => {
const progressListener = (seconds: number) => {
setTrackTime(seconds);
};
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
if (property === "filename") {
setTrackTime(0);
}
};
audioPlayer.on("status", statusListener);
audioPlayer.on("timeposition", progressListener);
return () => {
audioPlayer.off("status", statusListener);
audioPlayer.off("timeposition", progressListener);
};
});
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)
return (
<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>
);
useEffect(() => {
const progressListener = (seconds: number) => {
setTrackTime(seconds);
};
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
if (property === "filename") {
setTrackTime(0);
}
};
audioPlayer.on("status", statusListener);
audioPlayer.on("timeposition", progressListener);
return () => {
audioPlayer.off("status", statusListener);
audioPlayer.off("timeposition", progressListener);
};
});
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);
return (
<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;

View File

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

View File

@ -14,94 +14,130 @@ import { TrackButton } from "./shared/TrackButton";
import PlaceholderApplet from "./shared/PlaceholderApplet";
export interface PlaylistTrackRes {
name: string;
artists: string;
url: string;
name: string;
artists: string;
url: string;
}
const PlaylistView: FC = () => {
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
const params = useParams<{ id: string }>();
const location = useLocation<{ name: string; thumbnail: string }>();
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) =>
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body)
);
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 { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
useContext(playerContext);
const params = useParams<{ id: string }>();
const location = useLocation<{ name: string; thumbnail: string }>();
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>(
[QueryCacheKeys.categoryPlaylists, params.id],
(spotifyApi) =>
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body),
);
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 = () => {
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[0].track);
} else {
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
}
};
const handlePlaylistPlayPause = () => {
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[0].track);
} else {
audioPlayer
.stop()
.catch((error) => console.error("Failed to stop audio player: ", error));
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
}
};
return (
<View style={`flex: 1; flex-direction: 'column';`}>
<PlaylistSimpleControls
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
handlePlaylistPlayPause={handlePlaylistPlayPause}
isActive={currentPlaylist?.id === params.id}
isFavorite={isFavorite(params.id)}
/>
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
{<TrackTableIndex />}
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} />
{tracks?.map(({ track }, index) => {
if (track) {
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />;
}
})}
return (
<View style={`flex: 1; flex-direction: 'column';`}>
<PlaylistSimpleControls
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
handlePlaylistPlayPause={handlePlaylistPlayPause}
isActive={currentPlaylist?.id === params.id}
isFavorite={isFavorite(params.id)}
/>
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(
1,
)}</h2></center>`}</Text>
{<TrackTableIndex />}
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
<PlaceholderApplet
error={isError}
loading={isLoading}
reload={refetch}
message={`Failed retrieving ${location.state.name} tracks`}
/>
{tracks?.map(({ track }, index) => {
if (track) {
return (
<TrackButton
key={index + track.id}
track={track}
index={index}
playlist={playlist}
/>
);
}
})}
</View>
</ScrollArea>
</View>
</ScrollArea>
</View>
);
);
};
export default PlaylistView;
export function TrackTableIndex() {
return (
<View>
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
</View>
);
return (
<View>
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
</View>
);
}
interface PlaylistSimpleControlsProps {
handlePlaylistPlayPause: (index?: number) => void;
handlePlaylistReact?: () => void;
isActive: boolean;
isFavorite?: boolean;
handlePlaylistPlayPause: (index?: number) => void;
handlePlaylistReact?: () => void;
isActive: boolean;
isFavorite?: boolean;
}
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) {
return (
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
<BackButton />
{isFavorite !== undefined && <IconButton icon={new QIcon(isFavorite ? heart : heartRegular)} on={{ clicked: handlePlaylistReact }} />}
<IconButton
style={`background-color: #00be5f; color: white;`}
on={{
clicked() {
handlePlaylistPlayPause();
},
}}
icon={new QIcon(isActive ? stop : play)}
/>
</View>
);
export function PlaylistSimpleControls({
handlePlaylistPlayPause,
isActive,
handlePlaylistReact,
isFavorite,
}: PlaylistSimpleControlsProps) {
return (
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
<BackButton />
{isFavorite !== undefined && (
<IconButton
icon={new QIcon(isFavorite ? heart : heartRegular)}
on={{ clicked: handlePlaylistReact }}
/>
)}
<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";
function Search() {
const history = useHistory<{ searchQuery: string }>();
const [searchQuery, setSearchQuery] = useState<string>("");
const { data: searchResults, refetch, isError, isLoading } = useSpotifyQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.search,
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
{ enabled: false }
);
const history = useHistory<{ searchQuery: string }>();
const [searchQuery, setSearchQuery] = useState<string>("");
const {
data: searchResults,
refetch,
isError,
isLoading,
} = useSpotifyQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.search,
(spotifyApi) =>
spotifyApi
.search(searchQuery, ["playlist", "track"], { limit: 4 })
.then((res) => res.body),
{ enabled: false },
);
async function handleSearch() {
try {
await refetch();
} catch (error) {
showError(error, "[Failed to search through spotify]: ");
async function handleSearch() {
try {
await refetch();
} catch (error) {
showError(error, "[Failed to search through spotify]: ");
}
}
}
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
return (
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
<View>
<LineEdit
style="width: '65%'; margin: 5px;"
placeholderText="Search spotify"
on={{
textChanged(t) {
setSearchQuery(t);
},
KeyRelease(native: any) {
const key = new QKeyEvent(native);
const isEnter = key.key() === 16777220;
if (isEnter) {
handleSearch();
}
}
}}
const placeholder = (
<PlaceholderApplet
error={isError}
loading={isLoading}
message="Failed querying spotify"
reload={refetch}
/>
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} />
</View>
<ScrollArea style="flex: 1;">
<View style="flex-direction: 'column'; flex: 1;">
<View style="flex: 1; flex-direction: 'column';">
<Text
cursor={CursorShape.PointingHandCursor}
on={{
MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) {
history.push("/search/songs", { searchQuery });
}
},
}}>{`<h2>Songs</h2>`}</Text>
<TrackTableIndex />
{placeholder}
{searchResults?.tracks?.items.map((track, index) => (
<TrackButton key={index + track.id} index={index} track={track} />
))}
</View>
<View style="flex: 1; flex-direction: 'column';">
<Text
cursor={CursorShape.PointingHandCursor}
on={{
MouseButtonRelease(native: any) {
if (new QMouseEvent(native).button() === 1 && 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} />
))}
);
return (
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
<View>
<LineEdit
style="width: '65%'; margin: 5px;"
placeholderText="Search spotify"
on={{
textChanged(t) {
setSearchQuery(t);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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>
</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>
</ScrollArea>
</View>
);
);
}
export default Search;

View File

@ -5,34 +5,51 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import { GenreView } from "./PlaylistGenreView";
function SearchResultPlaylistCollection() {
const location = useLocation<{ searchQuery: string }>();
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.searchPlaylist,
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
{
getNextPageParam: (lastPage) => {
if (lastPage.playlists?.next) {
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0);
}
},
}
);
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}
/>
);
const location = useLocation<{ searchQuery: string }>();
const {
data: searchResults,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.searchPlaylist,
(spotifyApi, { pageParam }) =>
spotifyApi
.searchPlaylists(location.state.searchQuery, {
limit: 20,
offset: pageParam,
})
.then((res) => res.body),
{
getNextPageParam: (lastPage) => {
if (lastPage.playlists?.next) {
return (
(lastPage.playlists?.offset ?? 0) +
(lastPage.playlists?.limit ?? 0)
);
}
},
},
);
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;

View File

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

View File

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

View File

@ -6,27 +6,27 @@ import IconButton from "./shared/IconButton";
import { QIcon } from "@nodegui/nodegui";
function TabMenu() {
const history = useHistory();
const history = useHistory();
return (
<View id="tabmenu" styleSheet={tabBarStylesheet}>
<View>
<Text>{`<h1>Spotube</h1>`}</Text>
</View>
<TabMenuItem url="/home" title="Browse" />
<TabMenuItem url="/library" title="Library" />
<TabMenuItem url="/currently" title="Currently Playing" />
<TabMenuItem url="/search" title="Search" />
<IconButton
icon={new QIcon(settingsCog)}
on={{
clicked() {
history.push("/settings");
},
}}
/>
</View>
);
return (
<View id="tabmenu" styleSheet={tabBarStylesheet}>
<View>
<Text>{`<h1>Spotube</h1>`}</Text>
</View>
<TabMenuItem url="/home" title="Browse" />
<TabMenuItem url="/library" title="Library" />
<TabMenuItem url="/currently" title="Currently Playing" />
<TabMenuItem url="/search" title="Search" />
<IconButton
icon={new QIcon(settingsCog)}
on={{
clicked() {
history.push("/settings");
},
}}
/>
</View>
);
}
export const tabBarStylesheet = `
@ -47,21 +47,31 @@ export const tabBarStylesheet = `
export default TabMenu;
export interface TabMenuItemProps {
title: string;
url: string;
/**
* path to the icon in string
*/
icon?: string;
title: string;
url: string;
/**
* path to the icon in string
*/
icon?: string;
}
export function TabMenuItem({ icon, title, url }: TabMenuItemProps) {
const location = useLocation();
const history = useHistory();
export function TabMenuItem({ title, url }: TabMenuItemProps) {
const location = useLocation();
const history = useHistory();
function clicked() {
history.push(url);
}
function clicked() {
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";
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
src: string;
alt?: string;
src: string;
alt?: string;
}
function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
const labelRef = useRef<QLabel>();
const [imageBuffer, setImageBuffer] = useState<Buffer>();
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
const pixmap = new QPixmap();
const labelRef = useRef<QLabel>();
const [imageBuffer, setImageBuffer] = useState<Buffer>();
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
const pixmap = new QPixmap();
useEffect(() => {
if (imageBuffer === undefined) {
getCachedImageBuffer(src, maxSize ?? size)
.then((buffer) => setImageBuffer(buffer))
.catch((error) => {
setImageProcessError(false);
showError(error, "[Cached Image Error]: ");
});
}
useEffect(() => {
if (imageBuffer === undefined) {
getCachedImageBuffer(src, maxSize ?? size)
.then((buffer) => setImageBuffer(buffer))
.catch((error) => {
setImageProcessError(false);
showError(error, "[Cached Image Error]: ");
});
}
return () => {
labelRef.current?.close();
};
}, []);
return () => {
labelRef.current?.close();
};
}, []);
useEffect(() => {
if (imageBuffer) {
pixmap.loadFromData(imageBuffer);
pixmap.scaled((size ?? maxSize)?.height ?? 100, (size ?? maxSize)?.width ?? 100);
labelRef.current?.setPixmap(pixmap);
}
}, [imageBuffer]);
useEffect(() => {
if (imageBuffer) {
pixmap.loadFromData(imageBuffer);
pixmap.scaled(
(size ?? maxSize)?.height ?? 100,
(size ?? maxSize)?.width ?? 100,
);
labelRef.current?.setPixmap(pixmap);
}
}, [imageBuffer]);
return !imageProcessError && imageBuffer ? (
<Text ref={labelRef} {...props}/>
) : alt ? (
<View style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${((maxSize ?? size)?.width || 10) / 2.5}px;`}>
<Text>{alt}</Text>
</View>
) : (
<></>
);
return !imageProcessError && imageBuffer ? (
<Text ref={labelRef} {...props} />
) : alt ? (
<View
style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${
((maxSize ?? size)?.width || 10) / 2.5
}px;`}
>
<Text>{alt}</Text>
</View>
) : (
<></>
);
}
export default CachedImage;

View File

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

View File

@ -1,20 +1,20 @@
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 { 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) {
const iconBtnRef = useRef<QPushButton>()
const shadowGfx = new QGraphicsDropShadowEffect();
useEffect(() => {
shadowGfx.setBlurRadius(5);
shadowGfx.setXOffset(0);
shadowGfx.setYOffset(0);
iconBtnRef.current?.setGraphicsEffect(shadowGfx);
}, [])
const iconButtonStyleSheet = `
const iconBtnRef = useRef<QPushButton>();
const shadowGfx = new QGraphicsDropShadowEffect();
useEffect(() => {
shadowGfx.setBlurRadius(5);
shadowGfx.setXOffset(0);
shadowGfx.setYOffset(0);
iconBtnRef.current?.setGraphicsEffect(shadowGfx);
}, []);
const iconButtonStyleSheet = `
#icon-btn{
background-color: rgba(255, 255, 255, 0.055);
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;

View File

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

View File

@ -15,49 +15,56 @@ import CachedImage from "./CachedImage";
import IconButton from "./IconButton";
interface PlaylistCardProps {
playlist: SpotifyApi.PlaylistObjectSimplified;
playlist: SpotifyApi.PlaylistObjectSimplified;
}
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
const preferences = useContext(preferencesContext);
const thumbnail = playlist.images[0].url;
const { id, description, name, images } = playlist;
const history = useHistory();
const [hovered, setHovered] = useState(false);
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), {
initialData: [],
enabled: false,
});
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
const preferences = useContext(preferencesContext);
const thumbnail = playlist.images[0].url;
const { id, description, name } = playlist;
const history = useHistory();
const [hovered, setHovered] = useState(false);
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
useContext(playerContext);
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
[QueryCacheKeys.playlistTracks, id],
(spotifyApi) =>
spotifyApi.getPlaylistTracks(id).then((track) => track.body.items),
{
initialData: [],
enabled: false,
},
);
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
const handlePlaylistPlayPause = async () => {
try {
const { data: tracks, isSuccess } = await refetch();
if (currentPlaylist?.id !== id && isSuccess && tracks) {
setCurrentPlaylist({ tracks, id, name, thumbnail });
setCurrentTrack(tracks[0].track);
} else {
await audioPlayer.stop();
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
}
} catch (error) {
showError(error, "[Failed adding playlist to queue]: ");
const handlePlaylistPlayPause = async () => {
try {
const { data: tracks, isSuccess } = await refetch();
if (currentPlaylist?.id !== id && isSuccess && tracks) {
setCurrentPlaylist({ tracks, id, name, thumbnail });
setCurrentTrack(tracks[0].track);
} else {
await audioPlayer.stop();
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
}
} catch (error) {
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 key = new QMouseEvent(native);
if (key.button() === 1) {
history.push(`/playlist/${id}`, { name, thumbnail });
}
}
const bgColor1 = useMemo(() => generateRandomColor(), []);
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
const bgColor1 = useMemo(() => generateRandomColor(), []);
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
const playlistStyleSheet = `
const playlistStyleSheet = `
#playlist-container, #img-container{
width: 150px;
padding: 10px;
@ -76,70 +83,85 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
border: 5px solid green;
}
`;
const playlistAction = `
const playlistAction = `
position: absolute;
bottom: 30px;
background-color: ${color};
`;
const playlistActions = (
<>
<IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"}
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
on={{
clicked() {
reactToPlaylist(playlist);
},
}}
/>
<IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"}
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
on={{
clicked() {
handlePlaylistPlayPause();
},
}}
/>
</>
);
const hovers = {
HoverEnter() {
setHovered(true);
},
HoverLeave() {
setHovered(false);
},
};
const playlistActions = (
<>
<IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"}
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
on={{
clicked() {
reactToPlaylist(playlist);
},
}}
/>
<IconButton
style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"}
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
on={{
clicked() {
handlePlaylistPlayPause();
},
}}
/>
</>
);
const hovers = {
HoverEnter() {
setHovered(true);
},
HoverLeave() {
setHovered(false);
},
};
return (
<View
id={preferences.playlistImages ? "img-container" : "playlist-container"}
cursor={CursorShape.PointingHandCursor}
styleSheet={playlistStyleSheet}
on={{
MouseButtonRelease: gotoPlaylist,
...hovers,
}}>
{preferences.playlistImages && <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />}
return (
<View
id={preferences.playlistImages ? "img-container" : "playlist-container"}
cursor={CursorShape.PointingHandCursor}
styleSheet={playlistStyleSheet}
on={{
MouseButtonRelease: gotoPlaylist,
...hovers,
}}
>
{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>
<h3>${name}</h3>
<p>${description}</p>
</center>
`}
</Text>
</Text>
{(hovered || currentPlaylist?.id === id) && !preferences.playlistImages && playlistActions}
{preferences.playlistImages &&
<View style="flex: 1; justify-content: 'space-around';">{playlistActions}
{(hovered || currentPlaylist?.id === id) &&
!preferences.playlistImages &&
playlistActions}
{preferences.playlistImages && (
<View style="flex: 1; justify-content: 'space-around';">
{playlistActions}
</View>
)}
</View>
}
</View>
);
);
};
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";
export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> {
onChange?(checked: boolean): void;
onChange?(checked: boolean): void;
}
function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
const [checked, setChecked] = useState<boolean>(false);
const [checked, setChecked] = useState<boolean>(false);
useEffect(() => {
if (derivedChecked) {
setChecked(derivedChecked);
}
}, []);
useEffect(() => {
if (derivedChecked) {
setChecked(derivedChecked);
}
}, []);
return (
<Slider
value={checked ? 1 : 0}
hasTracking
mouseTracking
orientation={Orientation.Horizontal}
maximum={1}
minimum={0}
maxSize={{ width: 30, height: 20 }}
on={{
valueChanged(value) {
onChange && onChange(value===1);
},
MouseButtonRelease(native: any) {
const mouse = new QMouseEvent(native);
if (mouse.button() === 1) {
setChecked(!checked);
}
},
}}
{...props}
/>
);
return (
<Slider
value={checked ? 1 : 0}
hasTracking
mouseTracking
orientation={Orientation.Horizontal}
maximum={1}
minimum={0}
maxSize={{ width: 30, height: 20 }}
on={{
valueChanged(value) {
onChange && onChange(value === 1);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MouseButtonRelease(native: any) {
const mouse = new QMouseEvent(native);
if (mouse.button() === 1) {
setChecked(!checked);
}
},
}}
{...props}
/>
);
}
export default Switch;

View File

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

View File

@ -2,7 +2,7 @@ import dotenv from "dotenv";
import { homedir } from "os";
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 trace = process.argv.find((arg) => arg === "--trace") ?? false;
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 enum QueryCacheKeys {
categories = "categories",
categoryPlaylists = "categoryPlaylists",
featuredPlaylists = "featuredPlaylists",
genrePlaylists = "genrePlaylists",
playlistTracks = "playlistTracks",
userPlaylists = "user-palylists",
userSavedTracks = "user-saved-tracks",
search = "search",
searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs",
categories = "categories",
categoryPlaylists = "categoryPlaylists",
featuredPlaylists = "featuredPlaylists",
genrePlaylists = "genrePlaylists",
playlistTracks = "playlistTracks",
userPlaylists = "user-palylists",
userSavedTracks = "user-saved-tracks",
search = "search",
searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs",
followedArtists = "followed-artists",
}
export enum LocalStorageKeys {
credentials = "credentials",
refresh_token = "refresh_token",
preferences = "user-preferences",
volume = "volume",
cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track"
credentials = "credentials",
refresh_token = "refresh_token",
preferences = "user-preferences",
volume = "volume",
cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track",
}

View File

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

View File

@ -2,15 +2,27 @@ import React, { Dispatch, SetStateAction } from "react";
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 {
currentPlaylist?: CurrentPlaylist;
currentTrack?: CurrentTrack;
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
currentPlaylist?: CurrentPlaylist;
currentTrack?: CurrentTrack;
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | 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;

View File

@ -1,15 +1,17 @@
import React, { Dispatch, SetStateAction } from "react";
export interface PreferencesContextProperties {
playlistImages: boolean;
playlistImages: boolean;
}
export interface PreferencesContext extends PreferencesContextProperties {
setPreferences: Dispatch<SetStateAction<PreferencesContextProperties>>;
setPreferences: Dispatch<SetStateAction<PreferencesContextProperties>>;
}
const preferencesContext = React.createContext<PreferencesContext>({
playlistImages: false,
setPreferences() { }
playlistImages: false,
setPreferences() {
return;
},
});
export default preferencesContext;

View File

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

View File

@ -1,49 +1,80 @@
import axios from "axios";
import htmlToText from "html-to-text";
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 delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
const delim1 =
'</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=";
export default async function fetchLyrics(artists: string, title: string) {
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]: ");
let lyrics;
try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`);
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`)).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(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;
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_2) {
showError(err_2, "[Lyric Query Error]: ");
} catch (err) {
showError(err, "[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";
console.log(
"[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
)
).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(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");
let final = "";
for (const ret of rets) {
final = `${final}${htmlToText.htmlToText(ret)}\n`;
}
return final.trim();
const rets = lyrics.split("\n");
let final = "";
for (const ret of rets) {
final = `${final}${htmlToText.htmlToText(ret)}\n`;
}
return final.trim();
}

View File

@ -9,59 +9,83 @@ import du from "du";
import { cacheDir } from "../conf";
interface ImageDimensions {
height: number;
width: number;
height: number;
width: number;
}
const fsm = fs.promises;
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> {
try {
const MB_5 = 5000000; //5 Megabytes
const cacheImgFolder = path.join(cacheDir, "images");
// for clearing up the cache if it reaches out of the size
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
const cacheImgPath = path.join(cacheImgFolder, cacheName);
// checking if the cached image already exists or not
if (fs.existsSync(cacheImgPath)) {
// automatically removing cache after a certain 50 MB oversize
if ((await du(cacheImgFolder)) > MB_5) {
fs.rmdirSync(cacheImgFolder, { recursive: true });
}
const cachedImg = await fsm.readFile(cacheImgPath);
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
export async function getCachedImageBuffer(
name: string,
dims?: ImageDimensions,
): Promise<Buffer> {
try {
const MB_5 = 5000000; //5 Megabytes
const cacheImgFolder = path.join(cacheDir, "images");
// for clearing up the cache if it reaches out of the size
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
const cacheImgPath = path.join(cacheImgFolder, cacheName);
// checking if the cached image already exists or not
if (fs.existsSync(cacheImgPath)) {
// automatically removing cache after a certain 50 MB oversize
if ((await du(cacheImgFolder)) > MB_5) {
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
// images are removed and replaced with a new one
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) {
fs.unlinkSync(cacheImgPath);
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims });
}
return cachedImg;
} else {
// finding no cache image fetching it through axios
const { data: imgData } = await axios.get<Stream>(name, { 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;
// if the dimensions are changed then the previously cached
// images are removed and replaced with a new one
if (
dims &&
(cachedImgMeta.height !== dims.height ||
cachedImgMeta.width !== dims?.width)
) {
fs.unlinkSync(cacheImgPath);
return await imageResizeAndWrite(cachedImg, {
cacheFolder: cacheImgFolder,
cacheName,
dims,
});
}
return cachedImg;
} else {
// finding no cache image fetching it through axios
const { data: imgData } = await axios.get<Stream>(name, {
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> {
// 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;
async function imageResizeAndWrite(
img: Buffer,
{
cacheFolder,
cacheName,
dims,
}: { dims: ImageDimensions; cacheFolder: string; cacheName: string },
): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height)
// 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
* @return {*} {number}
*/
export function includePercentage(src: string | Array<string | number>, matches: Array<string | number>): number {
let count = 0;
matches.forEach((match) => {
if (src.includes(match.toString())) count++;
});
return (count / matches.length) * 100;
export function includePercentage(
src: string | Array<string | number>,
matches: Array<string | number>,
): number {
let count = 0;
matches.forEach((match) => {
if (src.includes(match.toString())) count++;
});
return (count / matches.length) * 100;
}
export interface YoutubeTrack extends CurrentTrack {
youtube_uri: string;
youtube_uri: string;
}
export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promise<YoutubeTrack> {
try {
const artistsName = track.artists.map((ar) => ar.name);
const queryString = `${artistsName[0]} - ${track.name}${artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``}`;
console.log("Youtube Query String:", queryString);
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
const tracksWithRelevance = result
.map((video) => {
// percentage of matched track {name, artists} matched with
// title of the youtube search results
const matchPercentage = includePercentage(video.title, [track.name, ...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);
export async function getYoutubeTrack(
track: SpotifyApi.TrackObjectFull,
): Promise<YoutubeTrack> {
try {
const artistsName = track.artists.map((ar) => ar.name);
const queryString = `${artistsName[0]} - ${track.name}${
artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``
}`;
console.log("Youtube Query String:", queryString);
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
const tracksWithRelevance = result
.map((video) => {
// percentage of matched track {name, artists} matched with
// title of the youtube search results
const matchPercentage = includePercentage(video.title, [
track.name,
...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 };
return finalTrack;
} catch (error) {
console.error("Failed to resolve track's youtube url: ", error);
throw error;
}
const finalTrack = {
...track,
youtube_uri: (sameChannelTracks.length > 0
? sameChannelTracks
: 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) {
const minutes = Math.floor(ms / 60000);
const seconds:number = parseInt(((ms % 60000) / 1000).toFixed(0));
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
const minutes = Math.floor(ms / 60000);
const seconds: number = parseInt(((ms % 60000) / 1000).toFixed(0));
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
}

View File

@ -1,8 +1,8 @@
import { trace } from "../conf";
import chalk from "chalk";
function showError(error: any, message: any="[Error]: ") {
console.error(chalk.red(message), trace ? error : error.message);
function showError(error: Error | TypeError, message = "[Error]: ") {
console.error(chalk.red(message), trace ? error : error.message);
}
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--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[i];

View File

@ -1,16 +1,16 @@
import { Stream } from "stream";
export function streamToBuffer(stream: Stream): Promise<Buffer> {
let buffArr: any[] = [];
return new Promise((resolve, reject) => {
stream.on("data", (data) => {
buffArr.push(data);
const buffArr: Uint8Array[] = [];
return new Promise((resolve, reject) => {
stream.on("data", (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";
function useDownloadQueue() {
const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]);
const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]);
const { currentTrack } = useContext(playerContext);
const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]);
const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]);
const { currentTrack } = useContext(playerContext);
function addToQueue(obj: YoutubeTrack) {
setDownloadQueue([...downloadQueue, obj]);
}
const completedTrackIds = completedQueue.map((x) => x.id);
const downloadingTrackIds = downloadQueue.map((x) => x.id);
function addToQueue(obj: YoutubeTrack) {
setDownloadQueue([...downloadQueue, obj]);
}
const completedTrackIds = completedQueue.map((x) => x.id);
const downloadingTrackIds = downloadQueue.map((x) => x.id);
function isActiveDownloading() {
return downloadingTrackIds.includes(currentTrack?.id ?? "");
}
function isActiveDownloading() {
return downloadingTrackIds.includes(currentTrack?.id ?? "");
}
function isFinishedDownloading() {
return completedTrackIds.includes(currentTrack?.id ?? "");
}
function isFinishedDownloading() {
return completedTrackIds.includes(currentTrack?.id ?? "");
}
useEffect(() => {
downloadQueue.forEach(async (el) => {
if (!completedTrackIds.includes(el.id)) {
ytdl(el.youtube_uri, {
filter: "audioonly",
})
.pipe(
fs.createWriteStream(
join(
os.homedir(),
"Music",
`${el.name} - ${el.artists
.map((x) => x.name)
.join(", ")
.trim()}.mp3`
)
)
)
.on("error", (err) => {
showError(err, `[failed to download ${el.name}]: `);
})
.on("finish", () => {
setCompletedQueue([...completedQueue, el]);
});
}
});
}, [downloadQueue]);
return { addToQueue, isFinishedDownloading, isActiveDownloading };
useEffect(() => {
downloadQueue.forEach(async (el) => {
if (!completedTrackIds.includes(el.id)) {
ytdl(el.youtube_uri, {
filter: "audioonly",
})
.pipe(
fs.createWriteStream(
join(
os.homedir(),
"Music",
`${el.name} - ${el.artists
.map((x) => x.name)
.join(", ")
.trim()}.mp3`,
),
),
)
.on("error", (err) => {
showError(err, `[failed to download ${el.name}]: `);
})
.on("finish", () => {
setCompletedQueue([...completedQueue, el]);
});
}
});
}, [downloadQueue]);
return { addToQueue, isFinishedDownloading, isActiveDownloading };
}
export default useDownloadQueue;

View File

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

View File

@ -1,4 +1,3 @@
import chalk from "chalk";
import { useContext, useEffect } from "react";
import { LocalStorageKeys } from "../conf";
import authContext from "../context/authContext";
@ -6,35 +5,30 @@ import showError from "../helpers/showError";
import spotifyApi from "../initializations/spotifyApi";
function useSpotifyApi() {
const {
access_token,
clientId,
clientSecret,
isLoggedIn,
setAccess_token,
} = useContext(authContext);
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
const { access_token, clientId, clientSecret, isLoggedIn, setAccess_token } =
useContext(authContext);
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
useEffect(() => {
if (isLoggedIn && clientId && clientSecret && refreshToken) {
spotifyApi.setClientId(clientId);
spotifyApi.setClientSecret(clientSecret);
spotifyApi.setRefreshToken(refreshToken);
if (!access_token) {
spotifyApi
.refreshAccessToken()
.then((token) => {
setAccess_token(token.body.access_token);
})
.catch((error) => {
showError(error);
});
}
spotifyApi.setAccessToken(access_token);
}
}, [access_token, clientId, clientSecret, isLoggedIn]);
useEffect(() => {
if (isLoggedIn && clientId && clientSecret && refreshToken) {
spotifyApi.setClientId(clientId);
spotifyApi.setClientSecret(clientSecret);
spotifyApi.setRefreshToken(refreshToken);
if (!access_token) {
spotifyApi
.refreshAccessToken()
.then((token) => {
setAccess_token(token.body.access_token);
})
.catch((error) => {
showError(error);
});
}
spotifyApi.setAccessToken(access_token);
}
}, [access_token, clientId, clientSecret, isLoggedIn]);
return spotifyApi;
return spotifyApi;
}
export default useSpotifyApi;

View File

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

View File

@ -1,28 +1,41 @@
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 useSpotifyApi from "./useSpotifyApi";
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>(
queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>,
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>,
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> {
const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, (pageArgs) => queryHandler(spotifyApi, pageArgs), options);
const { isError, error } = query;
const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(
queryKey,
(pageArgs) => queryHandler(spotifyApi, pageArgs),
options,
);
const { isError, error } = query;
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
return query;
return query;
}
export default useSpotifyInfiniteQuery;

View File

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

View File

@ -7,23 +7,26 @@ import useSpotifyApiError from "./useSpotifyApiError";
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
function useSpotifyQuery<TQueryData = unknown>(
queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>,
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
queryKey: QueryKey,
queryHandler: SpotifyQueryFn<TQueryData>,
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, ()=>queryHandler(spotifyApi), options);
const { isError, error } = query;
const spotifyApi = useSpotifyApi();
const handleSpotifyError = useSpotifyApiError(spotifyApi);
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(
queryKey,
() => queryHandler(spotifyApi),
options,
);
const { isError, error } = query;
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
return query;
return query;
}
export default useSpotifyQuery;

View File

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

View File

@ -1,18 +1,18 @@
import _play from "../assets/play-solid.svg";
import _pause from "../assets/pause-solid.svg"
import _angleLeft from "../assets/angle-left-solid.svg"
import _backward from "../assets/backward-solid.svg"
import _forward from "../assets/forward-solid.svg"
import _heartRegular from "../assets/heart-regular.svg"
import _heart from "../assets/heart-solid.svg"
import _random from "../assets/random-solid.svg"
import _stop from "../assets/stop-solid.svg"
import _pause from "../assets/pause-solid.svg";
import _angleLeft from "../assets/angle-left-solid.svg";
import _backward from "../assets/backward-solid.svg";
import _forward from "../assets/forward-solid.svg";
import _heartRegular from "../assets/heart-regular.svg";
import _heart from "../assets/heart-solid.svg";
import _random from "../assets/random-solid.svg";
import _stop from "../assets/stop-solid.svg";
import _search from "../assets/search-solid.svg";
import _loadingSpinner from "../assets/loading-spinner.gif";
import _settingsCog from "../assets/setting-cog.svg"
import _times from "../assets/times-solid.svg"
import _musicNode from "../assets/music-solid.svg"
import _download from "../assets/download-solid.svg"
import _settingsCog from "../assets/setting-cog.svg";
import _times from "../assets/times-solid.svg";
import _musicNode from "../assets/music-solid.svg";
import _download from "../assets/download-solid.svg";
export const play = _play;
export const pause = _pause;

View File

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

View File

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

File diff suppressed because it is too large Load Diff