All react-nodegui junk cleaned up

This commit is contained in:
Kingkor Roy Tirtho 2022-01-03 10:13:32 +06:00
parent ad8b55f1aa
commit 3068b4ca4c
60 changed files with 0 additions and 32890 deletions

View File

@ -1,6 +0,0 @@
node_modules
.git
.gitignore
.env
Dockerfile
docker-compose*

View File

@ -1,45 +0,0 @@
/**
* @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;

View File

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

182
README.md
View File

@ -1,182 +0,0 @@
![Spotube](assets/spotube_banner.svg)
Spotube is a [qt](https://qt.io) based lightweight spotify client which uses [nodegui/react-nodegui](https://github.com/nodegui/react-nodegui) as frontend & nodejs as backend. It utilizes the power of Spotify & Youtube's public API & creates a hazardless, performant & resource friendly User Experience
![Application Screenshot](assets/spotube-screenshot.png)
## Features
Following are the features that currently spotube offers:
- Open Source
- No telementry, diagnostics or user data collection
- Lightweight & resource friendly
- Near native performance & seemless with default desktop themes (Win10, Win7, OSX, QT-default)
- Playback control is on user's machine instead of server based
- Small size & less data hungry
- No spotify or youtube ads since it uses all public & free APIs (But it's recommended to support the creators by watching/liking/subscribing to the artists youtube channel or add as favourite track in spotify. Mostly buying spotify premium is the best way to support their valuable creations)
- Lyrics
- Downloadable track
## Requirements (Linux🐧 only)
Don't worry **spotify premium isn't required**😱. But some extra packages are required.
- [MPV](https://mpv.io/installation/) player for playing the actual audio
- [youtube-dl](https://github.com/ytdl-org/youtube-dl) for streaming the audio from youtube. It already comes pre bundled with mpv
> **Important for [Ubuntu/Debian]():** If you're using any **ubuntu/debian** based linux distro then **youtube-dl** installed from the typical **apt-get** repositories will most likely not work as that version is older than current release. So remove it & install from the repository manually
Remove the **youtube-dl** installed with **mpv** player or from **apt package manger**
```bash
$ sudo apt-get remove youtube-dl
```
Now, Install youtube-dl from
- official github repo: https://github.com/ytdl-org/youtube-dl#installation (recommended)
**or**
- snap installation
```bash
$ snap install youtube-dl
```
## Installation
I'm always releasing newer versions of binary of the software each 2-3 month with minor changes & each 6-8 month with major changes. Grab the binaries
All the binaries are located in the [releases](https://github.com/krtirtho/spotube/releases), just download
### Windows
Extract the **`Spotube-winx64-v<version>.zip`** & double click on **`install.bat`** & follow along the installer
### Linux
- **Ubuntu/Debian**
Make sure you've fulfilled all the requirements
```bash
sudo apt install spotube_linux-x86_64-v<version>.deb
or
sudo dpkg -i spotube_linux-x86_64-v<version>.deb
```
- **Others**: Navigate to the downloaded **`Spotube_linux-x86_64-v<version>.AppImage`**
file & double click to run it
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey stores or software centers or repositories**
## Configuration
There are some configurations that needs to be done to start using this software
You need a spotify account & a web app for
- clientId
- clientSecret
**Grab credentials:**
- Go to https://developer.spotify.com/dashboard/login & login with your spotify account (Skip if you're logged in)
![Step 1](https://user-images.githubusercontent.com/61944859/111762106-d1d37680-88ca-11eb-9884-ec7a40c0dd27.png)
- Create an web app for Spotify Public API
![step 2](https://user-images.githubusercontent.com/61944859/111762507-473f4700-88cb-11eb-91f3-d480e9584883.png)
- Give the app a name & description. Then Edit settings & add **http://localhost:4304/auth/spotify/callback** as **Redirect URI** for the app. Its important for authenticating
![setp-3](https://user-images.githubusercontent.com/61944859/111768971-d308a180-88d2-11eb-9108-3e7444cef049.png)
- Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields
![step-4](https://user-images.githubusercontent.com/61944859/111769501-7fe31e80-88d3-11eb-8fc1-f3655dbd4711.png)
> **Note!**: No personal data or any kind of sensitive information won't be collected from spotify. Don't believe? See the code for yourself
### Building from source
**nodegui/react-nodegui** requires following packages to run
- [CMake](https://cmake.org/install/) 3.1 & up
- GCC v7
- Nodejs 12.x & up
**Windows Specific:**
- Visual Studio 2019
**MacOS & Linux specific:**
- Make
**Ubuntu/Debian based linux specific:**
Having `pkg-config build-essential mesa-common-dev libglu1-mesa-dev` is important
```bash
$ sudo apt-get install pkg-config build-essential mesa-common-dev libglu1-mesa-dev
```
After having this dependencies set up run following commands:
```bash
$ git clone https://github.com/KRTirtho/spotube.git
$ cd spotube
$ npm install
```
Now start building:
```bash
$ npm run build
$ npm run pack
```
Go to built package directory replace `os-name` with `linux`|`win32`|`darwin`:
```bash
$ cd deploy/<os-name>/build/spotube
```
If everything went smoothly then double clicking on the
- `AppRun` or Spotube-x86_64.AppImage for **linux**
- qode.exe for **Windows**
- Spotube-x86_64.dmg for **MacOS**
should work just fine without any problem
### Development
Follow the **Build from Source** guideline till `npm install`
Now, to start the dev server run the command in one terminal:
```bash
$ npm run dev
```
To start the application in development environment run following command in another terminal keeping the dev server running:
```bash
$ npm start
```
## Known Issues
There will be some glitches, lags & stuck motions because of the library Spotube is currently using under the hood. It has some issues with layouts thus sometimes some contents aren't shown or overflows out of the window. But resizing the window would fix this issue. Soon there will be some updates fixing this sort of layout related problems
## TODO:
- [ ] Compile, Debug & Build for **MacOS**
- [x] Add support for show Lyric of currently playing track
- [x] Track download
- [x] Cached playback
- [ ] Support for playing/streaming podcasts/shows
- [x] Easy installation procedure/mechanism for simplicity in Windows
- [ ] Artist, User & Album pages
## Things that don't work
- Shows & Podcasts aren't supported as it'd require premium anyway
- Beautiful UI (you missed it, see the title😂👆)
- OS Media Controls
#### Social handlers
Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about this application

6
assets.d.ts vendored
View File

@ -1,6 +0,0 @@
declare module "*.svg";
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.bmp";

View File

@ -1,42 +0,0 @@
import { build } from "esbuild";
import { mkdir, copyFile } from "fs/promises";
import { join } from "path";
build({
bundle: true,
outdir: "./dist",
minify: false,
platform: 'node',
target: "es6",
color: true,
jsx: "transform",
sourcemap: true,
tsconfig: "./tsconfig.json",
entryPoints: ["src/index.tsx"],
target: ['node12'],
loader: {
".png": "file",
".jpg": "file",
".gif": "file",
".svg": "file",
".node": "file"
},
plugins: [
{
name: "Native Addon",
setup(build) {
build.onResolve({ filter: /\.node$/ }, async (arg) => {
const pluginName = arg.path.split("/").reverse()[0]
mkdir(join(process.cwd(), "dist"), {recursive: true})
await copyFile(join(arg.resolveDir, arg.path), join(process.cwd(), "dist", pluginName))
return {
external: true,
path: "./"+pluginName,
namespace: "js",
pluginName
}
})
}
},
]
}).catch(() => process.exit(1));

17741
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,87 +0,0 @@
{
"name": "spotube",
"version": "0.0.3",
"main": "index.js",
"author": "KR Tirtho",
"license": "MIT",
"private": true,
"scripts": {
"build": "webpack --mode=production",
"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\"",
"debug-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 debug:watch\" \"npm run check-types\"",
"start:trace": "qode ./dist/index.js --trace",
"debug": "cd dist && qode --inspect index.js",
"debug:watch": "nodemon -w dist -e js -x \"npm run debug\"",
"pack": "nodegui-packer -p ./dist",
"pack-deb": "node scripts/build-deb.js",
"pack-win32": "powershell.exe -ExecutionPolicy Unrestricted -Command \". '.\\scripts\\build-win32.ps1'\""
},
"dependencies": {
"@nodegui/nodegui": "0.27.0",
"@nodegui/react-nodegui": "0.10.0",
"axios": "^0.21.1",
"base64url": "^3.0.1",
"chalk": "^4.1.0",
"color": "^3.1.3",
"dotenv": "^8.2.0",
"du": "^1.0.0",
"express": "^4.17.1",
"html-to-text": "^7.0.0",
"is-url": "^1.2.4",
"jimp": "^0.16.1",
"node-localstorage": "^2.1.6",
"node-mpv": "^2.0.0-beta.2",
"open": "^7.4.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-query": "^3.13.0",
"react-router": "^5.2.0",
"spotify-web-api-node": "^5.0.2",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"youtubei": "^0.0.1-rc.27",
"ytdl-core": "^4.5.0"
},
"devDependencies": {
"@nodegui/devtools": "^1.0.1",
"@nodegui/packer": "^1.5.0",
"@types/color": "^3.0.1",
"@types/du": "^1.0.0",
"@types/express": "^4.17.11",
"@types/html-to-text": "^6.0.0",
"@types/is-url": "^1.2.28",
"@types/node": "^14.11.1",
"@types/node-localstorage": "^1.3.0",
"@types/react": "^17.0.2",
"@types/react-router": "^5.1.11",
"@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",
"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,283 +0,0 @@
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 { MemoryRouter } from "react-router";
import Routes from "./routes";
import { LocalStorage } from "node-localstorage";
import authContext from "./context/authContext";
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
import Player, { audioPlayer } from "./components/Player";
import { QueryClient, QueryClientProvider } from "react-query";
import express from "express";
import open from "open";
import spotifyApi from "./initializations/spotifyApi";
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 { useLogger } from "./hooks/useLogger";
import { Logger } from "./initializations/logger";
export interface Credentials {
clientId: string;
clientSecret: string;
}
const minSize = { width: 700, height: 750 };
const winIcon = new QIcon(spotubeIcon);
const localStorageDir = path.join(confDir, "local");
fs.mkdirSync(localStorageDir, { recursive: true });
global.localStorage = new LocalStorage(localStorageDir);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError(error: any) {
const logger = new Logger(QueryClient.name);
logger.error(error);
},
},
},
});
const initialPreferences: PreferencesContextProperties = {
playlistImages: false,
};
const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
//* Application start
function RootApp() {
const logger = useLogger(RootApp.name);
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: any) {
logger.error("Failed to fullfil code grant flow", error);
}
},
);
const server = app.listen(4304, () => {
logger.info("Server is running");
spotifyApi.setClientId(credentials.clientId);
spotifyApi.setClientSecret(credentials.clientSecret);
open(
spotifyApi.createAuthorizeURL(
[
"user-follow-read",
"user-library-modify",
"user-library-read",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read-private",
],
"xxxyyysssddd",
),
).catch((e) =>
logger.error("Opening IPC connection with browser failed", e),
);
});
return () => {
server.close(() => logger.info("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) => logger.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();
logger.info("You pressed SPACE");
} catch (error: any) {
logger.error("Failed to play/pause audioPlayer", error);
}
}
async function rightAction() {
try {
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(+5));
logger.info("You pressed RIGHT");
} catch (error: any) {
logger.error("Failed to seek audioPlayer", error);
}
}
async function leftAction() {
try {
currentTrack &&
audioPlayer.isRunning() &&
(await audioPlayer.isSeekable()) &&
(await audioPlayer.seek(-5));
logger.info("You pressed LEFT");
} catch (error: any) {
logger.error("Failed to seek audioPlayer", error);
}
}
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>
);
}
class App extends React.Component {
render() {
return <RootApp />;
}
}
export default hot(App);

View File

@ -1,10 +0,0 @@
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

@ -1,19 +0,0 @@
import { QIcon } from "@nodegui/nodegui";
import React, { ReactElement } from "react";
import { useHistory } from "react-router";
import { angleLeft } from "../icons";
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() }}
/>
);
}
export default BackButton;

View File

@ -1,43 +0,0 @@
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React, { useContext } from "react";
import playerContext from "../context/playerContext";
import { TrackTableIndex } from "./PlaylistView";
import { TrackButton } from "./shared/TrackButton";
function CurrentPlaylist() {
const { currentPlaylist, currentTrack } = useContext(playerContext);
if (!currentPlaylist && !currentTrack) {
return (
<Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>
);
}
if (currentTrack && !currentPlaylist) {
<View style="flex: 1;">
<TrackButton track={currentTrack} index={0} />
</View>;
}
return (
<View style="flex: 1; flex-direction: 'column';">
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
<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>
);
}
export default CurrentPlaylist;

View File

@ -1,102 +0,0 @@
import React from "react";
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
import { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import PlaceholderApplet from "./shared/PlaceholderApplet";
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 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;
}
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: [] },
);
return (
<CategoryCardView
url={`/genre/playlists/${id}`}
isError={isError}
name={name}
playlists={playlists ?? []}
/>
);
};

View File

@ -1,285 +0,0 @@
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";
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" />
<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 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}
/>
);
}
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 { 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}
/>
)}
</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

@ -1,65 +0,0 @@
import React, { useContext, useState } from "react";
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 [touched, setTouched] = useState({
clientId: false,
clientSecret: false,
});
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 });
}
}
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,99 +0,0 @@
import {
FlexLayout,
QDialog,
QLabel,
QPushButton,
QScrollArea,
QWidget,
TextFormat,
} from "@nodegui/nodegui";
import React, { PropsWithChildren, useEffect, useState } from "react";
import fetchLyrics from "../helpers/fetchLyrics";
import { useLogger } from "../hooks/useLogger";
interface ManualLyricDialogProps extends PropsWithChildren<unknown> {
open: boolean;
onClose?: (closed: boolean) => void;
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
}
function ManualLyricDialog({ open, track }: ManualLyricDialogProps) {
const logger = useLogger(ManualLyricDialog.name);
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);
logger.info("lyrics", lyrics);
setLyrics(lyrics);
setLyricNotFound(lyrics === "Not Found");
} catch (error: any) {
logger.error(`Finding lyrics for ${track.name} failed`, error);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
}
}
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);
// 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) => {
logger.error(`Finding lyrics for ${track.name} failed `, e);
setLyrics("No lyrics found, rare track :)");
setLyricNotFound(true);
});
return () => {
retryButton.removeEventListener("clicked", handleBtnClick);
dialog.hide();
};
}, [open, track, lyrics]);
useEffect(() => {
retryButton.setEnabled(!lyricNotFound);
}, [lyricNotFound]);
return <></>;
}
export default ManualLyricDialog;

View File

@ -1,483 +0,0 @@
import { Orientation, QAbstractSliderSignals, QIcon } from "@nodegui/nodegui";
import { Slider, Text, useEventHandler, View } 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 IconButton from "./shared/IconButton";
import useTrackReaction from "../hooks/useTrackReaction";
import ManualLyricDialog from "./ManualLyricDialog";
import { LocalStorageKeys } from "../conf";
import useDownloadQueue from "../hooks/useDownloadQueue";
import { useLogger } from "../hooks/useLogger";
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"],
);
function Player(): ReactElement {
const logger = useLogger(Player.name);
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 [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);
// initial Effect
useEffect(() => {
(async () => {
try {
if (!playerRunning) {
await audioPlayer.start();
await audioPlayer.volume(volume);
}
} catch (error: any) {
logger.error("Failed starting audio player", error);
}
})().then(() => {
if (cachedPlaylist && !currentPlaylist) {
setCurrentPlaylist(JSON.parse(cachedPlaylist));
}
if (cachedTrack && !currentTrack) {
setCurrentTrack(JSON.parse(cachedTrack));
}
});
return () => {
if (playerRunning) {
audioPlayer.quit().catch((e: any) => logger.error(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: any) {
if (error.errcode !== 5) {
setIsStopped(true);
setIsPaused(true);
}
logger.error("Failure at track change", error);
}
})();
}, [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);
};
}
});
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: any) {
logger.error("Track control failed", error);
}
};
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: any) {
logger.error("Failed at audio-player stop", error);
}
}
const artistsNames = currentTrack?.artists?.map((x) => x.name);
return (
<View
enabled={!!currentTrack}
style="max-height: 120px; flex-direction: row; width:100%; flex: 1"
>
{/* title box */}
<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>
{/* player control & progressbar */}
<View>
{/* progressbar */}
<View>
{currentTrack && (
<ManualLyricDialog open={openLyrics} track={currentTrack} />
)}
<PlayerProgressBar
audioPlayer={audioPlayer}
totalDuration={totalDuration}
/>
</View>
<View style="flex-direction: row">
<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 }} />
</View>
</View>
{/* track reactions & features */}
<View style="flex-direction: row">
<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}
/>
</View>
</View>
);
}
export default Player;
// 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}
// />
// <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>
// );

View File

@ -1,78 +0,0 @@
import { Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
import { Slider, Text, useEventHandler, View } from "@nodegui/react-nodegui";
import NodeMpv from "node-mpv";
import React, { useContext, useEffect, useState } from "react";
import playerContext from "../context/playerContext";
import { useLogger } from "../hooks/useLogger";
interface PlayerProgressBarProps {
audioPlayer: NodeMpv;
totalDuration: number;
}
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
const logger = useLogger(PlayerProgressBar.name);
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: any) {
logger.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);
const containerStyle = `
padding: 20px 0px;
flex-direction: row
`;
return (
<View style={containerStyle}>
<Slider
enabled={!!currentTrack || trackTime > 0}
on={trackSliderEvents}
sliderPosition={playbackPercentage}
hasTracking
orientation={Orientation.Horizontal}
/>
<Text>{playbackTime}</Text>
</View>
);
}
export default PlayerProgressBar;

View File

@ -1,133 +0,0 @@
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";
import BackButton from "./BackButton";
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 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}
/>
);
}
export default PlaylistGenreView;
interface GenreViewProps {
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 = `
#genre-container{
flex-direction: 'column';
flex: 1;
}
#heading {
padding: 10px;
}
#scroll-view{
flex-grow: 1;
border: none;
}
#child-container {
flex-direction: "row";
justify-content: "space-evenly";
align-items: 'center';
flex-wrap: "wrap";
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}
/>
)}
</View>
</ScrollArea>
</View>
);
}

View File

@ -1,146 +0,0 @@
import React, { FC, useContext } from "react";
import { View, ScrollArea, Text } from "@nodegui/react-nodegui";
import BackButton from "./BackButton";
import { useLocation, useParams } from "react-router";
import { QIcon } from "@nodegui/nodegui";
import playerContext from "../context/playerContext";
import IconButton from "./shared/IconButton";
import { heart, heartRegular, play, stop } from "../icons";
import { audioPlayer } from "./Player";
import { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import usePlaylistReaction from "../hooks/usePlaylistReaction";
import { TrackButton } from "./shared/TrackButton";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import { useLogger } from "../hooks/useLogger";
export interface PlaylistTrackRes {
name: string;
artists: string;
url: string;
}
const PlaylistView: FC = () => {
const logger = useLogger(PlaylistView.name);
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) => logger.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}
/>
);
}
})}
</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>
);
}
interface PlaylistSimpleControlsProps {
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>
);
}

View File

@ -1,135 +0,0 @@
import { CursorShape, QIcon, QKeyEvent, QMouseEvent } from "@nodegui/nodegui";
import { LineEdit, ScrollArea, Text, View } from "@nodegui/react-nodegui";
import React, { useState } from "react";
import { useHistory } from "react-router";
import { QueryCacheKeys } from "../conf";
import { useLogger } from "../hooks/useLogger";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import { search } from "../icons";
import { TrackTableIndex } from "./PlaylistView";
import IconButton from "./shared/IconButton";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
import { TrackButton } from "./shared/TrackButton";
function Search() {
const logger = useLogger(Search.name);
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: any) {
logger.error("Failed to search through spotify", error);
}
}
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);
},
// 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>
<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>
);
}
export default Search;

View File

@ -1,55 +0,0 @@
import React from "react";
import { useLocation } from "react-router";
import { QueryCacheKeys } from "../conf";
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}
/>
);
}
export default SearchResultPlaylistCollection;

View File

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

View File

@ -1,51 +0,0 @@
import { Text, View } from "@nodegui/react-nodegui";
import React, { useContext } from "react";
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>
);
}
export default Settings;
interface SettingsCheckTileProps {
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>
{`
<b>${title}</b>
<p>${subtitle}</p>
`}
</Text>
<Switch checked={checked} onChange={onChange} />
</View>
);
}

View File

@ -1,77 +0,0 @@
import React from "react";
import { View, Button, Text } from "@nodegui/react-nodegui";
import { useHistory, useLocation } from "react-router";
import { settingsCog } from "../icons";
import IconButton from "./shared/IconButton";
import { QIcon } from "@nodegui/nodegui";
function TabMenu() {
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>
);
}
export const tabBarStylesheet = `
#tabmenu{
padding: 10px;
flex-direction: 'row';
justify-content: 'space-around';
}
#tabmenu-item:hover{
color: green;
}
#tabmenu-active-item{
background-color: green;
color: white;
}
`;
export default TabMenu;
export interface TabMenuItemProps {
title: string;
url: string;
/**
* path to the icon in string
*/
icon?: string;
}
export function TabMenuItem({ title, url }: TabMenuItemProps) {
const location = useLocation();
const history = useHistory();
function clicked() {
history.push(url);
}
return (
<Button
on={{ clicked }}
id={
location.pathname.replace("/", " ").startsWith(url.replace("/", " "))
? "tabmenu-active-item"
: `tabmenu-item`
}
text={title}
/>
);
}

View File

@ -1,61 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Text, View } from "@nodegui/react-nodegui";
import { QLabel, QPixmap } from "@nodegui/nodegui";
import { ImageProps } from "@nodegui/react-nodegui/dist/components/Image/RNImage";
import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
import { useLogger } from "../../hooks/useLogger";
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
src: string;
alt?: string;
}
function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
const logger = useLogger(CachedImage.name);
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);
logger.error("Cached Image Error", error);
});
}
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]);
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

@ -1,67 +0,0 @@
import { QMouseEvent, CursorShape } from "@nodegui/nodegui";
import { View, Button } from "@nodegui/react-nodegui";
import React, { FC } from "react";
import { useHistory } from "react-router";
import PlaylistCard from "./PlaylistCard";
interface CategoryCardProps {
url: string;
name: string;
isError: boolean;
playlists: SpotifyApi.PlaylistObjectSimplified[];
}
const categoryStylesheet = `
#container{
flex: 1;
flex-direction: 'column';
justify-content: 'center';
}
#anchor-heading{
background: transparent;
padding: 10px;
border: none;
outline: none;
font-size: 20px;
font-weight: bold;
align-self: flex-start;
}
#child-view{
flex: 1;
}
#anchor-heading:hover{
border: none;
outline: none;
text-decoration: underline;
}
`;
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) {
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>
);
};
export default CategoryCard;

View File

@ -1,46 +0,0 @@
import React, { useEffect, useRef } from "react";
import { QGraphicsDropShadowEffect, QPushButton } from "@nodegui/nodegui";
import { Button } from "@nodegui/react-nodegui";
import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton";
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 = `
#icon-btn{
background-color: rgba(255, 255, 255, 0.055);
border-width: 1px;
border-style: solid;
border-color: transparent;
border-radius: ${((props.maxSize ?? props.size)?.width ?? 30) / 2}px;
${style ?? ``}
}
#icon-btn:hover{
border-color: green;
}
#icon-btn:pressed{
border-style: groove;
background-color: #1cca1c;
}
`;
return (
<Button
ref={iconBtnRef}
id="icon-btn"
size={{ height: 30, width: 30, fixed: true }}
styleSheet={iconButtonStyleSheet}
{...props}
/>
);
}
export default IconButton;

View File

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

View File

@ -1,168 +0,0 @@
import { CursorShape, QIcon, QMouseEvent } from "@nodegui/nodegui";
import { Text, View } from "@nodegui/react-nodegui";
import React, { useContext, useMemo, useState } from "react";
import { useHistory } from "react-router";
import { QueryCacheKeys } from "../../conf";
import playerContext from "../../context/playerContext";
import preferencesContext from "../../context/preferencesContext";
import { generateRandomColor, getDarkenForeground } from "../../helpers/RandomColor";
import { useLogger } from "../../hooks/useLogger";
import usePlaylistReaction from "../../hooks/usePlaylistReaction";
import useSpotifyQuery from "../../hooks/useSpotifyQuery";
import { heart, heartRegular, pause, play } from "../../icons";
import { audioPlayer } from "../Player";
import CachedImage from "./CachedImage";
import IconButton from "./IconButton";
interface PlaylistCardProps {
playlist: SpotifyApi.PlaylistObjectSimplified;
}
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
const logger = useLogger(PlaylistCard.name);
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: any) {
logger.error("Failed adding playlist to queue", error);
}
};
// 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 });
}
}
const bgColor1 = useMemo(() => generateRandomColor(), []);
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
const playlistStyleSheet = `
#playlist-container, #img-container{
width: 150px;
padding: 10px;
margin: 5px;
flex-direction: column;
background-color: ${bgColor1};
}
#playlist-container{
border-radius: 5px;
min-height: 150px;
}
#playlist-container:hover{
border: 1px solid green;
}
#playlist-container:clicked{
border: 5px solid green;
}
`;
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);
},
};
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 }}
>
{`
<center>
<h3>${name}</h3>
<p>${description}</p>
</center>
`}
</Text>
{(hovered || currentPlaylist?.id === id) &&
!preferences.playlistImages &&
playlistActions}
{preferences.playlistImages && (
<View style="flex: 1; justify-content: 'space-around';">
{playlistActions}
</View>
)}
</View>
);
};
export default PlaylistCard;

View File

@ -1,45 +0,0 @@
import { Orientation, QMouseEvent } from "@nodegui/nodegui";
import { Slider } from "@nodegui/react-nodegui";
import { CheckBoxProps } from "@nodegui/react-nodegui/dist/components/CheckBox/RNCheckBox";
import React, { useEffect, useState } from "react";
export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> {
onChange?(checked: boolean): void;
}
function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
const [checked, setChecked] = useState<boolean>(false);
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);
},
// 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

@ -1,98 +0,0 @@
import { QIcon, QMouseEvent } from "@nodegui/nodegui";
import { Text, View } from "@nodegui/react-nodegui";
import React, { FC, useContext, useMemo } from "react";
import playerContext from "../../context/playerContext";
import { msToMinAndSec } from "../../helpers/msToMin_sec";
import useTrackReaction from "../../hooks/useTrackReaction";
import { heart, heartRegular, pause, play } from "../../icons";
import IconButton from "./IconButton";
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
follower?: SpotifyApi.FollowersObject;
tracks: SpotifyApi.PagingObject<
SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject
>;
}
export interface TrackButtonProps {
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 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={{
// 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 = `
#active{
background-color: #34eb71;
color: #333;
}
#track-button:hover{
background-color: rgba(229, 224, 224, 0.48);
}
`;

View File

@ -1,33 +0,0 @@
import dotenv from "dotenv";
import { homedir } from "os";
import { join } from "path";
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";
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",
followedArtists = "followed-artists",
}
export enum LocalStorageKeys {
credentials = "credentials",
refresh_token = "refresh_token",
preferences = "user-preferences",
volume = "volume",
cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track",
}

View File

@ -1,30 +0,0 @@
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>>;
}
const authContext = React.createContext<AuthContext>({
isLoggedIn: false,
setIsLoggedIn() {
return;
},
access_token: "",
clientId: "",
clientSecret: "",
setCredentials() {
return;
},
setAccess_token() {
return;
},
});
export default authContext;

View File

@ -1,28 +0,0 @@
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 interface PlayerContext {
currentPlaylist?: CurrentPlaylist;
currentTrack?: CurrentTrack;
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
}
const playerContext = React.createContext<PlayerContext>({
setCurrentPlaylist() {
return;
},
setCurrentTrack() {
return;
},
});
export default playerContext;

View File

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

View File

@ -1,18 +0,0 @@
import color from "color";
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(0.5).hex();
}

View File

@ -1,82 +0,0 @@
import axios from "axios";
import htmlToText from "html-to-text";
import { Logger } from "../initializations/logger";
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=";
const logger = new Logger("FetchLyrics");
export default async function fetchLyrics(artists: string, title: string) {
let lyrics;
try {
logger.info(
"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: any) {
logger.error("Lyric Query Error", err);
try {
logger.info(
"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: any) {
logger.error("Lyric Query Error", err_1);
try {
logger.info(
"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: any) {
logger.error("Lyric Query Error", err_2);
try {
logger.info(
"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: any) {
logger.error("Lyric Query Error", err_3);
lyrics = "Not Found";
}
}
}
}
const rets = lyrics.split("\n");
let final = "";
for (const ret of rets) {
final = `${final}${htmlToText.htmlToText(ret)}\n`;
}
return final.trim();
}

View File

@ -1,94 +0,0 @@
import path from "path";
import isUrl from "is-url";
import fs from "fs";
import axios from "axios";
import { Stream } from "stream";
import { streamToBuffer } from "./streamToBuffer";
import Jimp from "jimp";
import du from "du";
import { cacheDir } from "../conf";
import { Logger } from "../initializations/logger";
interface ImageDimensions {
height: number;
width: number;
}
const fsm = fs.promises;
const logger = new Logger("GetCachedImageBuffer");
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;
}
} catch (error: any) {
logger.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;
}

View File

@ -1,85 +0,0 @@
import { CurrentTrack } from "../context/playerContext";
import { Client, SearchResult } from "youtubei";
import { Logger } from "../initializations/logger";
const youtube = new Client();
/**
* returns the percentage of matched elements of a certain array(src)
* which is determined by checking if a the element is available in the
* array or not
* @author KR Tirtho
* @param {(string | Array<string | number>)} src
* @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 interface YoutubeTrack extends CurrentTrack {
youtube_uri: string;
}
const logger = new Logger("GetYoutubeTrack");
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(" ")}` : ``
}`;
logger.info(`Youtube Query String: ${queryString}`);
const result = (await youtube.search(queryString, {
type: "video",
})) as SearchResult<"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]) ||
(video.channel && 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 finalTrack = {
...track,
youtube_uri: (sameChannelTracks.length > 0
? sameChannelTracks
: tracksWithRelevance.length > 0
? tracksWithRelevance
: rarestTrack)[0].url,
};
return finalTrack;
} catch (error: any) {
logger.error(error);
throw error;
}
}

View File

@ -1,5 +0,0 @@
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;
}

View File

@ -1,9 +0,0 @@
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];
array[i] = array[j];
array[j] = temp;
}
return array;
}

View File

@ -1,16 +0,0 @@
import { Stream } from "stream";
export function streamToBuffer(stream: Stream): Promise<Buffer> {
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);
});
});
}

View File

@ -1,60 +0,0 @@
import { useContext, useEffect, useState } from "react";
import ytdl from "ytdl-core";
import fs from "fs";
import { YoutubeTrack } from "../helpers/getYoutubeTrack";
import { join } from "path";
import os from "os";
import playerContext from "../context/playerContext";
import { useLogger } from "./useLogger";
function useDownloadQueue() {
const logger = useLogger(useDownloadQueue.name);
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 isActiveDownloading() {
return downloadingTrackIds.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) => {
logger.error(`failed to download ${el.name}`, err);
})
.on("finish", () => {
setCompletedQueue([...completedQueue, el]);
});
}
});
}, [downloadQueue]);
return { addToQueue, isFinishedDownloading, isActiveDownloading };
}
export default useDownloadQueue;

View File

@ -1,6 +0,0 @@
import { useMemo } from "react";
import { Logger } from "../initializations/logger";
export function useLogger(module: string) {
return useMemo(() => new Logger(module), []);
}

View File

@ -1,75 +0,0 @@
import { InfiniteData, useQueryClient } from "react-query";
import { QueryCacheKeys } from "../conf";
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[];
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<
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);
function isFavorite(playlistId: string) {
return favoritePlaylistIds?.includes(playlistId);
}
return { reactToPlaylist, isFavorite, favoritePlaylists };
}
export default usePlaylistReaction;

View File

@ -1,35 +0,0 @@
import { useContext, useEffect } from "react";
import { LocalStorageKeys } from "../conf";
import authContext from "../context/authContext";
import spotifyApi from "../initializations/spotifyApi";
import { useLogger } from "./useLogger";
function useSpotifyApi() {
const logger = useLogger(useSpotifyApi.name);
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) => {
logger.error(error);
});
}
spotifyApi.setAccessToken(access_token);
}
}, [access_token, clientId, clientSecret, isLoggedIn]);
return spotifyApi;
}
export default useSpotifyApi;

View File

@ -1,33 +0,0 @@
import { useContext } from "react";
import SpotifyWebApi from "spotify-web-api-node";
import authContext from "../context/authContext";
import { useLogger } from "./useLogger";
function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
const logger = useLogger(useSpotifyApiError.name);
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 {
logger.info("Refreshing Access token");
const {
body: { access_token: refreshedAccessToken },
} = await spotifyApi.refreshAccessToken();
setAccess_token(refreshedAccessToken);
} catch (error: any) {
logger.error("Authorization Failure", error);
}
}
};
}
export default useSpotifyApiError;

View File

@ -1,41 +0,0 @@
import { useEffect } from "react";
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>;
function useSpotifyInfiniteQuery<TQueryData = unknown>(
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;
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
return query;
}
export default useSpotifyInfiniteQuery;

View File

@ -1,33 +0,0 @@
import { useEffect } from "react";
import { useMutation, UseMutationOptions } from "react-query";
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>;
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]);
return mutation;
}
export default useSpotifyMutation;

View File

@ -1,32 +0,0 @@
import { useEffect } from "react";
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from "react-query";
import SpotifyWebApi from "spotify-web-api-node";
import useSpotifyApi from "./useSpotifyApi";
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> = {},
): 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;
useEffect(() => {
if (isError && error) {
handleSpotifyError(error);
}
}, [isError, error]);
return query;
}
export default useSpotifyQuery;

View File

@ -1,71 +0,0 @@
import { InfiniteData, useQueryClient } from "react-query";
import { QueryCacheKeys } from "../conf";
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;
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<
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);
function isFavorite(trackId: string) {
return favoriteTrackIds?.includes(trackId);
}
return { reactToTrack, isFavorite, favoriteTracks };
}
export default useTrackReaction;

View File

@ -1,31 +0,0 @@
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 _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";
export const play = _play;
export const pause = _pause;
export const angleLeft = _angleLeft;
export const backward = _backward;
export const forward = _forward;
export const heartRegular = _heartRegular;
export const heart = _heart;
export const random = _random;
export const stop = _stop;
export const search = _search;
export const loadingSpinner = _loadingSpinner;
export const settingsCog = _settingsCog;
export const times = _times;
export const musicNode = _musicNode;
export const download = _download;

View File

@ -1,18 +0,0 @@
import { Renderer } from "@nodegui/react-nodegui";
import React from "react";
import App from "./app";
process.title = "Spotube";
Renderer.render(<App />, {
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();
});
}

View File

@ -1,74 +0,0 @@
import winston from "winston";
import chalk from "chalk";
import util from "util";
function safeStringify(arg: string | Record<any, any>): string {
return typeof arg === "object" ? JSON.stringify(arg, null, 2) : arg;
}
const spotubeLogFormat = winston.format.printf(({ level, message, label, prefix }) => {
if (!prefix && !label && typeof message === "object") {
return util.inspect(message, { colors: true, sorted: true, depth: 5 });
}
const safeMsg = safeStringify(message);
const safePrefix = safeStringify(prefix) ?? "";
const colors = {
info: "skyblue",
error: "red",
debug: "orange",
warn: "yellow",
};
const colorize = chalk.bold.keyword(colors[level as keyof typeof colors]);
return `${colorize(level)} [${chalk.bold.green(label)}]: ${colorize(
safePrefix,
)} ${safeMsg}`;
});
const baseLogger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.prettyPrint({ colorize: true }),
spotubeLogFormat,
),
transports: [new winston.transports.Console()],
});
type LogMessage = string | Record<any, any> | number;
export class Logger {
logger: winston.Logger = baseLogger;
constructor(public module: string, logger?: winston.Logger) {
if (logger) this.logger = logger;
}
log(message: LogMessage, level = "debug", prefix?: LogMessage) {
if (typeof message === "object") {
this.logger.log(level, { label: this.module, prefix, message: "" });
this.logger.log(level, { message });
} else {
this.logger.log(level, { label: this.module, message, prefix });
}
}
info(msg: LogMessage, msg2?: LogMessage): void {
if (msg2) this.log(msg2, "info", msg);
else this.log(msg, "info");
}
warn(msg: LogMessage, msg2?: LogMessage): void {
if (msg2) this.log(msg2, "warn", msg);
else this.log(msg, "warn");
}
error(msg: LogMessage, msg2?: LogMessage): void {
if (msg2) this.log(msg2, "error", msg);
else this.log(msg, "error");
}
debug(msg: LogMessage, msg2?: LogMessage): void {
if (msg2) this.log(msg2, "debug", msg);
else this.log(msg, "debug");
}
}

View File

@ -1,6 +0,0 @@
import SpotifyWebApi from "spotify-web-api-node";
import { redirectURI } from "../conf";
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
export default spotifyApi;

View File

@ -1,65 +0,0 @@
import React, { useContext } from "react";
import { Redirect, Route } from "react-router";
import authContext from "./context/authContext";
import Home from "./components/Home";
import Login from "./components/Login";
import PlaylistView from "./components/PlaylistView";
import PlaylistGenreView from "./components/PlaylistGenreView";
import TabMenu from "./components/TabMenu";
import CurrentPlaylist from "./components/CurrentPlaylist";
import Library from "./components/Library";
import Search from "./components/Search";
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
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 />
</Route>
<Route exact path="/playlist/:id">
<PlaylistView />
</Route>
<Route exact path="/genre/playlists/:id">
<PlaylistGenreView />
</Route>
</>
) : (
<Login />
)}
</Route>
<Route path="/currently">
<CurrentPlaylist />
</Route>
<Route path="/library">
<Library />
</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;

View File

@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"lib": [
"ES2019.Array",
"DOM"
],
"jsx": "react",
"strict": true,
"alwaysStrict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"incremental": true,
"downlevelIteration": true,
"declaration": true,
"sourceMap": true,
"baseUrl": "src"
},
"include": [
"**/*"
],
"exclude": [
"**/node_modules/*"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,95 +0,0 @@
const path = require("path");
const webpack = require("webpack");
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { ESBuildMinifyPlugin } = require("esbuild-loader")
module.exports = (env, argv) => {
/**
* @type {import("webpack").Configuration}
*/
const config = {
mode: "production",
entry: ["./src/index.tsx"],
target: "node",
output: {
path: path.resolve(__dirname, "dist"),
filename: "index.js",
},
module: {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
loader: 'esbuild-loader',
options: {
loader: 'jsx',
target: 'es2015'
},
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'esbuild-loader',
options: {
loader: 'tsx',
target: 'es2015'
},
},
{
test: /\.ts?$/,
exclude: /node_modules/,
loader: 'esbuild-loader',
options: {
loader: 'ts',
target: 'es2015'
},
},
{
test: /\.(png|jpe?g|gif|svg|bmp|otf)$/i,
use: [
{
loader: "file-loader",
options: { publicPath: "dist" },
},
],
},
{
test: /\.node/i,
use: [
{
loader: "native-addon-loader",
options: { name: "[name]-[hash].[ext]" },
},
],
},
],
},
plugins: [
new webpack.DefinePlugin({"global.GENTLY": false}),
new CleanWebpackPlugin(),
],
optimization: {
minimizer: [
new ESBuildMinifyPlugin({target: 'es2015'})
]
},
resolve: {
extensions: [".tsx", ".ts", ".js", ".jsx", ".json"],
},
};
if (argv.mode === "development") {
config.mode = "development";
config.plugins.push(new webpack.HotModuleReplacementPlugin());
config.plugins.push(
new ForkTsCheckerWebpackPlugin()
);
config.devtool = "source-map";
config.watch = true;
config.entry.unshift("webpack/hot/poll?100");
}
return config;
};