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,7 +23,9 @@ 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;
@ -77,30 +86,53 @@ function RootApp() {
useEffect(() => {
// saving changed credentials to storage
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
if (credentials.clientId && credentials.clientSecret && !localStorage.getItem(LocalStorageKeys.refresh_token)) {
if (
credentials.clientId &&
credentials.clientSecret &&
!localStorage.getItem(LocalStorageKeys.refresh_token)
) {
const app = express();
app.use(express.json());
app.get<null, null, null, { code: string }>("/auth/spotify/callback", async (req, res) => {
app.get<null, null, null, { code: string }>(
"/auth/spotify/callback",
async (req, res) => {
try {
spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret);
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
const { body: authRes } = await spotifyApi.authorizationCodeGrant(
req.query.code,
);
setAccess_token(authRes.access_token);
localStorage.setItem(LocalStorageKeys.refresh_token, authRes.refresh_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)
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 () => {
@ -118,7 +150,9 @@ function RootApp() {
useEffect(() => {
const onWindowClose = () => {
if (audioPlayer.isRunning()) {
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e));
audioPlayer
.stop()
.catch((e) => console.error("Failed to quit MPV player: ", e));
}
};
@ -144,7 +178,11 @@ function RootApp() {
async function spaceAction() {
try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause();
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isPaused())
? await audioPlayer.play()
: await audioPlayer.pause();
console.log("You pressed SPACE");
} catch (error) {
showError(error, "[Failed to play/pause audioPlayer]: ");
@ -152,7 +190,10 @@ function RootApp() {
}
async function rightAction() {
try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5));
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(+5));
console.log("You pressed RIGHT");
} catch (error) {
showError(error, "[Failed to seek audioPlayer]: ");
@ -160,7 +201,10 @@ function RootApp() {
}
async function leftAction() {
try {
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5));
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(-5));
console.log("You pressed LEFT");
} catch (error) {
showError(error, "[Failed to seek audioPlayer]: ");
@ -183,13 +227,39 @@ function RootApp() {
});
return (
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
<Window
ref={windowRef}
windowState={WindowState.WindowMaximized}
windowIcon={winIcon}
windowTitle="Spotube"
minSize={minSize}
>
<MemoryRouter>
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}>
<preferencesContext.Provider value={{ ...preferences, setPreferences }}>
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
<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';`}>
<View
style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}
>
<Routes />
{isLoggedIn && <Player />}
</View>

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

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

View File

@ -7,7 +7,13 @@ import IconButton from "./shared/IconButton";
function BackButton(): ReactElement {
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

@ -8,13 +8,15 @@ function CurrentPlaylist() {
const { currentPlaylist, currentTrack } = useContext(playerContext);
if (!currentPlaylist && !currentTrack) {
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
return (
<Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>
);
}
if (currentTrack && !currentPlaylist) {
<View style="flex: 1;">
<TrackButton track={currentTrack} index={0}/>
</View>
<TrackButton track={currentTrack} index={0} />
</View>;
}
return (
@ -24,7 +26,13 @@ function CurrentPlaylist() {
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
<View style={`flex-direction:column; flex: 1;`}>
{currentPlaylist?.tracks.map(({ track }, index) => {
return <TrackButton key={index + track.id} track={track} index={index} />;
return (
<TrackButton
key={index + track.id}
track={track}
index={index}
/>
);
})}
</View>
</ScrollArea>

View File

@ -7,16 +7,27 @@ 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>>(
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),
(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
@ -27,11 +38,29 @@ function Home() {
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} />
<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} />;
return (
<CategoryCard
key={index + category.id}
id={category.id}
name={category.name}
/>
);
})}
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />}
{hasNextPage && (
<Button
on={{ clicked: () => fetchNextPage() }}
text="Load More"
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
);
@ -45,7 +74,9 @@ interface CategoryCardProps {
}
const CategoryCard = ({ id, name }: CategoryCardProps) => {
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
const { data: playlists, isError } = useSpotifyQuery<
SpotifyApi.PlaylistObjectSimplified[]
>(
[QueryCacheKeys.categoryPlaylists, id],
async (spotifyApi) => {
const option = { limit: 4 };
@ -57,8 +88,15 @@ const CategoryCard = ({ id, name }: CategoryCardProps) => {
}
return res.body.playlists.items;
},
{ initialData: [] }
{ initialData: [] },
);
return <CategoryCardView url={`/genre/playlists/${id}`} isError={isError} name={name} playlists={playlists ?? []} />;
return (
<CategoryCardView
url={`/genre/playlists/${id}`}
isError={isError}
name={name}
playlists={playlists ?? []}
/>
);
};

View File

@ -1,4 +1,5 @@
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
import { CursorShape } from "@nodegui/nodegui";
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React, { useContext } from "react";
import { Redirect, Route } from "react-router";
import { QueryCacheKeys } from "../conf";
@ -6,6 +7,7 @@ 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";
@ -17,6 +19,7 @@ function Library() {
<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 />
@ -24,6 +27,9 @@ function Library() {
<Route exact path="/library/playlists">
<UserPlaylists />
</Route>
<Route exact path="/library/followed-artists">
<FollowedArtists />
</Route>
</View>
);
}
@ -31,10 +37,20 @@ function Library() {
export default Library;
function UserPlaylists() {
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
const {
data: userPagedPlaylists,
isError,
isLoading,
refetch,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
QueryCacheKeys.userPlaylists,
(spotifyApi, { pageParam }) =>
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => {
spotifyApi
.getUserPlaylists({ limit: 20, offset: pageParam })
.then((userPlaylists) => {
return userPlaylists.body;
}),
{
@ -43,7 +59,7 @@ function UserPlaylists() {
return lastPage.offset + lastPage.limit;
}
},
}
},
);
const userPlaylists = userPagedPlaylists?.pages
@ -59,25 +75,37 @@ function UserPlaylists() {
playlists={userPlaylists ?? []}
isLoadable={!isFetchingNextPage}
refetch={refetch}
loadMore={hasNextPage ? ()=>fetchNextPage() : undefined}
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
/>
);
}
function UserSavedTracks() {
const userSavedPlaylistId = "user-saved-tracks";
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
const {
data: userSavedTracks,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
QueryCacheKeys.userSavedTracks,
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body),
(spotifyApi, { pageParam }) =>
spotifyApi
.getMySavedTracks({ limit: 50, offset: pageParam })
.then((res) => res.body),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.offset + lastPage.limit;
}
},
}
},
);
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext);
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } =
useContext(playerContext);
const userTracks = userSavedTracks?.pages
?.map((page) => page.items)
@ -86,7 +114,13 @@ function UserSavedTracks() {
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 });
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);
@ -111,7 +145,15 @@ function UserSavedTracks() {
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 } },
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",
@ -119,12 +161,30 @@ function UserSavedTracks() {
};
return (
<View style="flex: 1; flex-direction: 'column';">
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
<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} />)}
<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';"
@ -142,3 +202,84 @@ function UserSavedTracks() {
</View>
);
}
function FollowedArtists() {
const {
data: pagedFollowedArtists,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<
SpotifyApi.CursorBasedPagingObject<SpotifyApi.ArtistObjectFull>
>(
QueryCacheKeys.followedArtists,
(spotifyApi, { pageParam }) =>
spotifyApi
.getFollowedArtists({ limit: 50, after: pageParam })
.then((res) => res.body.artists),
{
getNextPageParam(lastPage) {
if (lastPage.next) {
return lastPage.cursors.after + lastPage.limit;
}
},
},
);
const followedArtists = pagedFollowedArtists?.pages
?.map((page) => page.items)
.filter(Boolean)
.flat(1) as SpotifyApi.ArtistObjectFull[] | undefined;
return (
<ScrollArea style="min-height: 750px; max-height: 1980px; max-width: 1980px; min-width: 700px; border: none;">
<View style="flex: 1; flex-direction: 'row'; flex-wrap: wrap; width: 330px;">
<PlaceholderApplet
error={isError}
loading={isLoading || isFetchingNextPage}
message="Failed querying spotify"
reload={refetch}
/>
{followedArtists?.map((artist, index) => {
return <ArtistCard key={index + artist.id} artist={artist} />;
})}
{hasNextPage && (
<Button
style="flex-grow: 0; align-self: 'center';"
text="Load more"
on={{
clicked() {
fetchNextPage();
},
}}
enabled={!isFetchingNextPage}
/>
)}
</View>
</ScrollArea>
);
}
interface ArtistCardProps {
artist: SpotifyApi.ArtistObjectFull;
}
function ArtistCard({ artist }: ArtistCardProps) {
return (
<View style="max-width: 150px; max-height: 200px; flex-direction: 'column'; align-items: 'center'; margin: 5px 0;">
<CachedImage
cursor={CursorShape.PointingHandCursor}
maxSize={{ height: 150, width: 150 }}
scaledContents
alt={artist.name}
src={artist.images[0].url}
/>
<Text>{artist.name}</Text>
<Button cursor={CursorShape.PointingHandCursor} text="Follow" />
</View>
);
}

View File

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

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";
@ -23,16 +42,19 @@ export const audioPlayer = new NodeMpv(
// debug: true,
// verbose: true,
},
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"]
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"],
);
function Player(): ReactElement {
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } =
useContext(playerContext);
const { reactToTrack, isFavorite } = useTrackReaction();
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
const [isPaused, setIsPaused] = useState(true);
const [volume, setVolume] = useState<number>(() => (cachedVolume ? parseFloat(cachedVolume) : 55));
const [volume, setVolume] = useState<number>(() =>
cachedVolume ? parseFloat(cachedVolume) : 55,
);
const [totalDuration, setTotalDuration] = useState<number>(0);
const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
@ -51,7 +73,7 @@ function Player(): ReactElement {
localStorage.setItem(LocalStorageKeys.volume, volume.toString());
},
},
[volume]
[volume],
);
const playerRunning = audioPlayer.isRunning();
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
@ -87,8 +109,12 @@ function Player(): ReactElement {
// track change effect
useEffect(() => {
// caching current track
if(!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
else localStorage.setItem(LocalStorageKeys.cachedTrack, JSON.stringify(currentTrack));
if (!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
else
localStorage.setItem(
LocalStorageKeys.cachedTrack,
JSON.stringify(currentTrack),
);
(async () => {
try {
@ -114,8 +140,12 @@ function Player(): ReactElement {
useEffect(() => {
setShuffle(false);
// caching playlist
if(!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
else localStorage.setItem(LocalStorageKeys.cachedPlaylist, JSON.stringify(currentPlaylist));
if (!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
else
localStorage.setItem(
LocalStorageKeys.cachedPlaylist,
JSON.stringify(currentPlaylist),
);
}, [currentPlaylist]);
useEffect(() => {
@ -140,6 +170,7 @@ function Player(): ReactElement {
// 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);
@ -149,9 +180,17 @@ function Player(): ReactElement {
setIsStopped(true);
setIsPaused(true);
// go to next track
if (currentTrack && playlistTracksIds && currentPlaylist?.tracks.length !== 0) {
if (
currentTrack &&
playlistTracksIds &&
currentPlaylist?.tracks.length !== 0
) {
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
setCurrentTrack(currentPlaylist?.tracks[index > playlistTracksIds.length - 1 ? 0 : index].track);
setCurrentTrack(
currentPlaylist?.tracks[
index > playlistTracksIds.length - 1 ? 0 : index
].track,
);
}
};
const pauseListener = () => {
@ -192,7 +231,15 @@ function Player(): ReactElement {
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);
setCurrentTrack(
currentPlaylist.tracks[
index > playlistTracksIds?.length - 1
? 0
: index < 0
? playlistTracksIds.length - 1
: index
].track,
);
}
};
@ -216,29 +263,71 @@ function Player(): ReactElement {
<Text wordWrap openExternalLinks>
{artistsNames && currentTrack
? `
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${currentTrack.name}</a></b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "feat. " + artistsNames.slice(1).join(", ") : ""}</p>
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${
currentTrack.name
}</a></b> - ${artistsNames[0]} ${
artistsNames.length > 1
? "feat. " + artistsNames.slice(1).join(", ")
: ""
}</p>
`
: `<b>Oh, dear don't waste time</b>`}
</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.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 }} />
<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;" : ""}
style={
isActiveDownloading() && !isFinishedDownloading()
? "background-color: green;"
: ""
}
enabled={!!currentYtTrack}
icon={new QIcon(download)}
on={{
@ -251,18 +340,36 @@ function Player(): ReactElement {
on={{
clicked() {
if (currentTrack) {
reactToTrack({ added_at: Date.now().toString(), track: currentTrack });
reactToTrack({
added_at: Date.now().toString(),
track: currentTrack,
});
}
},
}}
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : heartRegular)}
icon={
new QIcon(
isFavorite(currentTrack?.id ?? "")
? heart
: heartRegular,
)
}
/>
<IconButton
style={openLyrics ? "background-color: green;" : ""}
icon={new QIcon(musicNode)}
on={{ clicked: () => currentTrack && setOpenLyrics(!openLyrics) }}
on={{
clicked: () => currentTrack && setOpenLyrics(!openLyrics),
}}
/>
<Slider
minSize={{ height: 20, width: 80 }}
maxSize={{ height: 20, width: 100 }}
hasTracking
sliderPosition={volume}
on={volumeHandler}
orientation={Orientation.Horizontal}
/>
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} />
</BoxView>
</GridColumn>
</GridRow>

View File

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

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";
@ -11,7 +12,17 @@ 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>>(
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 };
@ -29,7 +40,7 @@ function PlaylistGenreView() {
return lastPage.offset + lastPage.limit;
}
},
}
},
);
const playlists = pagedPlaylists?.pages
@ -59,10 +70,18 @@ interface GenreViewProps {
isLoadable?: boolean;
isError: boolean;
isLoading: boolean;
refetch: Function;
refetch: (options?: RefetchOptions | undefined) => Promise<unknown>;
}
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: GenreViewProps) {
export function GenreView({
heading,
playlists,
loadMore,
isLoadable,
isError,
isLoading,
refetch,
}: GenreViewProps) {
const playlistGenreViewStylesheet = `
#genre-container{
flex-direction: 'column';
@ -89,11 +108,24 @@ export function GenreView({ heading, playlists, loadMore, isLoadable, isError, i
<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`} />
<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} />;
return (
<PlaylistCard key={index + playlist.id} playlist={playlist} />
);
})}
{loadMore && <Button text="Load more" on={{ clicked: loadMore }} enabled={isLoadable} />}
{loadMore && (
<Button
text="Load more"
on={{ clicked: loadMore }}
enabled={isLoadable}
/>
)}
</View>
</ScrollArea>
</View>

View File

@ -20,17 +20,27 @@ export interface PlaylistTrackRes {
}
const PlaylistView: FC = () => {
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
useContext(playerContext);
const params = useParams<{ id: string }>();
const location = useLocation<{ name: string; thumbnail: string }>();
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) =>
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body)
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[]>(
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: [] }
(spotifyApi) =>
spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
{ initialData: [] },
);
const handlePlaylistPlayPause = () => {
@ -38,7 +48,9 @@ const PlaylistView: FC = () => {
setCurrentPlaylist({ ...params, ...location.state, tracks });
setCurrentTrack(tracks[0].track);
} else {
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
audioPlayer
.stop()
.catch((error) => console.error("Failed to stop audio player: ", error));
setCurrentTrack(undefined);
setCurrentPlaylist(undefined);
}
@ -52,14 +64,28 @@ const PlaylistView: FC = () => {
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>
<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`} />
<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 (
<TrackButton
key={index + track.id}
track={track}
index={index}
playlist={playlist}
/>
);
}
})}
</View>
@ -88,11 +114,21 @@ interface PlaylistSimpleControlsProps {
isFavorite?: boolean;
}
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) {
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 }} />}
{isFavorite !== undefined && (
<IconButton
icon={new QIcon(isFavorite ? heart : heartRegular)}
on={{ clicked: handlePlaylistReact }}
/>
)}
<IconButton
style={`background-color: #00be5f; color: white;`}
on={{

View File

@ -15,10 +15,18 @@ 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>(
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 }
(spotifyApi) =>
spotifyApi
.search(searchQuery, ["playlist", "track"], { limit: 4 })
.then((res) => res.body),
{ enabled: false },
);
async function handleSearch() {
@ -29,7 +37,14 @@ function Search() {
}
}
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
const placeholder = (
<PlaceholderApplet
error={isError}
loading={isLoading}
message="Failed querying spotify"
reload={refetch}
/>
);
return (
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
<View>
@ -40,16 +55,21 @@ function Search() {
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 }} />
<IconButton
enabled={searchQuery.length > 0}
icon={new QIcon(search)}
on={{ clicked: handleSearch }}
/>
</View>
<ScrollArea style="flex: 1;">
<View style="flex-direction: 'column'; flex: 1;">
@ -57,32 +77,51 @@ function Search() {
<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) {
if (
new QMouseEvent(native).button() === 1 &&
searchResults?.tracks
) {
history.push("/search/songs", { searchQuery });
}
},
}}>{`<h2>Songs</h2>`}</Text>
}}
>{`<h2>Songs</h2>`}</Text>
<TrackTableIndex />
{placeholder}
{searchResults?.tracks?.items.map((track, index) => (
<TrackButton key={index + track.id} index={index} track={track} />
<TrackButton
key={index + track.id}
index={index}
track={track}
/>
))}
</View>
<View 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 });
if (
new QMouseEvent(native).button() === 1 &&
searchResults?.playlists
) {
history.push("/search/playlists", {
searchQuery,
});
}
},
}}>{`<h2>Playlists</h2>`}</Text>
}}
>{`<h2>Playlists</h2>`}</Text>
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
{placeholder}
{searchResults?.playlists?.items.map((playlist, index) => (
<PlaylistCard key={index + playlist.id} playlist={playlist} />
<PlaylistCard
key={index + playlist.id}
playlist={playlist}
/>
))}
</View>
</View>

View File

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

View File

@ -9,16 +9,30 @@ import { TrackButton } from "./shared/TrackButton";
function SearchResultSongsCollection() {
const location = useLocation<{ searchQuery: string }>();
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
const {
data: searchResults,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
refetch,
} = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
QueryCacheKeys.searchSongs,
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
(spotifyApi, { pageParam }) =>
spotifyApi
.searchTracks(location.state.searchQuery, {
limit: 20,
offset: pageParam,
})
.then((res) => res.body),
{
getNextPageParam: (lastPage) => {
if (lastPage.tracks?.next) {
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
}
},
}
},
);
return (
<View style="flex: 1; flex-direction: 'column';">
@ -30,12 +44,26 @@ function SearchResultSongsCollection() {
<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} />
<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} />)}
.map(
(track, index) =>
track && (
<TrackButton
key={index + track.id}
index={index}
track={track}
/>
),
)}
{hasNextPage && (
<Button

View File

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

View File

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

View File

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

View File

@ -37,6 +37,7 @@ const categoryStylesheet = `
`;
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
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) {
@ -48,7 +49,12 @@ const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url })
}
return (
<View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
<Button
id="anchor-heading"
cursor={CursorShape.PointingHandCursor}
on={{ MouseButtonRelease: goToGenre }}
text={name}
/>
<View id="child-view">
{playlists.map((playlist, index) => {
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;

View File

@ -1,19 +1,19 @@
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 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);
@ -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,5 +1,5 @@
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";
@ -7,7 +7,7 @@ interface ErrorAppletProps {
error: boolean;
loading: boolean;
message?: string;
reload: Function;
reload: () => void;
helps?: boolean;
}

View File

@ -21,14 +21,20 @@ interface PlaylistCardProps {
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
const preferences = useContext(preferencesContext);
const thumbnail = playlist.images[0].url;
const { id, description, name, images } = playlist;
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), {
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 () => {
@ -47,6 +53,7 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function gotoPlaylist(native?: any) {
const key = new QMouseEvent(native);
if (key.button() === 1) {
@ -121,10 +128,22 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
on={{
MouseButtonRelease: gotoPlaylist,
...hovers,
}}>
{preferences.playlistImages && <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />}
}}
>
{preferences.playlistImages && (
<CachedImage
src={thumbnail}
maxSize={{ height: 150, width: 150 }}
scaledContents
alt={name}
/>
)}
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}>
<Text
style={`color: ${color};`}
wordWrap
on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}
>
{`
<center>
<h3>${name}</h3>
@ -133,11 +152,14 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
`}
</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 File

@ -27,8 +27,9 @@ function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
maxSize={{ width: 30, height: 20 }}
on={{
valueChanged(value) {
onChange && onChange(value===1);
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) {

View File

@ -9,7 +9,9 @@ import IconButton from "./IconButton";
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
follower?: SpotifyApi.FollowersObject;
tracks: SpotifyApi.PagingObject<SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject>;
tracks: SpotifyApi.PagingObject<
SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject
>;
}
export interface TrackButtonProps {
@ -20,10 +22,16 @@ export interface TrackButtonProps {
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
const { reactToTrack, isFavorite } = useTrackReaction();
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
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 };
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);
}
@ -34,18 +42,22 @@ export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) =>
};
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
const active = (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) || currentTrack?.id === track.id;
const active =
(currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) ||
currentTrack?.id === track.id;
return (
<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>

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";
@ -20,6 +20,7 @@ export enum QueryCacheKeys {
search = "search",
searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs",
followedArtists = "followed-artists",
}
export enum LocalStorageKeys {
@ -28,5 +29,5 @@ export enum LocalStorageKeys {
preferences = "user-preferences",
volume = "volume",
cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track"
cachedTrack = "cached-track",
}

View File

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

View File

@ -2,7 +2,12 @@ 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;
@ -11,6 +16,13 @@ export interface PlayerContext {
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

@ -9,7 +9,9 @@ export interface PreferencesContext extends PreferencesContextProperties {
const preferencesContext = React.createContext<PreferencesContext>({
playlistImages: false,
setPreferences() { }
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();
return adjustedColor.darken(0.5).hex();
}

View File

@ -1,36 +1,67 @@
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;
console.log(
"[lyric query]:",
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
);
lyrics = (
await axios.get<string>(
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
{ responseType: "text" },
)
).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (err) {
showError(err, "[Lyric Query Error]: ");
try {
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`);
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`)).data;
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)}+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;
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) {

View File

@ -15,7 +15,10 @@ interface ImageDimensions {
const fsm = fs.promises;
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> {
export async function getCachedImageBuffer(
name: string,
dims?: ImageDimensions,
): Promise<Buffer> {
try {
const MB_5 = 5000000; //5 Megabytes
const cacheImgFolder = path.join(cacheDir, "images");
@ -33,20 +36,34 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
// if the dimensions are changed then the previously cached
// images are removed and replaced with a new one
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) {
if (
dims &&
(cachedImgMeta.height !== dims.height ||
cachedImgMeta.width !== dims?.width)
) {
fs.unlinkSync(cacheImgPath);
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims });
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" });
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 });
return await imageResizeAndWrite(resImgBuf, {
cacheFolder: cacheImgFolder,
cacheName,
dims,
});
}
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
return resImgBuf;
@ -57,7 +74,14 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
}
}
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> {
async function imageResizeAndWrite(
img: Buffer,
{
cacheFolder,
cacheName,
dims,
}: { dims: ImageDimensions; cacheFolder: string; cacheName: string },
): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height)
// is available in the args
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);

View File

@ -10,7 +10,10 @@ import { CurrentTrack } from "../context/playerContext";
* @param {(Array<string | number>)} matches
* @return {*} {number}
*/
export function includePercentage(src: string | Array<string | number>, matches: Array<string | number>): number {
export function includePercentage(
src: string | Array<string | number>,
matches: Array<string | number>,
): number {
let count = 0;
matches.forEach((match) => {
if (src.includes(match.toString())) count++;
@ -22,27 +25,51 @@ export interface YoutubeTrack extends CurrentTrack {
youtube_uri: string;
}
export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promise<YoutubeTrack> {
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(" ")}` : ``}`;
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]);
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 };
const sameChannel =
video.channel.name.includes(artistsName[0]) ||
artistsName[0].includes(video.channel.name);
return {
url: `http://www.youtube.com/watch?v=${video.id}`,
matchPercentage,
sameChannel,
id: track.id,
};
})
.sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1));
const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel);
const rarestTrack = result.map((res) => ({ url: `http://www.youtube.com/watch?v=${res.id}`, id: res.id }));
const rarestTrack = result.map((res) => ({
url: `http://www.youtube.com/watch?v=${res.id}`,
id: res.id,
}));
const finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance.length > 0 ? tracksWithRelevance : rarestTrack)[0].url };
const finalTrack = {
...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);

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 seconds: number = parseInt(((ms % 60000) / 1000).toFixed(0));
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
}

View File

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

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

View File

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

View File

@ -5,17 +5,25 @@ 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) => {
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> {
function updateFunction(
playlist: SpotifyApi.PlaylistObjectSimplified,
old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>,
): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
const obj: typeof old = {
pageParams: old?.pageParams ?? [],
pages:
@ -25,22 +33,35 @@ function usePlaylistReaction() {
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,
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),
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));
queryClient.setQueryData<
InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>
>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
},
},
}
);
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);

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,13 +5,8 @@ import showError from "../helpers/showError";
import spotifyApi from "../initializations/spotifyApi";
function useSpotifyApi() {
const {
access_token,
clientId,
clientSecret,
isLoggedIn,
setAccess_token,
} = useContext(authContext);
const { access_token, clientId, clientSecret, isLoggedIn, setAccess_token } =
useContext(authContext);
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
useEffect(() => {

View File

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

View File

@ -1,19 +1,32 @@
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> = {}
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 query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(
queryKey,
(pageArgs) => queryHandler(spotifyApi, pageArgs),
options,
);
const { isError, error } = query;
useEffect(() => {

View File

@ -4,12 +4,21 @@ 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>) {
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 mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>(
(arg) => mutationFn(spotifyApi, arg),
options,
);
const { isError, error } = mutation;
useEffect(() => {

View File

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

View File

@ -5,40 +5,59 @@ 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 { 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> {
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 => {
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,
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),
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));
queryClient.setQueryData<
InfiniteData<SpotifyApi.UsersSavedTracksResponse>
>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old));
},
},
}
);
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);

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

@ -12,6 +12,7 @@ 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);
@ -42,6 +43,9 @@ function Routes() {
<Route path="/library">
<Library />
</Route>
<Route path="/artist">
<Artist />
</Route>
<Route exact path="/search">
<Search />
</Route>

File diff suppressed because it is too large Load Diff