mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
All react-nodegui junk cleaned up
This commit is contained in:
parent
ad8b55f1aa
commit
3068b4ca4c
@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.env
|
|
||||||
Dockerfile
|
|
||||||
docker-compose*
|
|
45
.eslintrc.js
45
.eslintrc.js
@ -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;
|
|
@ -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
182
README.md
@ -1,182 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
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
|
|
||||||

|
|
||||||
|
|
||||||
## 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)
|
|
||||||

|
|
||||||
|
|
||||||
- Create an web app for Spotify Public API
|
|
||||||

|
|
||||||
|
|
||||||
- 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
|
|
||||||

|
|
||||||
|
|
||||||
- Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields
|
|
||||||

|
|
||||||
|
|
||||||
> **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
6
assets.d.ts
vendored
@ -1,6 +0,0 @@
|
|||||||
declare module "*.svg";
|
|
||||||
declare module "*.png";
|
|
||||||
declare module "*.jpg";
|
|
||||||
declare module "*.jpeg";
|
|
||||||
declare module "*.gif";
|
|
||||||
declare module "*.bmp";
|
|
@ -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
17741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
283
src/app.tsx
283
src/app.tsx
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 ?? []}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
// );
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
||||||
}
|
|
||||||
`;
|
|
33
src/conf.ts
33
src/conf.ts
@ -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",
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
@ -1,6 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Logger } from "../initializations/logger";
|
|
||||||
|
|
||||||
export function useLogger(module: string) {
|
|
||||||
return useMemo(() => new Logger(module), []);
|
|
||||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
31
src/icons.ts
31
src/icons.ts
@ -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;
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import SpotifyWebApi from "spotify-web-api-node";
|
|
||||||
import { redirectURI } from "../conf";
|
|
||||||
|
|
||||||
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
|
|
||||||
|
|
||||||
export default spotifyApi;
|
|
@ -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;
|
|
@ -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/*"
|
|
||||||
]
|
|
||||||
}
|
|
10960
tsconfig.tsbuildinfo
10960
tsconfig.tsbuildinfo
File diff suppressed because it is too large
Load Diff
@ -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;
|
|
||||||
};
|
|
Loading…
Reference in New Issue
Block a user