mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
ESLint, Pretteir configured, Library Followed Artists route
This commit is contained in:
parent
f54ce27d77
commit
7024da3fed
45
.eslintrc.js
Normal file
45
.eslintrc.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @type {import("eslint").Linter.Config}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
extends: [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
],
|
||||||
|
plugins: ["react", "@typescript-eslint"],
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es6: true,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "16.14.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
Atomics: "readonly",
|
||||||
|
SharedArrayBuffer: "readonly",
|
||||||
|
},
|
||||||
|
ignorePatterns: [".eslintrc.js", "./src/reportWebVitals.ts"],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
ecmaVersion: 2018,
|
||||||
|
sourceType: "module",
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
|
"prettier/prettier": "warn",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"linebreak-style": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
11
.prettierrc.js
Normal file
11
.prettierrc.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @type {import("prettier").Options}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
tabWidth: 4,
|
||||||
|
printWidth: 90,
|
||||||
|
semi: true,
|
||||||
|
endOfLine: "auto",
|
||||||
|
trailingComma: "all",
|
||||||
|
};
|
||||||
|
module.exports = config;
|
4098
package-lock.json
generated
4098
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -7,8 +7,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode=production",
|
"build": "webpack --mode=production",
|
||||||
"dev": "nodemon -w src/** -e ts,tsx -x 'node esbuild.config.mjs'",
|
"dev": "nodemon -w src/ -e ts,tsx -x 'node esbuild.config.mjs'",
|
||||||
"check-types": "tsc --noEmit --watch",
|
"check-types": "nodemon --quiet -e tsx,ts -w src/ -x tsc --noEmit --pretty",
|
||||||
"start": "cd dist && qode index.js",
|
"start": "cd dist && qode index.js",
|
||||||
"start:watch": "nodemon -w dist -e js -x \"npm start\"",
|
"start:watch": "nodemon -w dist -e js -x \"npm start\"",
|
||||||
"start-dev": "concurrently -n \"esbuild,spotube,tsc\" -p \"{name}\" -c \"bgYellow.black.bold,bgGreen.black.bold,bgBlue.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\" \"npm run check-types\"",
|
"start-dev": "concurrently -n \"esbuild,spotube,tsc\" -p \"{name}\" -c \"bgYellow.black.bold,bgGreen.black.bold,bgBlue.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\" \"npm run check-types\"",
|
||||||
@ -60,16 +60,26 @@
|
|||||||
"@types/spotify-web-api-node": "^5.0.0",
|
"@types/spotify-web-api-node": "^5.0.0",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
"@types/webpack-env": "^1.15.3",
|
"@types/webpack-env": "^1.15.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.27.0",
|
||||||
|
"@typescript-eslint/parser": "^4.27.0",
|
||||||
"@vitejs/plugin-react-refresh": "^1.3.3",
|
"@vitejs/plugin-react-refresh": "^1.3.3",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"concurrently": "^6.0.0",
|
"concurrently": "^6.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"esbuild": "^0.12.8",
|
"esbuild": "^0.12.8",
|
||||||
"esbuild-loader": "^2.13.1",
|
"esbuild-loader": "^2.13.1",
|
||||||
|
"eslint": "^7.28.0",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-plugin-import": "^2.23.4",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||||
|
"eslint-plugin-prettier": "^3.4.0",
|
||||||
|
"eslint-plugin-react": "^7.24.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"fork-ts-checker-webpack-plugin": "^6.2.0",
|
"fork-ts-checker-webpack-plugin": "^6.2.0",
|
||||||
"native-addon-loader": "^2.0.1",
|
"native-addon-loader": "^2.0.1",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
|
"prettier": "^2.3.1",
|
||||||
"typescript": "^4.2.3",
|
"typescript": "^4.2.3",
|
||||||
"webpack": "^5.27.0",
|
"webpack": "^5.27.0",
|
||||||
"webpack-cli": "^4.4.0"
|
"webpack-cli": "^4.4.0"
|
||||||
|
396
src/app.tsx
396
src/app.tsx
@ -1,6 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Window, hot, View } from "@nodegui/react-nodegui";
|
import { Window, hot, View } from "@nodegui/react-nodegui";
|
||||||
import { QIcon, QMainWindow, WidgetEventTypes, WindowState, QShortcut, QKeySequence } from "@nodegui/nodegui";
|
import {
|
||||||
|
QIcon,
|
||||||
|
QMainWindow,
|
||||||
|
WidgetEventTypes,
|
||||||
|
WindowState,
|
||||||
|
QShortcut,
|
||||||
|
QKeySequence,
|
||||||
|
} from "@nodegui/nodegui";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import Routes from "./routes";
|
import Routes from "./routes";
|
||||||
import { LocalStorage } from "node-localstorage";
|
import { LocalStorage } from "node-localstorage";
|
||||||
@ -16,11 +23,13 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { confDir, LocalStorageKeys } from "./conf";
|
import { confDir, LocalStorageKeys } from "./conf";
|
||||||
import spotubeIcon from "../assets/icon.svg";
|
import spotubeIcon from "../assets/icon.svg";
|
||||||
import preferencesContext, { PreferencesContextProperties } from "./context/preferencesContext";
|
import preferencesContext, {
|
||||||
|
PreferencesContextProperties,
|
||||||
|
} from "./context/preferencesContext";
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minSize = { width: 700, height: 750 };
|
const minSize = { width: 700, height: 750 };
|
||||||
@ -29,183 +38,244 @@ const localStorageDir = path.join(confDir, "local");
|
|||||||
fs.mkdirSync(localStorageDir, { recursive: true });
|
fs.mkdirSync(localStorageDir, { recursive: true });
|
||||||
global.localStorage = new LocalStorage(localStorageDir);
|
global.localStorage = new LocalStorage(localStorageDir);
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
onError(error) {
|
onError(error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
},
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialPreferences: PreferencesContextProperties = {
|
const initialPreferences: PreferencesContextProperties = {
|
||||||
playlistImages: false,
|
playlistImages: false,
|
||||||
};
|
};
|
||||||
const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
|
const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
|
||||||
|
|
||||||
//* Application start
|
//* Application start
|
||||||
function RootApp() {
|
function RootApp() {
|
||||||
const windowRef = useRef<QMainWindow>();
|
const windowRef = useRef<QMainWindow>();
|
||||||
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
|
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
|
||||||
// cache
|
// cache
|
||||||
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
|
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
|
||||||
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
|
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
|
||||||
// state
|
// state
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
||||||
|
|
||||||
const [credentials, setCredentials] = useState<Credentials>(() => {
|
const [credentials, setCredentials] = useState<Credentials>(() => {
|
||||||
if (cachedCredentials) {
|
if (cachedCredentials) {
|
||||||
return JSON.parse(cachedCredentials);
|
return JSON.parse(cachedCredentials);
|
||||||
}
|
|
||||||
return initialCredentials;
|
|
||||||
});
|
|
||||||
const [preferences, setPreferences] = useState<PreferencesContextProperties>(() => {
|
|
||||||
if (cachedPreferences) {
|
|
||||||
return JSON.parse(cachedPreferences);
|
|
||||||
}
|
|
||||||
return initialPreferences;
|
|
||||||
});
|
|
||||||
const [access_token, setAccess_token] = useState<string>("");
|
|
||||||
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parsedCredentials: Credentials = JSON.parse(cachedCredentials ?? "{}");
|
|
||||||
setIsLoggedIn(!!(parsedCredentials.clientId && parsedCredentials.clientSecret));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// for user code login
|
|
||||||
useEffect(() => {
|
|
||||||
// saving changed credentials to storage
|
|
||||||
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
|
|
||||||
if (credentials.clientId && credentials.clientSecret && !localStorage.getItem(LocalStorageKeys.refresh_token)) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
app.get<null, null, null, { code: string }>("/auth/spotify/callback", async (req, res) => {
|
|
||||||
try {
|
|
||||||
spotifyApi.setClientId(credentials.clientId);
|
|
||||||
spotifyApi.setClientSecret(credentials.clientSecret);
|
|
||||||
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
|
|
||||||
setAccess_token(authRes.access_token);
|
|
||||||
localStorage.setItem(LocalStorageKeys.refresh_token, authRes.refresh_token);
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
return res.end();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fullfil code grant flow: ", error);
|
|
||||||
}
|
}
|
||||||
});
|
return initialCredentials;
|
||||||
|
});
|
||||||
const server = app.listen(4304, () => {
|
const [preferences, setPreferences] = useState<PreferencesContextProperties>(() => {
|
||||||
console.log("Server is running");
|
if (cachedPreferences) {
|
||||||
spotifyApi.setClientId(credentials.clientId);
|
return JSON.parse(cachedPreferences);
|
||||||
spotifyApi.setClientSecret(credentials.clientSecret);
|
|
||||||
open(spotifyApi.createAuthorizeURL(["user-library-read", "playlist-read-private", "user-library-modify", "playlist-modify-private", "playlist-modify-public"], "xxxyyysssddd")).catch((e) =>
|
|
||||||
console.error("Opening IPC connection with browser failed: ", e)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
server.close(() => console.log("Closed server"));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [credentials]);
|
|
||||||
|
|
||||||
// just saves the preferences
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(LocalStorageKeys.preferences, JSON.stringify(preferences));
|
|
||||||
}, [preferences]);
|
|
||||||
|
|
||||||
// window event listeners
|
|
||||||
useEffect(() => {
|
|
||||||
const onWindowClose = () => {
|
|
||||||
if (audioPlayer.isRunning()) {
|
|
||||||
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
windowRef.current?.addEventListener(WidgetEventTypes.Close, onWindowClose);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
windowRef.current?.removeEventListener(WidgetEventTypes.Close, onWindowClose);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
let spaceShortcut: QShortcut | null;
|
|
||||||
let rightShortcut: QShortcut | null;
|
|
||||||
let leftShortcut: QShortcut | null;
|
|
||||||
// short cut effect
|
|
||||||
useEffect(() => {
|
|
||||||
if (windowRef.current) {
|
|
||||||
spaceShortcut = new QShortcut(windowRef.current);
|
|
||||||
rightShortcut = new QShortcut(windowRef.current);
|
|
||||||
leftShortcut = new QShortcut(windowRef.current);
|
|
||||||
|
|
||||||
spaceShortcut.setKey(new QKeySequence("SPACE"));
|
|
||||||
rightShortcut.setKey(new QKeySequence("RIGHT"));
|
|
||||||
leftShortcut.setKey(new QKeySequence("LEFT"));
|
|
||||||
|
|
||||||
async function spaceAction() {
|
|
||||||
try {
|
|
||||||
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause();
|
|
||||||
console.log("You pressed SPACE");
|
|
||||||
} catch (error) {
|
|
||||||
showError(error, "[Failed to play/pause audioPlayer]: ");
|
|
||||||
}
|
}
|
||||||
}
|
return initialPreferences;
|
||||||
async function rightAction() {
|
});
|
||||||
try {
|
const [access_token, setAccess_token] = useState<string>("");
|
||||||
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5));
|
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||||
console.log("You pressed RIGHT");
|
|
||||||
} catch (error) {
|
useEffect(() => {
|
||||||
showError(error, "[Failed to seek audioPlayer]: ");
|
const parsedCredentials: Credentials = JSON.parse(cachedCredentials ?? "{}");
|
||||||
|
setIsLoggedIn(!!(parsedCredentials.clientId && parsedCredentials.clientSecret));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// for user code login
|
||||||
|
useEffect(() => {
|
||||||
|
// saving changed credentials to storage
|
||||||
|
localStorage.setItem(LocalStorageKeys.credentials, JSON.stringify(credentials));
|
||||||
|
if (
|
||||||
|
credentials.clientId &&
|
||||||
|
credentials.clientSecret &&
|
||||||
|
!localStorage.getItem(LocalStorageKeys.refresh_token)
|
||||||
|
) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get<null, null, null, { code: string }>(
|
||||||
|
"/auth/spotify/callback",
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
spotifyApi.setClientId(credentials.clientId);
|
||||||
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
|
const { body: authRes } = await spotifyApi.authorizationCodeGrant(
|
||||||
|
req.query.code,
|
||||||
|
);
|
||||||
|
setAccess_token(authRes.access_token);
|
||||||
|
localStorage.setItem(
|
||||||
|
LocalStorageKeys.refresh_token,
|
||||||
|
authRes.refresh_token,
|
||||||
|
);
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
return res.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fullfil code grant flow: ", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = app.listen(4304, () => {
|
||||||
|
console.log("Server is running");
|
||||||
|
spotifyApi.setClientId(credentials.clientId);
|
||||||
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
|
open(
|
||||||
|
spotifyApi.createAuthorizeURL(
|
||||||
|
[
|
||||||
|
"user-library-read",
|
||||||
|
"playlist-read-private",
|
||||||
|
"user-library-modify",
|
||||||
|
"playlist-modify-private",
|
||||||
|
"playlist-modify-public",
|
||||||
|
],
|
||||||
|
"xxxyyysssddd",
|
||||||
|
),
|
||||||
|
).catch((e) =>
|
||||||
|
console.error("Opening IPC connection with browser failed: ", e),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
server.close(() => console.log("Closed server"));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}, [credentials]);
|
||||||
async function leftAction() {
|
|
||||||
try {
|
// just saves the preferences
|
||||||
currentTrack && audioPlayer.isRunning() && (await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5));
|
useEffect(() => {
|
||||||
console.log("You pressed LEFT");
|
localStorage.setItem(LocalStorageKeys.preferences, JSON.stringify(preferences));
|
||||||
} catch (error) {
|
}, [preferences]);
|
||||||
showError(error, "[Failed to seek audioPlayer]: ");
|
|
||||||
|
// window event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const onWindowClose = () => {
|
||||||
|
if (audioPlayer.isRunning()) {
|
||||||
|
audioPlayer
|
||||||
|
.stop()
|
||||||
|
.catch((e) => console.error("Failed to quit MPV player: ", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
windowRef.current?.addEventListener(WidgetEventTypes.Close, onWindowClose);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
windowRef.current?.removeEventListener(WidgetEventTypes.Close, onWindowClose);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let spaceShortcut: QShortcut | null;
|
||||||
|
let rightShortcut: QShortcut | null;
|
||||||
|
let leftShortcut: QShortcut | null;
|
||||||
|
// short cut effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (windowRef.current) {
|
||||||
|
spaceShortcut = new QShortcut(windowRef.current);
|
||||||
|
rightShortcut = new QShortcut(windowRef.current);
|
||||||
|
leftShortcut = new QShortcut(windowRef.current);
|
||||||
|
|
||||||
|
spaceShortcut.setKey(new QKeySequence("SPACE"));
|
||||||
|
rightShortcut.setKey(new QKeySequence("RIGHT"));
|
||||||
|
leftShortcut.setKey(new QKeySequence("LEFT"));
|
||||||
|
|
||||||
|
async function spaceAction() {
|
||||||
|
try {
|
||||||
|
currentTrack &&
|
||||||
|
audioPlayer.isRunning() &&
|
||||||
|
(await audioPlayer.isPaused())
|
||||||
|
? await audioPlayer.play()
|
||||||
|
: await audioPlayer.pause();
|
||||||
|
console.log("You pressed SPACE");
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed to play/pause audioPlayer]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function rightAction() {
|
||||||
|
try {
|
||||||
|
currentTrack &&
|
||||||
|
audioPlayer.isRunning() &&
|
||||||
|
(await audioPlayer.isSeekable()) &&
|
||||||
|
(await audioPlayer.seek(+5));
|
||||||
|
console.log("You pressed RIGHT");
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed to seek audioPlayer]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function leftAction() {
|
||||||
|
try {
|
||||||
|
currentTrack &&
|
||||||
|
audioPlayer.isRunning() &&
|
||||||
|
(await audioPlayer.isSeekable()) &&
|
||||||
|
(await audioPlayer.seek(-5));
|
||||||
|
console.log("You pressed LEFT");
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed to seek audioPlayer]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceShortcut.addEventListener("activated", spaceAction);
|
||||||
|
rightShortcut.addEventListener("activated", rightAction);
|
||||||
|
leftShortcut.addEventListener("activated", leftAction);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
spaceShortcut?.removeEventListener("activated", spaceAction);
|
||||||
|
rightShortcut?.removeEventListener("activated", rightAction);
|
||||||
|
leftShortcut?.removeEventListener("activated", leftAction);
|
||||||
|
spaceShortcut = null;
|
||||||
|
rightShortcut = null;
|
||||||
|
leftShortcut = null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
spaceShortcut.addEventListener("activated", spaceAction);
|
return (
|
||||||
rightShortcut.addEventListener("activated", rightAction);
|
<Window
|
||||||
leftShortcut.addEventListener("activated", leftAction);
|
ref={windowRef}
|
||||||
|
windowState={WindowState.WindowMaximized}
|
||||||
return () => {
|
windowIcon={winIcon}
|
||||||
spaceShortcut?.removeEventListener("activated", spaceAction);
|
windowTitle="Spotube"
|
||||||
rightShortcut?.removeEventListener("activated", rightAction);
|
minSize={minSize}
|
||||||
leftShortcut?.removeEventListener("activated", leftAction);
|
>
|
||||||
spaceShortcut = null;
|
<MemoryRouter>
|
||||||
rightShortcut = null;
|
<authContext.Provider
|
||||||
leftShortcut = null;
|
value={{
|
||||||
};
|
isLoggedIn,
|
||||||
}
|
setIsLoggedIn,
|
||||||
});
|
access_token,
|
||||||
|
setAccess_token,
|
||||||
return (
|
...credentials,
|
||||||
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
|
setCredentials,
|
||||||
<MemoryRouter>
|
}}
|
||||||
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}>
|
>
|
||||||
<preferencesContext.Provider value={{ ...preferences, setPreferences }}>
|
<preferencesContext.Provider
|
||||||
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
value={{ ...preferences, setPreferences }}
|
||||||
<QueryClientProvider client={queryClient}>
|
>
|
||||||
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}>
|
<playerContext.Provider
|
||||||
<Routes />
|
value={{
|
||||||
{isLoggedIn && <Player />}
|
currentPlaylist,
|
||||||
</View>
|
currentTrack,
|
||||||
</QueryClientProvider>
|
setCurrentPlaylist,
|
||||||
</playerContext.Provider>
|
setCurrentTrack,
|
||||||
</preferencesContext.Provider>
|
}}
|
||||||
</authContext.Provider>
|
>
|
||||||
</MemoryRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
</Window>
|
<View
|
||||||
);
|
style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}
|
||||||
|
>
|
||||||
|
<Routes />
|
||||||
|
{isLoggedIn && <Player />}
|
||||||
|
</View>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</playerContext.Provider>
|
||||||
|
</preferencesContext.Provider>
|
||||||
|
</authContext.Provider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</Window>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
return <RootApp />;
|
return <RootApp />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default hot(App);
|
export default hot(App);
|
||||||
|
10
src/components/Artist.tsx
Normal file
10
src/components/Artist.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ScrollArea } from "@nodegui/react-nodegui";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function Artist() {
|
||||||
|
return (
|
||||||
|
<ScrollArea style="min-height: 750px; max-height: 1980px; max-width: 1980px; min-width: 700px; border: none;"></ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Artist;
|
@ -5,9 +5,15 @@ import { angleLeft } from "../icons";
|
|||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
|
|
||||||
function BackButton(): ReactElement {
|
function BackButton(): ReactElement {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
return <IconButton style={"align-self: flex-start;"} icon={new QIcon(angleLeft)} on={{ clicked: () => history.goBack() }} />;
|
return (
|
||||||
|
<IconButton
|
||||||
|
style={"align-self: flex-start;"}
|
||||||
|
icon={new QIcon(angleLeft)}
|
||||||
|
on={{ clicked: () => history.goBack() }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BackButton;
|
export default BackButton;
|
||||||
|
@ -5,31 +5,39 @@ import { TrackTableIndex } from "./PlaylistView";
|
|||||||
import { TrackButton } from "./shared/TrackButton";
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
function CurrentPlaylist() {
|
function CurrentPlaylist() {
|
||||||
const { currentPlaylist, currentTrack } = useContext(playerContext);
|
const { currentPlaylist, currentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
if (!currentPlaylist && !currentTrack) {
|
if (!currentPlaylist && !currentTrack) {
|
||||||
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
|
return (
|
||||||
}
|
<Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentTrack && !currentPlaylist) {
|
if (currentTrack && !currentPlaylist) {
|
||||||
<View style="flex: 1;">
|
<View style="flex: 1;">
|
||||||
<TrackButton track={currentTrack} index={0}/>
|
<TrackButton track={currentTrack} index={0} />
|
||||||
</View>
|
</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
|
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
|
||||||
<TrackTableIndex />
|
<TrackTableIndex />
|
||||||
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
<View style={`flex-direction:column; flex: 1;`}>
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
{currentPlaylist?.tracks.map(({ track }, index) => {
|
{currentPlaylist?.tracks.map(({ track }, index) => {
|
||||||
return <TrackButton key={index + track.id} track={track} index={index} />;
|
return (
|
||||||
})}
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
track={track}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CurrentPlaylist;
|
export default CurrentPlaylist;
|
||||||
|
@ -7,58 +7,96 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
|||||||
import CategoryCardView from "./shared/CategoryCardView";
|
import CategoryCardView from "./shared/CategoryCardView";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data: pagedCategories, isError, refetch, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>(
|
const {
|
||||||
QueryCacheKeys.categories,
|
data: pagedCategories,
|
||||||
(spotifyApi, { pageParam }) => spotifyApi.getCategories({ country: "US", limit: 10, offset: pageParam }).then((categoriesReceived) => categoriesReceived.body.categories),
|
isError,
|
||||||
{
|
refetch,
|
||||||
getNextPageParam(lastPage) {
|
isLoading,
|
||||||
if (lastPage.next) {
|
hasNextPage,
|
||||||
return lastPage.offset + lastPage.limit;
|
isFetchingNextPage,
|
||||||
}
|
fetchNextPage,
|
||||||
},
|
} = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>(
|
||||||
}
|
QueryCacheKeys.categories,
|
||||||
);
|
(spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi
|
||||||
|
.getCategories({ country: "US", limit: 10, offset: pageParam })
|
||||||
|
.then((categoriesReceived) => categoriesReceived.body.categories),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const categories = pagedCategories?.pages
|
const categories = pagedCategories?.pages
|
||||||
.map((page) => page.items)
|
.map((page) => page.items)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.flat(1);
|
.flat(1);
|
||||||
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
|
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
|
||||||
return (
|
return (
|
||||||
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
||||||
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
|
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
|
||||||
<PlaceholderApplet error={isError} message="Failed to query genres" reload={refetch} helps loading={isLoading} />
|
<PlaceholderApplet
|
||||||
{categories?.map((category, index) => {
|
error={isError}
|
||||||
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
|
message="Failed to query genres"
|
||||||
})}
|
reload={refetch}
|
||||||
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />}
|
helps
|
||||||
</View>
|
loading={isLoading}
|
||||||
</ScrollArea>
|
/>
|
||||||
);
|
{categories?.map((category, index) => {
|
||||||
|
return (
|
||||||
|
<CategoryCard
|
||||||
|
key={index + category.id}
|
||||||
|
id={category.id}
|
||||||
|
name={category.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
on={{ clicked: () => fetchNextPage() }}
|
||||||
|
text="Load More"
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
||||||
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
const { data: playlists, isError } = useSpotifyQuery<
|
||||||
[QueryCacheKeys.categoryPlaylists, id],
|
SpotifyApi.PlaylistObjectSimplified[]
|
||||||
async (spotifyApi) => {
|
>(
|
||||||
const option = { limit: 4 };
|
[QueryCacheKeys.categoryPlaylists, id],
|
||||||
let res;
|
async (spotifyApi) => {
|
||||||
if (id === "featured") {
|
const option = { limit: 4 };
|
||||||
res = await spotifyApi.getFeaturedPlaylists(option);
|
let res;
|
||||||
} else {
|
if (id === "featured") {
|
||||||
res = await spotifyApi.getPlaylistsForCategory(id, option);
|
res = await spotifyApi.getFeaturedPlaylists(option);
|
||||||
}
|
} else {
|
||||||
return res.body.playlists.items;
|
res = await spotifyApi.getPlaylistsForCategory(id, option);
|
||||||
},
|
}
|
||||||
{ initialData: [] }
|
return res.body.playlists.items;
|
||||||
);
|
},
|
||||||
|
{ initialData: [] },
|
||||||
|
);
|
||||||
|
|
||||||
return <CategoryCardView url={`/genre/playlists/${id}`} isError={isError} name={name} playlists={playlists ?? []} />;
|
return (
|
||||||
|
<CategoryCardView
|
||||||
|
url={`/genre/playlists/${id}`}
|
||||||
|
isError={isError}
|
||||||
|
name={name}
|
||||||
|
playlists={playlists ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
|
import { CursorShape } from "@nodegui/nodegui";
|
||||||
|
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Redirect, Route } from "react-router";
|
import { Redirect, Route } from "react-router";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
@ -6,139 +7,279 @@ import playerContext from "../context/playerContext";
|
|||||||
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
import { GenreView } from "./PlaylistGenreView";
|
import { GenreView } from "./PlaylistGenreView";
|
||||||
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
|
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
|
||||||
|
import CachedImage from "./shared/CachedImage";
|
||||||
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton";
|
import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton";
|
||||||
import { TabMenuItem } from "./TabMenu";
|
import { TabMenuItem } from "./TabMenu";
|
||||||
|
|
||||||
function Library() {
|
function Library() {
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<Redirect from="/library" to="/library/saved-tracks" />
|
<Redirect from="/library" to="/library/saved-tracks" />
|
||||||
<View style="max-width: 350px; justify-content: 'space-evenly'">
|
<View style="max-width: 350px; justify-content: 'space-evenly'">
|
||||||
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
|
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
|
||||||
<TabMenuItem title="Playlists" url="/library/playlists" />
|
<TabMenuItem title="Playlists" url="/library/playlists" />
|
||||||
</View>
|
<TabMenuItem title="Artists" url="/library/followed-artists" />
|
||||||
<Route exact path="/library/saved-tracks">
|
</View>
|
||||||
<UserSavedTracks />
|
<Route exact path="/library/saved-tracks">
|
||||||
</Route>
|
<UserSavedTracks />
|
||||||
<Route exact path="/library/playlists">
|
</Route>
|
||||||
<UserPlaylists />
|
<Route exact path="/library/playlists">
|
||||||
</Route>
|
<UserPlaylists />
|
||||||
</View>
|
</Route>
|
||||||
);
|
<Route exact path="/library/followed-artists">
|
||||||
|
<FollowedArtists />
|
||||||
|
</Route>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Library;
|
export default Library;
|
||||||
|
|
||||||
function UserPlaylists() {
|
function UserPlaylists() {
|
||||||
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
|
const {
|
||||||
QueryCacheKeys.userPlaylists,
|
data: userPagedPlaylists,
|
||||||
(spotifyApi, { pageParam }) =>
|
isError,
|
||||||
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => {
|
isLoading,
|
||||||
return userPlaylists.body;
|
refetch,
|
||||||
}),
|
isFetchingNextPage,
|
||||||
{
|
hasNextPage,
|
||||||
getNextPageParam(lastPage) {
|
fetchNextPage,
|
||||||
if (lastPage.next) {
|
} = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
|
||||||
return lastPage.offset + lastPage.limit;
|
QueryCacheKeys.userPlaylists,
|
||||||
}
|
(spotifyApi, { pageParam }) =>
|
||||||
},
|
spotifyApi
|
||||||
}
|
.getUserPlaylists({ limit: 20, offset: pageParam })
|
||||||
);
|
.then((userPlaylists) => {
|
||||||
|
return userPlaylists.body;
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const userPlaylists = userPagedPlaylists?.pages
|
const userPlaylists = userPagedPlaylists?.pages
|
||||||
?.map((playlist) => playlist.items)
|
?.map((playlist) => playlist.items)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenreView
|
<GenreView
|
||||||
heading="User Playlists"
|
heading="User Playlists"
|
||||||
isError={isError}
|
isError={isError}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
playlists={userPlaylists ?? []}
|
playlists={userPlaylists ?? []}
|
||||||
isLoadable={!isFetchingNextPage}
|
isLoadable={!isFetchingNextPage}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
loadMore={hasNextPage ? ()=>fetchNextPage() : undefined}
|
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserSavedTracks() {
|
function UserSavedTracks() {
|
||||||
const userSavedPlaylistId = "user-saved-tracks";
|
const userSavedPlaylistId = "user-saved-tracks";
|
||||||
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
|
const {
|
||||||
QueryCacheKeys.userSavedTracks,
|
data: userSavedTracks,
|
||||||
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body),
|
fetchNextPage,
|
||||||
{
|
hasNextPage,
|
||||||
getNextPageParam(lastPage) {
|
isFetchingNextPage,
|
||||||
if (lastPage.next) {
|
isError,
|
||||||
return lastPage.offset + lastPage.limit;
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
|
||||||
|
QueryCacheKeys.userSavedTracks,
|
||||||
|
(spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi
|
||||||
|
.getMySavedTracks({ limit: 50, offset: pageParam })
|
||||||
|
.then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } =
|
||||||
|
useContext(playerContext);
|
||||||
|
|
||||||
|
const userTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
|
|
||||||
|
function handlePlaylistPlayPause(index?: number) {
|
||||||
|
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
||||||
|
setCurrentPlaylist({
|
||||||
|
id: userSavedPlaylistId,
|
||||||
|
name: "Liked Tracks",
|
||||||
|
thumbnail:
|
||||||
|
"https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg",
|
||||||
|
tracks: userTracks,
|
||||||
|
});
|
||||||
|
setCurrentTrack(userTracks[index ?? 0].track);
|
||||||
|
} else {
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
setCurrentTrack(undefined);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext);
|
|
||||||
|
|
||||||
const userTracks = userSavedTracks?.pages
|
const playlist: TrackButtonPlaylistObject = {
|
||||||
?.map((page) => page.items)
|
collaborative: false,
|
||||||
.filter(Boolean)
|
description: "User Playlist",
|
||||||
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
tracks: {
|
||||||
|
items: userTracks ?? [],
|
||||||
function handlePlaylistPlayPause(index?: number) {
|
limit: 20,
|
||||||
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
href: "",
|
||||||
setCurrentPlaylist({ id: userSavedPlaylistId, name: "Liked Tracks", thumbnail: "https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg", tracks: userTracks });
|
next: "",
|
||||||
setCurrentTrack(userTracks[index ?? 0].track);
|
offset: 0,
|
||||||
} else {
|
previous: "",
|
||||||
setCurrentPlaylist(undefined);
|
total: 20,
|
||||||
setCurrentTrack(undefined);
|
},
|
||||||
}
|
external_urls: { spotify: "" },
|
||||||
}
|
href: "",
|
||||||
|
id: userSavedPlaylistId,
|
||||||
const playlist: TrackButtonPlaylistObject = {
|
images: [{ url: "https://facebook.com/img.jpeg" }],
|
||||||
collaborative: false,
|
name: "User saved track",
|
||||||
description: "User Playlist",
|
owner: {
|
||||||
tracks: {
|
external_urls: { spotify: "" },
|
||||||
items: userTracks ?? [],
|
href: "",
|
||||||
limit: 20,
|
id: "Me",
|
||||||
href: "",
|
type: "user",
|
||||||
next: "",
|
uri: "spotify:user:me",
|
||||||
offset: 0,
|
display_name: "User",
|
||||||
previous: "",
|
followers: { href: null, total: 0 },
|
||||||
total: 20,
|
},
|
||||||
},
|
public: false,
|
||||||
external_urls: { spotify: "" },
|
snapshot_id: userSavedPlaylistId + "snapshot",
|
||||||
href: "",
|
type: "playlist",
|
||||||
id: userSavedPlaylistId,
|
uri: "spotify:user:me:saved-tracks",
|
||||||
images: [{ url: "https://facebook.com/img.jpeg" }],
|
};
|
||||||
name: "User saved track",
|
return (
|
||||||
owner: { external_urls: { spotify: "" }, href: "", id: "Me", type: "user", uri: "spotify:user:me", display_name: "User", followers: { href: null, total: 0 } },
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
public: false,
|
<PlaylistSimpleControls
|
||||||
snapshot_id: userSavedPlaylistId + "snapshot",
|
handlePlaylistPlayPause={handlePlaylistPlayPause}
|
||||||
type: "playlist",
|
isActive={currentPlaylist?.id === userSavedPlaylistId}
|
||||||
uri: "spotify:user:me:saved-tracks",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
|
||||||
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
|
|
||||||
<TrackTableIndex />
|
|
||||||
<ScrollArea style="flex: 1; border: none;">
|
|
||||||
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
|
||||||
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
|
||||||
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)}
|
|
||||||
{hasNextPage && (
|
|
||||||
<Button
|
|
||||||
style="flex-grow: 0; align-self: 'center';"
|
|
||||||
text="Load more"
|
|
||||||
on={{
|
|
||||||
clicked() {
|
|
||||||
fetchNextPage();
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
enabled={!isFetchingNextPage}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<TrackTableIndex />
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
||||||
|
<PlaceholderApplet
|
||||||
|
error={isError}
|
||||||
|
loading={isLoading || isFetchingNextPage}
|
||||||
|
message="Failed querying spotify"
|
||||||
|
reload={refetch}
|
||||||
|
/>
|
||||||
|
{userTracks?.map(
|
||||||
|
({ track }, index) =>
|
||||||
|
track && (
|
||||||
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
track={track}
|
||||||
|
index={index}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
|
text="Load more"
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
}
|
||||||
);
|
|
||||||
|
function FollowedArtists() {
|
||||||
|
const {
|
||||||
|
data: pagedFollowedArtists,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useSpotifyInfiniteQuery<
|
||||||
|
SpotifyApi.CursorBasedPagingObject<SpotifyApi.ArtistObjectFull>
|
||||||
|
>(
|
||||||
|
QueryCacheKeys.followedArtists,
|
||||||
|
(spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi
|
||||||
|
.getFollowedArtists({ limit: 50, after: pageParam })
|
||||||
|
.then((res) => res.body.artists),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.cursors.after + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const followedArtists = pagedFollowedArtists?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.ArtistObjectFull[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea style="min-height: 750px; max-height: 1980px; max-width: 1980px; min-width: 700px; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'row'; flex-wrap: wrap; width: 330px;">
|
||||||
|
<PlaceholderApplet
|
||||||
|
error={isError}
|
||||||
|
loading={isLoading || isFetchingNextPage}
|
||||||
|
message="Failed querying spotify"
|
||||||
|
reload={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{followedArtists?.map((artist, index) => {
|
||||||
|
return <ArtistCard key={index + artist.id} artist={artist} />;
|
||||||
|
})}
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
|
text="Load more"
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistCardProps {
|
||||||
|
artist: SpotifyApi.ArtistObjectFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtistCard({ artist }: ArtistCardProps) {
|
||||||
|
return (
|
||||||
|
<View style="max-width: 150px; max-height: 200px; flex-direction: 'column'; align-items: 'center'; margin: 5px 0;">
|
||||||
|
<CachedImage
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
maxSize={{ height: 150, width: 150 }}
|
||||||
|
scaledContents
|
||||||
|
alt={artist.name}
|
||||||
|
src={artist.images[0].url}
|
||||||
|
/>
|
||||||
|
<Text>{artist.name}</Text>
|
||||||
|
<Button cursor={CursorShape.PointingHandCursor} text="Follow" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,63 +3,63 @@ import { LineEdit, Text, Button, View } from "@nodegui/react-nodegui";
|
|||||||
import authContext from "../context/authContext";
|
import authContext from "../context/authContext";
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const { setCredentials: setGlobalCredentials } = useContext(authContext);
|
const { setCredentials: setGlobalCredentials } = useContext(authContext);
|
||||||
const [credentials, setCredentials] = useState({
|
const [credentials, setCredentials] = useState({
|
||||||
clientId: "",
|
clientId: "",
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [touched, setTouched] = useState({
|
const [touched, setTouched] = useState({
|
||||||
clientId: false,
|
clientId: false,
|
||||||
clientSecret: false,
|
clientSecret: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
type fieldNames = "clientId" | "clientSecret";
|
type fieldNames = "clientId" | "clientSecret";
|
||||||
|
|
||||||
function textChanged(text: string, fieldName: fieldNames) {
|
function textChanged(text: string, fieldName: fieldNames) {
|
||||||
setCredentials({ ...credentials, [fieldName]: text });
|
setCredentials({ ...credentials, [fieldName]: text });
|
||||||
}
|
|
||||||
|
|
||||||
function textEdited(name: fieldNames) {
|
|
||||||
if (!touched[name]) {
|
|
||||||
setTouched({ ...touched, [name]: true });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
function textEdited(name: fieldNames) {
|
||||||
<View style={`flex: 1; flex-direction: 'column';`}>
|
if (!touched[name]) {
|
||||||
<Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text>
|
setTouched({ ...touched, [name]: true });
|
||||||
<Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
|
}
|
||||||
<LineEdit
|
}
|
||||||
on={{
|
|
||||||
textChanged: (t) => textChanged(t, "clientId"),
|
return (
|
||||||
textEdited() {
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
textEdited("clientId");
|
<Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text>
|
||||||
},
|
<Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
|
||||||
}}
|
<LineEdit
|
||||||
text={credentials.clientId}
|
on={{
|
||||||
placeholderText="spotify clientId"
|
textChanged: (t) => textChanged(t, "clientId"),
|
||||||
/>
|
textEdited() {
|
||||||
<LineEdit
|
textEdited("clientId");
|
||||||
on={{
|
},
|
||||||
textChanged: (t) => textChanged(t, "clientSecret"),
|
}}
|
||||||
textEdited() {
|
text={credentials.clientId}
|
||||||
textEdited("clientSecret");
|
placeholderText="spotify clientId"
|
||||||
},
|
/>
|
||||||
}}
|
<LineEdit
|
||||||
text={credentials.clientSecret}
|
on={{
|
||||||
placeholderText="spotify clientSecret"
|
textChanged: (t) => textChanged(t, "clientSecret"),
|
||||||
/>
|
textEdited() {
|
||||||
<Button
|
textEdited("clientSecret");
|
||||||
on={{
|
},
|
||||||
clicked: () => {
|
}}
|
||||||
setGlobalCredentials(credentials);
|
text={credentials.clientSecret}
|
||||||
},
|
placeholderText="spotify clientSecret"
|
||||||
}}
|
/>
|
||||||
text="Add"
|
<Button
|
||||||
/>
|
on={{
|
||||||
</View>
|
clicked: () => {
|
||||||
);
|
setGlobalCredentials(credentials);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
text="Add"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
@ -1,89 +1,97 @@
|
|||||||
import { FlexLayout, QDialog, QLabel, QPushButton, QScrollArea, QWidget, TextFormat } from "@nodegui/nodegui";
|
import {
|
||||||
|
FlexLayout,
|
||||||
|
QDialog,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QWidget,
|
||||||
|
TextFormat,
|
||||||
|
} from "@nodegui/nodegui";
|
||||||
import React, { PropsWithChildren, useEffect, useState } from "react";
|
import React, { PropsWithChildren, useEffect, useState } from "react";
|
||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
import fetchLyrics from "../helpers/fetchLyrics";
|
import fetchLyrics from "../helpers/fetchLyrics";
|
||||||
|
|
||||||
interface ManualLyricDialogProps extends PropsWithChildren<{}> {
|
interface ManualLyricDialogProps extends PropsWithChildren<unknown> {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose?: (closed: boolean) => void;
|
onClose?: (closed: boolean) => void;
|
||||||
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
|
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManualLyricDialog({ open, track, onClose }: ManualLyricDialogProps) {
|
function ManualLyricDialog({ open, track }: ManualLyricDialogProps) {
|
||||||
const dialog = new QDialog();
|
const dialog = new QDialog();
|
||||||
const areaContainer = new QWidget();
|
const areaContainer = new QWidget();
|
||||||
const retryButton = new QPushButton();
|
const retryButton = new QPushButton();
|
||||||
const scrollArea = new QScrollArea();
|
const scrollArea = new QScrollArea();
|
||||||
const titleLabel = new QLabel();
|
const titleLabel = new QLabel();
|
||||||
const lyricLabel = new QLabel();
|
const lyricLabel = new QLabel();
|
||||||
const [lyricNotFound, setLyricNotFound] = useState<boolean>(false);
|
const [lyricNotFound, setLyricNotFound] = useState<boolean>(false);
|
||||||
const [lyrics, setLyrics] = useState<string>("");
|
const [lyrics, setLyrics] = useState<string>("");
|
||||||
const artists = track.artists.map((artist) => artist.name).join(", ");
|
const artists = track.artists.map((artist) => artist.name).join(", ");
|
||||||
|
|
||||||
async function handleBtnClick() {
|
async function handleBtnClick() {
|
||||||
try {
|
try {
|
||||||
const lyrics = await fetchLyrics(artists, track.name);
|
const lyrics = await fetchLyrics(artists, track.name);
|
||||||
console.log('lyrics:', lyrics)
|
console.log("lyrics:", lyrics);
|
||||||
setLyrics(lyrics);
|
setLyrics(lyrics);
|
||||||
setLyricNotFound(lyrics === "Not Found");
|
setLyricNotFound(lyrics === "Not Found");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, `[Finding lyrics for ${track.name} failed]: `);
|
showError(error, `[Finding lyrics for ${track.name} failed]: `);
|
||||||
setLyrics("No lyrics found, rare track :)");
|
setLyrics("No lyrics found, rare track :)");
|
||||||
setLyricNotFound(true);
|
setLyricNotFound(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// title label
|
// title label
|
||||||
titleLabel.setText(`
|
titleLabel.setText(`
|
||||||
<center>
|
<center>
|
||||||
<h2>${track.name}</h2>
|
<h2>${track.name}</h2>
|
||||||
<p>- ${artists}</p>
|
<p>- ${artists}</p>
|
||||||
</center>
|
</center>
|
||||||
`);
|
`);
|
||||||
// lyric label
|
// lyric label
|
||||||
lyricLabel.setText(lyrics);
|
lyricLabel.setText(lyrics);
|
||||||
lyricLabel.setTextFormat(TextFormat.PlainText);
|
lyricLabel.setTextFormat(TextFormat.PlainText);
|
||||||
// area container
|
// area container
|
||||||
areaContainer.setLayout(new FlexLayout());
|
areaContainer.setLayout(new FlexLayout());
|
||||||
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
|
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
|
||||||
areaContainer.layout?.addWidget(titleLabel);
|
areaContainer.layout?.addWidget(titleLabel);
|
||||||
areaContainer.layout?.addWidget(lyricLabel);
|
areaContainer.layout?.addWidget(lyricLabel);
|
||||||
areaContainer.layout?.addWidget(retryButton);
|
areaContainer.layout?.addWidget(retryButton);
|
||||||
// scroll area
|
// scroll area
|
||||||
scrollArea.setInlineStyle("flex: 1;");
|
scrollArea.setInlineStyle("flex: 1;");
|
||||||
scrollArea.setWidget(areaContainer);
|
scrollArea.setWidget(areaContainer);
|
||||||
|
|
||||||
// reload button
|
// reload button
|
||||||
retryButton.setText("Retry");
|
retryButton.setText("Retry");
|
||||||
retryButton.addEventListener("clicked", handleBtnClick);
|
retryButton.addEventListener("clicked", handleBtnClick);
|
||||||
// dialog
|
// dialog
|
||||||
dialog.setWindowTitle("Lyrics");
|
dialog.setWindowTitle("Lyrics");
|
||||||
dialog.setLayout(new FlexLayout());
|
dialog.setLayout(new FlexLayout());
|
||||||
dialog.layout?.addWidget(scrollArea);
|
dialog.layout?.addWidget(scrollArea);
|
||||||
open ? dialog.open() : dialog.close();
|
open ? dialog.open() : dialog.close();
|
||||||
open &&
|
open &&
|
||||||
fetchLyrics(artists, track.name)
|
fetchLyrics(artists, track.name)
|
||||||
.then((lyrics: string) => {
|
.then((lyrics: string) => {
|
||||||
setLyrics(lyrics);
|
setLyrics(lyrics);
|
||||||
setLyricNotFound(lyrics === "Not Found");
|
setLyricNotFound(lyrics === "Not Found");
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
showError(e, `[Finding lyrics for ${track.name} failed]: `);
|
showError(e, `[Finding lyrics for ${track.name} failed]: `);
|
||||||
setLyrics("No lyrics found, rare track :)");
|
setLyrics("No lyrics found, rare track :)");
|
||||||
setLyricNotFound(true);
|
setLyricNotFound(true);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
retryButton.removeEventListener("clicked", handleBtnClick);
|
retryButton.removeEventListener("clicked", handleBtnClick);
|
||||||
dialog.hide();
|
dialog.hide();
|
||||||
};
|
};
|
||||||
}, [open, track, lyrics]);
|
}, [open, track, lyrics]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
retryButton.setEnabled(!lyricNotFound);
|
retryButton.setEnabled(!lyricNotFound);
|
||||||
}, [lyricNotFound]);
|
}, [lyricNotFound]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ManualLyricDialog;
|
export default ManualLyricDialog;
|
||||||
|
@ -1,12 +1,31 @@
|
|||||||
import { Direction, Orientation, QAbstractSliderSignals, QIcon } from "@nodegui/nodegui";
|
import { Direction, Orientation, QAbstractSliderSignals, QIcon } from "@nodegui/nodegui";
|
||||||
import { BoxView, GridColumn, GridRow, GridView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui";
|
import {
|
||||||
|
BoxView,
|
||||||
|
GridColumn,
|
||||||
|
GridRow,
|
||||||
|
GridView,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
useEventHandler,
|
||||||
|
} from "@nodegui/react-nodegui";
|
||||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
||||||
import playerContext, { CurrentPlaylist } from "../context/playerContext";
|
import playerContext, { CurrentPlaylist } from "../context/playerContext";
|
||||||
import { shuffleArray } from "../helpers/shuffleArray";
|
import { shuffleArray } from "../helpers/shuffleArray";
|
||||||
import NodeMpv from "node-mpv";
|
import NodeMpv from "node-mpv";
|
||||||
import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack";
|
import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack";
|
||||||
import PlayerProgressBar from "./PlayerProgressBar";
|
import PlayerProgressBar from "./PlayerProgressBar";
|
||||||
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular, heart, musicNode, download } from "../icons";
|
import {
|
||||||
|
random as shuffleIcon,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
backward,
|
||||||
|
forward,
|
||||||
|
stop,
|
||||||
|
heartRegular,
|
||||||
|
heart,
|
||||||
|
musicNode,
|
||||||
|
download,
|
||||||
|
} from "../icons";
|
||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
import useTrackReaction from "../hooks/useTrackReaction";
|
import useTrackReaction from "../hooks/useTrackReaction";
|
||||||
@ -15,259 +34,347 @@ import { LocalStorageKeys } from "../conf";
|
|||||||
import useDownloadQueue from "../hooks/useDownloadQueue";
|
import useDownloadQueue from "../hooks/useDownloadQueue";
|
||||||
|
|
||||||
export const audioPlayer = new NodeMpv(
|
export const audioPlayer = new NodeMpv(
|
||||||
{
|
{
|
||||||
audio_only: true,
|
audio_only: true,
|
||||||
auto_restart: true,
|
auto_restart: true,
|
||||||
time_update: 1,
|
time_update: 1,
|
||||||
binary: process.env.MPV_EXECUTABLE,
|
binary: process.env.MPV_EXECUTABLE,
|
||||||
// debug: true,
|
// debug: true,
|
||||||
// verbose: true,
|
// verbose: true,
|
||||||
},
|
},
|
||||||
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"]
|
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"],
|
||||||
);
|
);
|
||||||
function Player(): ReactElement {
|
function Player(): ReactElement {
|
||||||
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
|
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } =
|
||||||
const { reactToTrack, isFavorite } = useTrackReaction();
|
useContext(playerContext);
|
||||||
|
const { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
|
|
||||||
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
|
const cachedVolume = localStorage.getItem(LocalStorageKeys.volume);
|
||||||
|
|
||||||
const [isPaused, setIsPaused] = useState(true);
|
const [isPaused, setIsPaused] = useState(true);
|
||||||
const [volume, setVolume] = useState<number>(() => (cachedVolume ? parseFloat(cachedVolume) : 55));
|
const [volume, setVolume] = useState<number>(() =>
|
||||||
const [totalDuration, setTotalDuration] = useState<number>(0);
|
cachedVolume ? parseFloat(cachedVolume) : 55,
|
||||||
const [shuffle, setShuffle] = useState<boolean>(false);
|
);
|
||||||
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
|
const [totalDuration, setTotalDuration] = useState<number>(0);
|
||||||
const [isStopped, setIsStopped] = useState<boolean>(false);
|
const [shuffle, setShuffle] = useState<boolean>(false);
|
||||||
const [openLyrics, setOpenLyrics] = useState<boolean>(false);
|
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
|
||||||
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>();
|
const [isStopped, setIsStopped] = useState<boolean>(false);
|
||||||
const { addToQueue, isActiveDownloading, isFinishedDownloading } = useDownloadQueue();
|
const [openLyrics, setOpenLyrics] = useState<boolean>(false);
|
||||||
|
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>();
|
||||||
|
const { addToQueue, isActiveDownloading, isFinishedDownloading } = useDownloadQueue();
|
||||||
|
|
||||||
const playlistTracksIds = currentPlaylist?.tracks?.map((t) => t.track.id) ?? [];
|
const playlistTracksIds = currentPlaylist?.tracks?.map((t) => t.track.id) ?? [];
|
||||||
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
|
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
|
||||||
{
|
{
|
||||||
sliderMoved: (value) => {
|
sliderMoved: (value) => {
|
||||||
setVolume(value);
|
setVolume(value);
|
||||||
},
|
},
|
||||||
sliderReleased: () => {
|
sliderReleased: () => {
|
||||||
localStorage.setItem(LocalStorageKeys.volume, volume.toString());
|
localStorage.setItem(LocalStorageKeys.volume, volume.toString());
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[volume]
|
[volume],
|
||||||
);
|
);
|
||||||
const playerRunning = audioPlayer.isRunning();
|
const playerRunning = audioPlayer.isRunning();
|
||||||
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
|
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
|
||||||
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
|
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
|
||||||
|
|
||||||
// initial Effect
|
// initial Effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (!playerRunning) {
|
if (!playerRunning) {
|
||||||
await audioPlayer.start();
|
await audioPlayer.start();
|
||||||
await audioPlayer.volume(volume);
|
await audioPlayer.volume(volume);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed starting audio player]: ");
|
||||||
|
}
|
||||||
|
})().then(() => {
|
||||||
|
if (cachedPlaylist && !currentPlaylist) {
|
||||||
|
setCurrentPlaylist(JSON.parse(cachedPlaylist));
|
||||||
|
}
|
||||||
|
if (cachedTrack && !currentTrack) {
|
||||||
|
setCurrentTrack(JSON.parse(cachedTrack));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerRunning) {
|
||||||
|
audioPlayer.quit().catch((e: unknown) => console.log(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// track change effect
|
||||||
|
useEffect(() => {
|
||||||
|
// caching current track
|
||||||
|
if (!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
|
||||||
|
else
|
||||||
|
localStorage.setItem(
|
||||||
|
LocalStorageKeys.cachedTrack,
|
||||||
|
JSON.stringify(currentTrack),
|
||||||
|
);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (currentTrack && playerRunning) {
|
||||||
|
const youtubeTrack = await getYoutubeTrack(currentTrack);
|
||||||
|
setCurrentYtTrack(youtubeTrack);
|
||||||
|
await audioPlayer.load(youtubeTrack.youtube_uri, "replace");
|
||||||
|
await audioPlayer.play();
|
||||||
|
setIsPaused(false);
|
||||||
|
}
|
||||||
|
setIsStopped(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.errcode !== 5) {
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
showError(error, "[Failure at track change]: ");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [currentTrack]);
|
||||||
|
|
||||||
|
// changing shuffle to default
|
||||||
|
useEffect(() => {
|
||||||
|
setShuffle(false);
|
||||||
|
// caching playlist
|
||||||
|
if (!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
|
||||||
|
else
|
||||||
|
localStorage.setItem(
|
||||||
|
LocalStorageKeys.cachedPlaylist,
|
||||||
|
JSON.stringify(currentPlaylist),
|
||||||
|
);
|
||||||
|
}, [currentPlaylist]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRunning) {
|
||||||
|
audioPlayer.volume(volume);
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// for monitoring shuffle playlist
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPlaylist) {
|
||||||
|
if (shuffle && realPlaylist.length === 0) {
|
||||||
|
const shuffledTracks = shuffleArray(currentPlaylist.tracks);
|
||||||
|
setRealPlaylist(currentPlaylist.tracks);
|
||||||
|
setCurrentPlaylist({ ...currentPlaylist, tracks: shuffledTracks });
|
||||||
|
} else if (!shuffle && realPlaylist.length > 0) {
|
||||||
|
setCurrentPlaylist({ ...currentPlaylist, tracks: realPlaylist });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shuffle]);
|
||||||
|
|
||||||
|
// live Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRunning) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const statusListener = (status: { property: string; value: any }) => {
|
||||||
|
if (status?.property === "duration") {
|
||||||
|
setTotalDuration(status.value ?? 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stopListener = () => {
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
// go to next track
|
||||||
|
if (
|
||||||
|
currentTrack &&
|
||||||
|
playlistTracksIds &&
|
||||||
|
currentPlaylist?.tracks.length !== 0
|
||||||
|
) {
|
||||||
|
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
|
||||||
|
setCurrentTrack(
|
||||||
|
currentPlaylist?.tracks[
|
||||||
|
index > playlistTracksIds.length - 1 ? 0 : index
|
||||||
|
].track,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pauseListener = () => {
|
||||||
|
setIsPaused(true);
|
||||||
|
};
|
||||||
|
const resumeListener = () => {
|
||||||
|
setIsPaused(false);
|
||||||
|
};
|
||||||
|
audioPlayer.on("status", statusListener);
|
||||||
|
audioPlayer.on("stopped", stopListener);
|
||||||
|
audioPlayer.on("paused", pauseListener);
|
||||||
|
audioPlayer.on("resumed", resumeListener);
|
||||||
|
return () => {
|
||||||
|
audioPlayer.off("status", statusListener);
|
||||||
|
audioPlayer.off("stopped", stopListener);
|
||||||
|
audioPlayer.off("paused", pauseListener);
|
||||||
|
audioPlayer.off("resumed", resumeListener);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
showError(error, "[Failed starting audio player]: ");
|
|
||||||
}
|
|
||||||
})().then(() => {
|
|
||||||
if (cachedPlaylist && !currentPlaylist) {
|
|
||||||
setCurrentPlaylist(JSON.parse(cachedPlaylist));
|
|
||||||
}
|
|
||||||
if (cachedTrack && !currentTrack) {
|
|
||||||
setCurrentTrack(JSON.parse(cachedTrack));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
const handlePlayPause = async () => {
|
||||||
if (playerRunning) {
|
try {
|
||||||
audioPlayer.quit().catch((e: unknown) => console.log(e));
|
if ((await audioPlayer.isPaused()) && playerRunning) {
|
||||||
}
|
await audioPlayer.play();
|
||||||
|
setIsStopped(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
} else {
|
||||||
|
await audioPlayer.pause();
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Track control failed]: ");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
// track change effect
|
const prevOrNext = (constant: number) => {
|
||||||
useEffect(() => {
|
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
||||||
// caching current track
|
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
|
||||||
if(!currentTrack) localStorage.removeItem(LocalStorageKeys.cachedTrack);
|
setCurrentTrack(
|
||||||
else localStorage.setItem(LocalStorageKeys.cachedTrack, JSON.stringify(currentTrack));
|
currentPlaylist.tracks[
|
||||||
|
index > playlistTracksIds?.length - 1
|
||||||
(async () => {
|
? 0
|
||||||
try {
|
: index < 0
|
||||||
if (currentTrack && playerRunning) {
|
? playlistTracksIds.length - 1
|
||||||
const youtubeTrack = await getYoutubeTrack(currentTrack);
|
: index
|
||||||
setCurrentYtTrack(youtubeTrack);
|
].track,
|
||||||
await audioPlayer.load(youtubeTrack.youtube_uri, "replace");
|
);
|
||||||
await audioPlayer.play();
|
|
||||||
setIsPaused(false);
|
|
||||||
}
|
}
|
||||||
setIsStopped(false);
|
};
|
||||||
} catch (error) {
|
|
||||||
if (error.errcode !== 5) {
|
async function stopPlayback() {
|
||||||
setIsStopped(true);
|
try {
|
||||||
setIsPaused(true);
|
if (playerRunning) {
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
await audioPlayer.stop();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed at audio-player stop]: ");
|
||||||
}
|
}
|
||||||
showError(error, "[Failure at track change]: ");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [currentTrack]);
|
|
||||||
|
|
||||||
// changing shuffle to default
|
|
||||||
useEffect(() => {
|
|
||||||
setShuffle(false);
|
|
||||||
// caching playlist
|
|
||||||
if(!currentPlaylist) localStorage.removeItem(LocalStorageKeys.cachedPlaylist);
|
|
||||||
else localStorage.setItem(LocalStorageKeys.cachedPlaylist, JSON.stringify(currentPlaylist));
|
|
||||||
}, [currentPlaylist]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (playerRunning) {
|
|
||||||
audioPlayer.volume(volume);
|
|
||||||
}
|
}
|
||||||
}, [volume]);
|
|
||||||
|
|
||||||
// for monitoring shuffle playlist
|
const artistsNames = currentTrack?.artists?.map((x) => x.name);
|
||||||
useEffect(() => {
|
return (
|
||||||
if (currentPlaylist) {
|
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
|
||||||
if (shuffle && realPlaylist.length === 0) {
|
<GridRow>
|
||||||
const shuffledTracks = shuffleArray(currentPlaylist.tracks);
|
<GridColumn width={2}>
|
||||||
setRealPlaylist(currentPlaylist.tracks);
|
<Text wordWrap openExternalLinks>
|
||||||
setCurrentPlaylist({ ...currentPlaylist, tracks: shuffledTracks });
|
{artistsNames && currentTrack
|
||||||
} else if (!shuffle && realPlaylist.length > 0) {
|
? `
|
||||||
setCurrentPlaylist({ ...currentPlaylist, tracks: realPlaylist });
|
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${
|
||||||
}
|
currentTrack.name
|
||||||
}
|
}</a></b> - ${artistsNames[0]} ${
|
||||||
}, [shuffle]);
|
artistsNames.length > 1
|
||||||
|
? "feat. " + artistsNames.slice(1).join(", ")
|
||||||
// live Effect
|
: ""
|
||||||
useEffect(() => {
|
}</p>
|
||||||
if (playerRunning) {
|
|
||||||
const statusListener = (status: { property: string; value: any }) => {
|
|
||||||
if (status?.property === "duration") {
|
|
||||||
setTotalDuration(status.value ?? 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const stopListener = () => {
|
|
||||||
setIsStopped(true);
|
|
||||||
setIsPaused(true);
|
|
||||||
// go to next track
|
|
||||||
if (currentTrack && playlistTracksIds && currentPlaylist?.tracks.length !== 0) {
|
|
||||||
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
|
|
||||||
setCurrentTrack(currentPlaylist?.tracks[index > playlistTracksIds.length - 1 ? 0 : index].track);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const pauseListener = () => {
|
|
||||||
setIsPaused(true);
|
|
||||||
};
|
|
||||||
const resumeListener = () => {
|
|
||||||
setIsPaused(false);
|
|
||||||
};
|
|
||||||
audioPlayer.on("status", statusListener);
|
|
||||||
audioPlayer.on("stopped", stopListener);
|
|
||||||
audioPlayer.on("paused", pauseListener);
|
|
||||||
audioPlayer.on("resumed", resumeListener);
|
|
||||||
return () => {
|
|
||||||
audioPlayer.off("status", statusListener);
|
|
||||||
audioPlayer.off("stopped", stopListener);
|
|
||||||
audioPlayer.off("paused", pauseListener);
|
|
||||||
audioPlayer.off("resumed", resumeListener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
|
||||||
try {
|
|
||||||
if ((await audioPlayer.isPaused()) && playerRunning) {
|
|
||||||
await audioPlayer.play();
|
|
||||||
setIsStopped(false);
|
|
||||||
setIsPaused(false);
|
|
||||||
} else {
|
|
||||||
await audioPlayer.pause();
|
|
||||||
setIsStopped(true);
|
|
||||||
setIsPaused(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error, "[Track control failed]: ");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevOrNext = (constant: number) => {
|
|
||||||
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
|
||||||
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
|
|
||||||
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function stopPlayback() {
|
|
||||||
try {
|
|
||||||
if (playerRunning) {
|
|
||||||
setCurrentTrack(undefined);
|
|
||||||
setCurrentPlaylist(undefined);
|
|
||||||
await audioPlayer.stop();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error, "[Failed at audio-player stop]: ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistsNames = currentTrack?.artists?.map((x) => x.name);
|
|
||||||
return (
|
|
||||||
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
|
|
||||||
<GridRow>
|
|
||||||
<GridColumn width={2}>
|
|
||||||
<Text wordWrap openExternalLinks>
|
|
||||||
{artistsNames && currentTrack
|
|
||||||
? `
|
|
||||||
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${currentTrack.name}</a></b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "feat. " + artistsNames.slice(1).join(", ") : ""}</p>
|
|
||||||
`
|
`
|
||||||
: `<b>Oh, dear don't waste time</b>`}
|
: `<b>Oh, dear don't waste time</b>`}
|
||||||
</Text>
|
</Text>
|
||||||
</GridColumn>
|
</GridColumn>
|
||||||
<GridColumn width={4}>
|
<GridColumn width={4}>
|
||||||
<BoxView direction={Direction.TopToBottom} style={`max-width: 600px; min-width: 380px;`}>
|
<BoxView
|
||||||
{currentTrack && <ManualLyricDialog open={openLyrics} track={currentTrack} />}
|
direction={Direction.TopToBottom}
|
||||||
<PlayerProgressBar audioPlayer={audioPlayer} totalDuration={totalDuration} />
|
style={`max-width: 600px; min-width: 380px;`}
|
||||||
|
>
|
||||||
|
{currentTrack && (
|
||||||
|
<ManualLyricDialog open={openLyrics} track={currentTrack} />
|
||||||
|
)}
|
||||||
|
<PlayerProgressBar
|
||||||
|
audioPlayer={audioPlayer}
|
||||||
|
totalDuration={totalDuration}
|
||||||
|
/>
|
||||||
|
|
||||||
<BoxView direction={Direction.LeftToRight}>
|
<BoxView direction={Direction.LeftToRight}>
|
||||||
<IconButton style={`background-color: ${shuffle ? "orange" : "rgba(255, 255, 255, 0.055)"}`} on={{ clicked: () => setShuffle(!shuffle) }} icon={new QIcon(shuffleIcon)} />
|
<IconButton
|
||||||
<IconButton on={{ clicked: () => prevOrNext(-1) }} icon={new QIcon(backward)} />
|
style={`background-color: ${
|
||||||
<IconButton on={{ clicked: handlePlayPause }} icon={new QIcon(isStopped || isPaused || !currentTrack ? play : pause)} />
|
shuffle ? "orange" : "rgba(255, 255, 255, 0.055)"
|
||||||
<IconButton on={{ clicked: () => prevOrNext(1) }} icon={new QIcon(forward)} />
|
}`}
|
||||||
<IconButton icon={new QIcon(stop)} on={{ clicked: stopPlayback }} />
|
on={{ clicked: () => setShuffle(!shuffle) }}
|
||||||
</BoxView>
|
icon={new QIcon(shuffleIcon)}
|
||||||
</BoxView>
|
/>
|
||||||
</GridColumn>
|
<IconButton
|
||||||
<GridColumn width={2}>
|
on={{ clicked: () => prevOrNext(-1) }}
|
||||||
<BoxView>
|
icon={new QIcon(backward)}
|
||||||
<IconButton
|
/>
|
||||||
style={isActiveDownloading() && !isFinishedDownloading() ? "background-color: green;" : ""}
|
<IconButton
|
||||||
enabled={!!currentYtTrack}
|
on={{ clicked: handlePlayPause }}
|
||||||
icon={new QIcon(download)}
|
icon={
|
||||||
on={{
|
new QIcon(
|
||||||
clicked() {
|
isStopped || isPaused || !currentTrack
|
||||||
currentYtTrack && addToQueue(currentYtTrack);
|
? play
|
||||||
},
|
: pause,
|
||||||
}}
|
)
|
||||||
/>
|
}
|
||||||
<IconButton
|
/>
|
||||||
on={{
|
<IconButton
|
||||||
clicked() {
|
on={{ clicked: () => prevOrNext(1) }}
|
||||||
if (currentTrack) {
|
icon={new QIcon(forward)}
|
||||||
reactToTrack({ added_at: Date.now().toString(), track: currentTrack });
|
/>
|
||||||
}
|
<IconButton
|
||||||
},
|
icon={new QIcon(stop)}
|
||||||
}}
|
on={{ clicked: stopPlayback }}
|
||||||
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : heartRegular)}
|
/>
|
||||||
/>
|
</BoxView>
|
||||||
<IconButton
|
</BoxView>
|
||||||
style={openLyrics ? "background-color: green;" : ""}
|
</GridColumn>
|
||||||
icon={new QIcon(musicNode)}
|
<GridColumn width={2}>
|
||||||
on={{ clicked: () => currentTrack && setOpenLyrics(!openLyrics) }}
|
<BoxView>
|
||||||
/>
|
<IconButton
|
||||||
<Slider minSize={{ height: 20, width: 80 }} maxSize={{ height: 20, width: 100 }} hasTracking sliderPosition={volume} on={volumeHandler} orientation={Orientation.Horizontal} />
|
style={
|
||||||
</BoxView>
|
isActiveDownloading() && !isFinishedDownloading()
|
||||||
</GridColumn>
|
? "background-color: green;"
|
||||||
</GridRow>
|
: ""
|
||||||
</GridView>
|
}
|
||||||
);
|
enabled={!!currentYtTrack}
|
||||||
|
icon={new QIcon(download)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
currentYtTrack && addToQueue(currentYtTrack);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
if (currentTrack) {
|
||||||
|
reactToTrack({
|
||||||
|
added_at: Date.now().toString(),
|
||||||
|
track: currentTrack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
new QIcon(
|
||||||
|
isFavorite(currentTrack?.id ?? "")
|
||||||
|
? heart
|
||||||
|
: heartRegular,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
style={openLyrics ? "background-color: green;" : ""}
|
||||||
|
icon={new QIcon(musicNode)}
|
||||||
|
on={{
|
||||||
|
clicked: () => currentTrack && setOpenLyrics(!openLyrics),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
minSize={{ height: 20, width: 80 }}
|
||||||
|
maxSize={{ height: 20, width: 100 }}
|
||||||
|
hasTracking
|
||||||
|
sliderPosition={volume}
|
||||||
|
on={volumeHandler}
|
||||||
|
orientation={Orientation.Horizontal}
|
||||||
|
/>
|
||||||
|
</BoxView>
|
||||||
|
</GridColumn>
|
||||||
|
</GridRow>
|
||||||
|
</GridView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Player;
|
export default Player;
|
||||||
|
@ -5,58 +5,70 @@ import React, { useContext, useEffect, useState } from "react";
|
|||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
|
|
||||||
interface PlayerProgressBarProps {
|
interface PlayerProgressBarProps {
|
||||||
audioPlayer: NodeMpv;
|
audioPlayer: NodeMpv;
|
||||||
totalDuration: number;
|
totalDuration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
|
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
|
||||||
const { currentTrack } = useContext(playerContext);
|
const { currentTrack } = useContext(playerContext);
|
||||||
const [trackTime, setTrackTime] = useState<number>(0);
|
const [trackTime, setTrackTime] = useState<number>(0);
|
||||||
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
|
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
|
||||||
{
|
{
|
||||||
sliderMoved: (value) => {
|
sliderMoved: (value) => {
|
||||||
if (audioPlayer.isRunning() && currentTrack) {
|
if (audioPlayer.isRunning() && currentTrack) {
|
||||||
const newPosition = (totalDuration * value) / 100;
|
const newPosition = (totalDuration * value) / 100;
|
||||||
setTrackTime(parseInt(newPosition.toString()));
|
setTrackTime(parseInt(newPosition.toString()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sliderReleased: () => {
|
sliderReleased: () => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await audioPlayer.goToPosition(trackTime);
|
await audioPlayer.goToPosition(trackTime);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[currentTrack, totalDuration, trackTime]
|
[currentTrack, totalDuration, trackTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const progressListener = (seconds: number) => {
|
const progressListener = (seconds: number) => {
|
||||||
setTrackTime(seconds);
|
setTrackTime(seconds);
|
||||||
};
|
};
|
||||||
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
|
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
|
||||||
if (property === "filename") {
|
if (property === "filename") {
|
||||||
setTrackTime(0);
|
setTrackTime(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
audioPlayer.on("status", statusListener);
|
audioPlayer.on("status", statusListener);
|
||||||
audioPlayer.on("timeposition", progressListener);
|
audioPlayer.on("timeposition", progressListener);
|
||||||
return () => {
|
return () => {
|
||||||
audioPlayer.off("status", statusListener);
|
audioPlayer.off("status", statusListener);
|
||||||
audioPlayer.off("timeposition", progressListener);
|
audioPlayer.off("timeposition", progressListener);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const playbackPercentage = totalDuration > 0 ? (trackTime * 100) / totalDuration : 0;
|
const playbackPercentage = totalDuration > 0 ? (trackTime * 100) / totalDuration : 0;
|
||||||
const playbackTime = new Date(trackTime * 1000).toISOString().substr(14, 5) + "/" + new Date(totalDuration * 1000).toISOString().substr(14, 5)
|
const playbackTime =
|
||||||
return (
|
new Date(trackTime * 1000).toISOString().substr(14, 5) +
|
||||||
<BoxView direction={Direction.LeftToRight} style={`padding: 20px 0px; flex-direction: row;`}>
|
"/" +
|
||||||
<Slider enabled={!!currentTrack || trackTime > 0} on={trackSliderEvents} sliderPosition={playbackPercentage} hasTracking orientation={Orientation.Horizontal} />
|
new Date(totalDuration * 1000).toISOString().substr(14, 5);
|
||||||
<Text>{playbackTime}</Text>
|
return (
|
||||||
</BoxView>
|
<BoxView
|
||||||
);
|
direction={Direction.LeftToRight}
|
||||||
|
style={`padding: 20px 0px; flex-direction: row;`}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
enabled={!!currentTrack || trackTime > 0}
|
||||||
|
on={trackSliderEvents}
|
||||||
|
sliderPosition={playbackPercentage}
|
||||||
|
hasTracking
|
||||||
|
orientation={Orientation.Horizontal}
|
||||||
|
/>
|
||||||
|
<Text>{playbackTime}</Text>
|
||||||
|
</BoxView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlayerProgressBar;
|
export default PlayerProgressBar;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { QAbstractButtonSignals } from "@nodegui/nodegui";
|
import { QAbstractButtonSignals } from "@nodegui/nodegui";
|
||||||
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { RefetchOptions } from "react-query";
|
||||||
import { useLocation, useParams } from "react-router";
|
import { useLocation, useParams } from "react-router";
|
||||||
import { QueryCacheKeys } from "../conf";
|
import { QueryCacheKeys } from "../conf";
|
||||||
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
@ -9,61 +10,79 @@ import PlaceholderApplet from "./shared/PlaceholderApplet";
|
|||||||
import PlaylistCard from "./shared/PlaylistCard";
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
|
||||||
function PlaylistGenreView() {
|
function PlaylistGenreView() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const location = useLocation<{ name: string }>();
|
const location = useLocation<{ name: string }>();
|
||||||
const { data: pagedPlaylists, isError, isLoading, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>>(
|
const {
|
||||||
[QueryCacheKeys.genrePlaylists, id],
|
data: pagedPlaylists,
|
||||||
async (spotifyApi, { pageParam }) => {
|
isError,
|
||||||
const option = { limit: 20, offset: pageParam };
|
isLoading,
|
||||||
let res;
|
refetch,
|
||||||
if (id === "featured") {
|
hasNextPage,
|
||||||
res = await spotifyApi.getFeaturedPlaylists(option);
|
isFetchingNextPage,
|
||||||
} else {
|
fetchNextPage,
|
||||||
res = await spotifyApi.getPlaylistsForCategory(id, option);
|
} = useSpotifyInfiniteQuery<
|
||||||
}
|
SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>
|
||||||
return res.body.playlists;
|
>(
|
||||||
},
|
[QueryCacheKeys.genrePlaylists, id],
|
||||||
{
|
async (spotifyApi, { pageParam }) => {
|
||||||
getNextPageParam(lastPage) {
|
const option = { limit: 20, offset: pageParam };
|
||||||
if (lastPage.next) {
|
let res;
|
||||||
return lastPage.offset + lastPage.limit;
|
if (id === "featured") {
|
||||||
}
|
res = await spotifyApi.getFeaturedPlaylists(option);
|
||||||
},
|
} else {
|
||||||
}
|
res = await spotifyApi.getPlaylistsForCategory(id, option);
|
||||||
);
|
}
|
||||||
|
return res.body.playlists;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const playlists = pagedPlaylists?.pages
|
const playlists = pagedPlaylists?.pages
|
||||||
.map((page) => page.items)
|
.map((page) => page.items)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.flat(1);
|
.flat(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenreView
|
<GenreView
|
||||||
isError={isError}
|
isError={isError}
|
||||||
isLoading={isLoading || isFetchingNextPage}
|
isLoading={isLoading || isFetchingNextPage}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
heading={location.state.name}
|
heading={location.state.name}
|
||||||
playlists={playlists ?? []}
|
playlists={playlists ?? []}
|
||||||
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
||||||
isLoadable={!isFetchingNextPage}
|
isLoadable={!isFetchingNextPage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlaylistGenreView;
|
export default PlaylistGenreView;
|
||||||
|
|
||||||
interface GenreViewProps {
|
interface GenreViewProps {
|
||||||
heading: string;
|
heading: string;
|
||||||
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
||||||
loadMore?: QAbstractButtonSignals["clicked"];
|
loadMore?: QAbstractButtonSignals["clicked"];
|
||||||
isLoadable?: boolean;
|
isLoadable?: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refetch: Function;
|
refetch: (options?: RefetchOptions | undefined) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: GenreViewProps) {
|
export function GenreView({
|
||||||
const playlistGenreViewStylesheet = `
|
heading,
|
||||||
|
playlists,
|
||||||
|
loadMore,
|
||||||
|
isLoadable,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
}: GenreViewProps) {
|
||||||
|
const playlistGenreViewStylesheet = `
|
||||||
#genre-container{
|
#genre-container{
|
||||||
flex-direction: 'column';
|
flex-direction: 'column';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -83,19 +102,32 @@ export function GenreView({ heading, playlists, loadMore, isLoadable, isError, i
|
|||||||
width: 330px;
|
width: 330px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
return (
|
return (
|
||||||
<View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
|
<View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
|
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
|
||||||
<ScrollArea id="scroll-view">
|
<ScrollArea id="scroll-view">
|
||||||
<View id="child-container">
|
<View id="child-container">
|
||||||
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed loading ${heading}'s playlists`} />
|
<PlaceholderApplet
|
||||||
{playlists?.map((playlist, index) => {
|
error={isError}
|
||||||
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
loading={isLoading}
|
||||||
})}
|
reload={refetch}
|
||||||
{loadMore && <Button text="Load more" on={{ clicked: loadMore }} enabled={isLoadable} />}
|
message={`Failed loading ${heading}'s playlists`}
|
||||||
|
/>
|
||||||
|
{playlists?.map((playlist, index) => {
|
||||||
|
return (
|
||||||
|
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{loadMore && (
|
||||||
|
<Button
|
||||||
|
text="Load more"
|
||||||
|
on={{ clicked: loadMore }}
|
||||||
|
enabled={isLoadable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -14,94 +14,130 @@ import { TrackButton } from "./shared/TrackButton";
|
|||||||
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
|
||||||
export interface PlaylistTrackRes {
|
export interface PlaylistTrackRes {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistView: FC = () => {
|
const PlaylistView: FC = () => {
|
||||||
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
|
||||||
const params = useParams<{ id: string }>();
|
useContext(playerContext);
|
||||||
const location = useLocation<{ name: string; thumbnail: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
|
const location = useLocation<{ name: string; thumbnail: string }>();
|
||||||
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) =>
|
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
|
||||||
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body)
|
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>(
|
||||||
);
|
[QueryCacheKeys.categoryPlaylists, params.id],
|
||||||
const { data: tracks, isSuccess, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
|
(spotifyApi) =>
|
||||||
[QueryCacheKeys.playlistTracks, params.id],
|
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body),
|
||||||
(spotifyApi) => spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
|
);
|
||||||
{ initialData: [] }
|
const {
|
||||||
);
|
data: tracks,
|
||||||
|
isSuccess,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
|
||||||
|
[QueryCacheKeys.playlistTracks, params.id],
|
||||||
|
(spotifyApi) =>
|
||||||
|
spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
|
||||||
|
{ initialData: [] },
|
||||||
|
);
|
||||||
|
|
||||||
const handlePlaylistPlayPause = () => {
|
const handlePlaylistPlayPause = () => {
|
||||||
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
|
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
|
||||||
setCurrentPlaylist({ ...params, ...location.state, tracks });
|
setCurrentPlaylist({ ...params, ...location.state, tracks });
|
||||||
setCurrentTrack(tracks[0].track);
|
setCurrentTrack(tracks[0].track);
|
||||||
} else {
|
} else {
|
||||||
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
|
audioPlayer
|
||||||
setCurrentTrack(undefined);
|
.stop()
|
||||||
setCurrentPlaylist(undefined);
|
.catch((error) => console.error("Failed to stop audio player: ", error));
|
||||||
}
|
setCurrentTrack(undefined);
|
||||||
};
|
setCurrentPlaylist(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={`flex: 1; flex-direction: 'column';`}>
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
<PlaylistSimpleControls
|
<PlaylistSimpleControls
|
||||||
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
|
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
|
||||||
handlePlaylistPlayPause={handlePlaylistPlayPause}
|
handlePlaylistPlayPause={handlePlaylistPlayPause}
|
||||||
isActive={currentPlaylist?.id === params.id}
|
isActive={currentPlaylist?.id === params.id}
|
||||||
isFavorite={isFavorite(params.id)}
|
isFavorite={isFavorite(params.id)}
|
||||||
/>
|
/>
|
||||||
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(
|
||||||
{<TrackTableIndex />}
|
1,
|
||||||
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
)}</h2></center>`}</Text>
|
||||||
<View style={`flex-direction:column; flex: 1;`}>
|
{<TrackTableIndex />}
|
||||||
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} />
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
{tracks?.map(({ track }, index) => {
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
if (track) {
|
<PlaceholderApplet
|
||||||
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />;
|
error={isError}
|
||||||
}
|
loading={isLoading}
|
||||||
})}
|
reload={refetch}
|
||||||
|
message={`Failed retrieving ${location.state.name} tracks`}
|
||||||
|
/>
|
||||||
|
{tracks?.map(({ track }, index) => {
|
||||||
|
if (track) {
|
||||||
|
return (
|
||||||
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
track={track}
|
||||||
|
index={index}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PlaylistView;
|
export default PlaylistView;
|
||||||
|
|
||||||
export function TrackTableIndex() {
|
export function TrackTableIndex() {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
|
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
|
||||||
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
|
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
|
||||||
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
|
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
|
||||||
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
|
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
|
||||||
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
|
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
interface PlaylistSimpleControlsProps {
|
interface PlaylistSimpleControlsProps {
|
||||||
handlePlaylistPlayPause: (index?: number) => void;
|
handlePlaylistPlayPause: (index?: number) => void;
|
||||||
handlePlaylistReact?: () => void;
|
handlePlaylistReact?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) {
|
export function PlaylistSimpleControls({
|
||||||
return (
|
handlePlaylistPlayPause,
|
||||||
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
|
isActive,
|
||||||
<BackButton />
|
handlePlaylistReact,
|
||||||
{isFavorite !== undefined && <IconButton icon={new QIcon(isFavorite ? heart : heartRegular)} on={{ clicked: handlePlaylistReact }} />}
|
isFavorite,
|
||||||
<IconButton
|
}: PlaylistSimpleControlsProps) {
|
||||||
style={`background-color: #00be5f; color: white;`}
|
return (
|
||||||
on={{
|
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
|
||||||
clicked() {
|
<BackButton />
|
||||||
handlePlaylistPlayPause();
|
{isFavorite !== undefined && (
|
||||||
},
|
<IconButton
|
||||||
}}
|
icon={new QIcon(isFavorite ? heart : heartRegular)}
|
||||||
icon={new QIcon(isActive ? stop : play)}
|
on={{ clicked: handlePlaylistReact }}
|
||||||
/>
|
/>
|
||||||
</View>
|
)}
|
||||||
);
|
<IconButton
|
||||||
|
style={`background-color: #00be5f; color: white;`}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
handlePlaylistPlayPause();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={new QIcon(isActive ? stop : play)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -13,83 +13,122 @@ import PlaylistCard from "./shared/PlaylistCard";
|
|||||||
import { TrackButton } from "./shared/TrackButton";
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
const history = useHistory<{ searchQuery: string }>();
|
const history = useHistory<{ searchQuery: string }>();
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
const { data: searchResults, refetch, isError, isLoading } = useSpotifyQuery<SpotifyApi.SearchResponse>(
|
const {
|
||||||
QueryCacheKeys.search,
|
data: searchResults,
|
||||||
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
|
refetch,
|
||||||
{ enabled: false }
|
isError,
|
||||||
);
|
isLoading,
|
||||||
|
} = useSpotifyQuery<SpotifyApi.SearchResponse>(
|
||||||
|
QueryCacheKeys.search,
|
||||||
|
(spotifyApi) =>
|
||||||
|
spotifyApi
|
||||||
|
.search(searchQuery, ["playlist", "track"], { limit: 4 })
|
||||||
|
.then((res) => res.body),
|
||||||
|
{ enabled: false },
|
||||||
|
);
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
try {
|
try {
|
||||||
await refetch();
|
await refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, "[Failed to search through spotify]: ");
|
showError(error, "[Failed to search through spotify]: ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
|
const placeholder = (
|
||||||
return (
|
<PlaceholderApplet
|
||||||
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
|
error={isError}
|
||||||
<View>
|
loading={isLoading}
|
||||||
<LineEdit
|
message="Failed querying spotify"
|
||||||
style="width: '65%'; margin: 5px;"
|
reload={refetch}
|
||||||
placeholderText="Search spotify"
|
|
||||||
on={{
|
|
||||||
textChanged(t) {
|
|
||||||
setSearchQuery(t);
|
|
||||||
},
|
|
||||||
KeyRelease(native: any) {
|
|
||||||
const key = new QKeyEvent(native);
|
|
||||||
const isEnter = key.key() === 16777220;
|
|
||||||
if (isEnter) {
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} />
|
);
|
||||||
</View>
|
return (
|
||||||
<ScrollArea style="flex: 1;">
|
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
|
||||||
<View style="flex-direction: 'column'; flex: 1;">
|
<View>
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<LineEdit
|
||||||
<Text
|
style="width: '65%'; margin: 5px;"
|
||||||
cursor={CursorShape.PointingHandCursor}
|
placeholderText="Search spotify"
|
||||||
on={{
|
on={{
|
||||||
MouseButtonRelease(native: any) {
|
textChanged(t) {
|
||||||
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) {
|
setSearchQuery(t);
|
||||||
history.push("/search/songs", { searchQuery });
|
},
|
||||||
}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
},
|
KeyRelease(native: any) {
|
||||||
}}>{`<h2>Songs</h2>`}</Text>
|
const key = new QKeyEvent(native);
|
||||||
<TrackTableIndex />
|
const isEnter = key.key() === 16777220;
|
||||||
{placeholder}
|
if (isEnter) {
|
||||||
{searchResults?.tracks?.items.map((track, index) => (
|
handleSearch();
|
||||||
<TrackButton key={index + track.id} index={index} track={track} />
|
}
|
||||||
))}
|
},
|
||||||
</View>
|
}}
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
/>
|
||||||
<Text
|
<IconButton
|
||||||
cursor={CursorShape.PointingHandCursor}
|
enabled={searchQuery.length > 0}
|
||||||
on={{
|
icon={new QIcon(search)}
|
||||||
MouseButtonRelease(native: any) {
|
on={{ clicked: handleSearch }}
|
||||||
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) {
|
/>
|
||||||
history.push("/search/playlists", { searchQuery });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}>{`<h2>Playlists</h2>`}</Text>
|
|
||||||
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
|
|
||||||
{placeholder}
|
|
||||||
{searchResults?.playlists?.items.map((playlist, index) => (
|
|
||||||
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<ScrollArea style="flex: 1;">
|
||||||
|
<View style="flex-direction: 'column'; flex: 1;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
on={{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (
|
||||||
|
new QMouseEvent(native).button() === 1 &&
|
||||||
|
searchResults?.tracks
|
||||||
|
) {
|
||||||
|
history.push("/search/songs", { searchQuery });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>{`<h2>Songs</h2>`}</Text>
|
||||||
|
<TrackTableIndex />
|
||||||
|
{placeholder}
|
||||||
|
{searchResults?.tracks?.items.map((track, index) => (
|
||||||
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
index={index}
|
||||||
|
track={track}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
on={{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (
|
||||||
|
new QMouseEvent(native).button() === 1 &&
|
||||||
|
searchResults?.playlists
|
||||||
|
) {
|
||||||
|
history.push("/search/playlists", {
|
||||||
|
searchQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>{`<h2>Playlists</h2>`}</Text>
|
||||||
|
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
|
||||||
|
{placeholder}
|
||||||
|
{searchResults?.playlists?.items.map((playlist, index) => (
|
||||||
|
<PlaylistCard
|
||||||
|
key={index + playlist.id}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Search;
|
export default Search;
|
||||||
|
@ -5,34 +5,51 @@ import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
|||||||
import { GenreView } from "./PlaylistGenreView";
|
import { GenreView } from "./PlaylistGenreView";
|
||||||
|
|
||||||
function SearchResultPlaylistCollection() {
|
function SearchResultPlaylistCollection() {
|
||||||
const location = useLocation<{ searchQuery: string }>();
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
const {
|
||||||
QueryCacheKeys.searchPlaylist,
|
data: searchResults,
|
||||||
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
fetchNextPage,
|
||||||
{
|
hasNextPage,
|
||||||
getNextPageParam: (lastPage) => {
|
isFetchingNextPage,
|
||||||
if (lastPage.playlists?.next) {
|
isError,
|
||||||
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0);
|
isLoading,
|
||||||
}
|
refetch,
|
||||||
},
|
} = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
}
|
QueryCacheKeys.searchPlaylist,
|
||||||
);
|
(spotifyApi, { pageParam }) =>
|
||||||
return (
|
spotifyApi
|
||||||
<GenreView
|
.searchPlaylists(location.state.searchQuery, {
|
||||||
isError={isError}
|
limit: 20,
|
||||||
isLoading={isLoading || isFetchingNextPage}
|
offset: pageParam,
|
||||||
refetch={refetch}
|
})
|
||||||
heading={"Search: " + location.state.searchQuery}
|
.then((res) => res.body),
|
||||||
playlists={
|
{
|
||||||
(searchResults?.pages
|
getNextPageParam: (lastPage) => {
|
||||||
?.map((page) => page.playlists?.items)
|
if (lastPage.playlists?.next) {
|
||||||
.filter(Boolean)
|
return (
|
||||||
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
|
(lastPage.playlists?.offset ?? 0) +
|
||||||
}
|
(lastPage.playlists?.limit ?? 0)
|
||||||
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
);
|
||||||
isLoadable={!isFetchingNextPage}
|
}
|
||||||
/>
|
},
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<GenreView
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading || isFetchingNextPage}
|
||||||
|
refetch={refetch}
|
||||||
|
heading={"Search: " + location.state.searchQuery}
|
||||||
|
playlists={
|
||||||
|
(searchResults?.pages
|
||||||
|
?.map((page) => page.playlists?.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
|
||||||
|
}
|
||||||
|
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
||||||
|
isLoadable={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SearchResultPlaylistCollection;
|
export default SearchResultPlaylistCollection;
|
||||||
|
@ -8,51 +8,79 @@ import PlaceholderApplet from "./shared/PlaceholderApplet";
|
|||||||
import { TrackButton } from "./shared/TrackButton";
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
function SearchResultSongsCollection() {
|
function SearchResultSongsCollection() {
|
||||||
const location = useLocation<{ searchQuery: string }>();
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
const {
|
||||||
QueryCacheKeys.searchSongs,
|
data: searchResults,
|
||||||
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
fetchNextPage,
|
||||||
{
|
hasNextPage,
|
||||||
getNextPageParam: (lastPage) => {
|
isFetchingNextPage,
|
||||||
if (lastPage.tracks?.next) {
|
isError,
|
||||||
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
|
isLoading,
|
||||||
}
|
refetch,
|
||||||
},
|
} = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
}
|
QueryCacheKeys.searchSongs,
|
||||||
);
|
(spotifyApi, { pageParam }) =>
|
||||||
return (
|
spotifyApi
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
.searchTracks(location.state.searchQuery, {
|
||||||
<Text>{`
|
limit: 20,
|
||||||
|
offset: pageParam,
|
||||||
|
})
|
||||||
|
.then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.tracks?.next) {
|
||||||
|
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text>{`
|
||||||
<center>
|
<center>
|
||||||
<h2>Search: ${location.state.searchQuery}</h2>
|
<h2>Search: ${location.state.searchQuery}</h2>
|
||||||
</center>
|
</center>
|
||||||
`}</Text>
|
`}</Text>
|
||||||
<TrackTableIndex />
|
<TrackTableIndex />
|
||||||
<ScrollArea style="flex: 1; border: none;">
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
<View style="flex: 1; flex-direction: 'column';">
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
<PlaceholderApplet
|
||||||
{searchResults?.pages
|
error={isError}
|
||||||
.map((searchResult) => searchResult.tracks?.items)
|
loading={isLoading || isFetchingNextPage}
|
||||||
.filter(Boolean)
|
message="Failed querying spotify"
|
||||||
.flat(1)
|
reload={refetch}
|
||||||
.map((track, index) => track && <TrackButton key={index + track.id} index={index} track={track} />)}
|
/>
|
||||||
|
{searchResults?.pages
|
||||||
|
.map((searchResult) => searchResult.tracks?.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1)
|
||||||
|
.map(
|
||||||
|
(track, index) =>
|
||||||
|
track && (
|
||||||
|
<TrackButton
|
||||||
|
key={index + track.id}
|
||||||
|
index={index}
|
||||||
|
track={track}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{hasNextPage && (
|
{hasNextPage && (
|
||||||
<Button
|
<Button
|
||||||
style="flex-grow: 0; align-self: 'center';"
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
text="Load more"
|
text="Load more"
|
||||||
on={{
|
on={{
|
||||||
clicked() {
|
clicked() {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
enabled={!isFetchingNextPage}
|
enabled={!isFetchingNextPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
</View>
|
</View>
|
||||||
</ScrollArea>
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SearchResultSongsCollection;
|
export default SearchResultSongsCollection;
|
||||||
|
@ -4,41 +4,48 @@ import preferencesContext from "../context/preferencesContext";
|
|||||||
import Switch, { SwitchProps } from "./shared/Switch";
|
import Switch, { SwitchProps } from "./shared/Switch";
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
const { setPreferences, ...preferences } = useContext(preferencesContext)
|
const { setPreferences, ...preferences } = useContext(preferencesContext);
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; flex-direction: 'column'; justify-content: 'flex-start';">
|
<View style="flex: 1; flex-direction: 'column'; justify-content: 'flex-start';">
|
||||||
<Text>{`<center><h2>Settings</h2></center>`}</Text>
|
<Text>{`<center><h2>Settings</h2></center>`}</Text>
|
||||||
<View style="width: '100%'; flex-direction: 'column'; justify-content: 'flex-start';">
|
<View style="width: '100%'; flex-direction: 'column'; justify-content: 'flex-start';">
|
||||||
<SettingsCheckTile
|
<SettingsCheckTile
|
||||||
checked={preferences.playlistImages}
|
checked={preferences.playlistImages}
|
||||||
title="Use images instead of colors for playlist"
|
title="Use images instead of colors for playlist"
|
||||||
subtitle="This will increase memory usage"
|
subtitle="This will increase memory usage"
|
||||||
onChange={(checked) => setPreferences({ ...preferences, playlistImages: checked })}
|
onChange={(checked) =>
|
||||||
/>
|
setPreferences({ ...preferences, playlistImages: checked })
|
||||||
</View>
|
}
|
||||||
</View>
|
/>
|
||||||
);
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|
||||||
interface SettingsCheckTileProps {
|
interface SettingsCheckTileProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?: SwitchProps["onChange"];
|
onChange?: SwitchProps["onChange"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsCheckTile({ title, subtitle = "", onChange, checked }: SettingsCheckTileProps) {
|
export function SettingsCheckTile({
|
||||||
return (
|
title,
|
||||||
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';">
|
subtitle = "",
|
||||||
<Text>
|
onChange,
|
||||||
{`
|
checked,
|
||||||
|
}: SettingsCheckTileProps) {
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';">
|
||||||
|
<Text>
|
||||||
|
{`
|
||||||
<b>${title}</b>
|
<b>${title}</b>
|
||||||
<p>${subtitle}</p>
|
<p>${subtitle}</p>
|
||||||
`}
|
`}
|
||||||
</Text>
|
</Text>
|
||||||
<Switch checked={checked} onChange={onChange} />
|
<Switch checked={checked} onChange={onChange} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,27 +6,27 @@ import IconButton from "./shared/IconButton";
|
|||||||
import { QIcon } from "@nodegui/nodegui";
|
import { QIcon } from "@nodegui/nodegui";
|
||||||
|
|
||||||
function TabMenu() {
|
function TabMenu() {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View id="tabmenu" styleSheet={tabBarStylesheet}>
|
<View id="tabmenu" styleSheet={tabBarStylesheet}>
|
||||||
<View>
|
<View>
|
||||||
<Text>{`<h1>Spotube</h1>`}</Text>
|
<Text>{`<h1>Spotube</h1>`}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TabMenuItem url="/home" title="Browse" />
|
<TabMenuItem url="/home" title="Browse" />
|
||||||
<TabMenuItem url="/library" title="Library" />
|
<TabMenuItem url="/library" title="Library" />
|
||||||
<TabMenuItem url="/currently" title="Currently Playing" />
|
<TabMenuItem url="/currently" title="Currently Playing" />
|
||||||
<TabMenuItem url="/search" title="Search" />
|
<TabMenuItem url="/search" title="Search" />
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={new QIcon(settingsCog)}
|
icon={new QIcon(settingsCog)}
|
||||||
on={{
|
on={{
|
||||||
clicked() {
|
clicked() {
|
||||||
history.push("/settings");
|
history.push("/settings");
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tabBarStylesheet = `
|
export const tabBarStylesheet = `
|
||||||
@ -47,21 +47,31 @@ export const tabBarStylesheet = `
|
|||||||
export default TabMenu;
|
export default TabMenu;
|
||||||
|
|
||||||
export interface TabMenuItemProps {
|
export interface TabMenuItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
/**
|
||||||
* path to the icon in string
|
* path to the icon in string
|
||||||
*/
|
*/
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabMenuItem({ icon, title, url }: TabMenuItemProps) {
|
export function TabMenuItem({ title, url }: TabMenuItemProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
function clicked() {
|
function clicked() {
|
||||||
history.push(url);
|
history.push(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Button on={{ clicked }} id={location.pathname.replace("/", " ").startsWith(url.replace("/", " ")) ? "tabmenu-active-item" : `tabmenu-item`} text={title} />;
|
return (
|
||||||
|
<Button
|
||||||
|
on={{ clicked }}
|
||||||
|
id={
|
||||||
|
location.pathname.replace("/", " ").startsWith(url.replace("/", " "))
|
||||||
|
? "tabmenu-active-item"
|
||||||
|
: `tabmenu-item`
|
||||||
|
}
|
||||||
|
text={title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,48 +6,55 @@ import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
|
|||||||
import showError from "../../helpers/showError";
|
import showError from "../../helpers/showError";
|
||||||
|
|
||||||
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
|
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
|
||||||
src: string;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
|
function CachedImage({ src, alt, size, maxSize, ...props }: CachedImageProps) {
|
||||||
const labelRef = useRef<QLabel>();
|
const labelRef = useRef<QLabel>();
|
||||||
const [imageBuffer, setImageBuffer] = useState<Buffer>();
|
const [imageBuffer, setImageBuffer] = useState<Buffer>();
|
||||||
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
|
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
|
||||||
const pixmap = new QPixmap();
|
const pixmap = new QPixmap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageBuffer === undefined) {
|
if (imageBuffer === undefined) {
|
||||||
getCachedImageBuffer(src, maxSize ?? size)
|
getCachedImageBuffer(src, maxSize ?? size)
|
||||||
.then((buffer) => setImageBuffer(buffer))
|
.then((buffer) => setImageBuffer(buffer))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setImageProcessError(false);
|
setImageProcessError(false);
|
||||||
showError(error, "[Cached Image Error]: ");
|
showError(error, "[Cached Image Error]: ");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
labelRef.current?.close();
|
labelRef.current?.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
pixmap.loadFromData(imageBuffer);
|
pixmap.loadFromData(imageBuffer);
|
||||||
pixmap.scaled((size ?? maxSize)?.height ?? 100, (size ?? maxSize)?.width ?? 100);
|
pixmap.scaled(
|
||||||
labelRef.current?.setPixmap(pixmap);
|
(size ?? maxSize)?.height ?? 100,
|
||||||
}
|
(size ?? maxSize)?.width ?? 100,
|
||||||
}, [imageBuffer]);
|
);
|
||||||
|
labelRef.current?.setPixmap(pixmap);
|
||||||
|
}
|
||||||
|
}, [imageBuffer]);
|
||||||
|
|
||||||
return !imageProcessError && imageBuffer ? (
|
return !imageProcessError && imageBuffer ? (
|
||||||
<Text ref={labelRef} {...props}/>
|
<Text ref={labelRef} {...props} />
|
||||||
) : alt ? (
|
) : alt ? (
|
||||||
<View style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${((maxSize ?? size)?.width || 10) / 2.5}px;`}>
|
<View
|
||||||
<Text>{alt}</Text>
|
style={`padding: ${((maxSize ?? size)?.height || 10) / 2.5}px ${
|
||||||
</View>
|
((maxSize ?? size)?.width || 10) / 2.5
|
||||||
) : (
|
}px;`}
|
||||||
<></>
|
>
|
||||||
);
|
<Text>{alt}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CachedImage;
|
export default CachedImage;
|
||||||
|
@ -5,10 +5,10 @@ import { useHistory } from "react-router";
|
|||||||
import PlaylistCard from "./PlaylistCard";
|
import PlaylistCard from "./PlaylistCard";
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryStylesheet = `
|
const categoryStylesheet = `
|
||||||
@ -36,26 +36,32 @@ const categoryStylesheet = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
|
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
function goToGenre(native: any) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const mouse = new QMouseEvent(native);
|
function goToGenre(native: any) {
|
||||||
if (mouse.button() === 1) {
|
const mouse = new QMouseEvent(native);
|
||||||
history.push(url, { name });
|
if (mouse.button() === 1) {
|
||||||
|
history.push(url, { name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (isError) {
|
||||||
if (isError) {
|
return <></>;
|
||||||
return <></>;
|
}
|
||||||
}
|
return (
|
||||||
return (
|
<View id="container" styleSheet={categoryStylesheet}>
|
||||||
<View id="container" styleSheet={categoryStylesheet}>
|
<Button
|
||||||
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
id="anchor-heading"
|
||||||
<View id="child-view">
|
cursor={CursorShape.PointingHandCursor}
|
||||||
{playlists.map((playlist, index) => {
|
on={{ MouseButtonRelease: goToGenre }}
|
||||||
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
text={name}
|
||||||
})}
|
/>
|
||||||
</View>
|
<View id="child-view">
|
||||||
</View>
|
{playlists.map((playlist, index) => {
|
||||||
);
|
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryCard;
|
export default CategoryCard;
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import {QGraphicsDropShadowEffect, QPushButton} from "@nodegui/nodegui"
|
import { QGraphicsDropShadowEffect, QPushButton } from "@nodegui/nodegui";
|
||||||
import { Button } from "@nodegui/react-nodegui";
|
import { Button } from "@nodegui/react-nodegui";
|
||||||
import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton";
|
import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton";
|
||||||
|
|
||||||
interface IconButtonProps extends Omit<ButtonProps, "text"> {}
|
type IconButtonProps = Omit<ButtonProps, "text">;
|
||||||
|
|
||||||
function IconButton({ style, ...props }: IconButtonProps) {
|
function IconButton({ style, ...props }: IconButtonProps) {
|
||||||
const iconBtnRef = useRef<QPushButton>()
|
const iconBtnRef = useRef<QPushButton>();
|
||||||
const shadowGfx = new QGraphicsDropShadowEffect();
|
const shadowGfx = new QGraphicsDropShadowEffect();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shadowGfx.setBlurRadius(5);
|
shadowGfx.setBlurRadius(5);
|
||||||
shadowGfx.setXOffset(0);
|
shadowGfx.setXOffset(0);
|
||||||
shadowGfx.setYOffset(0);
|
shadowGfx.setYOffset(0);
|
||||||
iconBtnRef.current?.setGraphicsEffect(shadowGfx);
|
iconBtnRef.current?.setGraphicsEffect(shadowGfx);
|
||||||
}, [])
|
}, []);
|
||||||
const iconButtonStyleSheet = `
|
const iconButtonStyleSheet = `
|
||||||
#icon-btn{
|
#icon-btn{
|
||||||
background-color: rgba(255, 255, 255, 0.055);
|
background-color: rgba(255, 255, 255, 0.055);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@ -32,7 +32,15 @@ function IconButton({ style, ...props }: IconButtonProps) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return <Button ref={iconBtnRef} id="icon-btn" size={{ height: 30, width: 30, fixed: true }} styleSheet={iconButtonStyleSheet} {...props} />;
|
return (
|
||||||
|
<Button
|
||||||
|
ref={iconBtnRef}
|
||||||
|
id="icon-btn"
|
||||||
|
size={{ height: 30, width: 30, fixed: true }}
|
||||||
|
styleSheet={iconButtonStyleSheet}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IconButton;
|
export default IconButton;
|
||||||
|
@ -1,56 +1,56 @@
|
|||||||
import { View, Button, Text } from "@nodegui/react-nodegui";
|
import { View, Button, Text } from "@nodegui/react-nodegui";
|
||||||
import { QLabel, QMovie, } from "@nodegui/nodegui";
|
import { QLabel, QMovie } from "@nodegui/nodegui";
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { loadingSpinner } from "../../icons";
|
import { loadingSpinner } from "../../icons";
|
||||||
|
|
||||||
interface ErrorAppletProps {
|
interface ErrorAppletProps {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
reload: Function;
|
reload: () => void;
|
||||||
helps?: boolean;
|
helps?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) {
|
function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) {
|
||||||
const textRef = useRef<QLabel>();
|
const textRef = useRef<QLabel>();
|
||||||
const movie = new QMovie();
|
const movie = new QMovie();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
movie.setFileName(loadingSpinner);
|
movie.setFileName(loadingSpinner);
|
||||||
textRef.current?.setMovie(movie);
|
textRef.current?.setMovie(movie);
|
||||||
textRef.current?.show();
|
textRef.current?.show();
|
||||||
movie.start();
|
movie.start();
|
||||||
}, []);
|
}, []);
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style="flex: 1; justify-content: 'center'; align-items: 'center';">
|
<View style="flex: 1; justify-content: 'center'; align-items: 'center';">
|
||||||
<Text ref={textRef} />
|
<Text ref={textRef} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
return (
|
return (
|
||||||
<View style="flex-direction: 'column'; align-items: 'center';">
|
<View style="flex-direction: 'column'; align-items: 'center';">
|
||||||
<Text openExternalLinks>{`
|
<Text openExternalLinks>{`
|
||||||
<h3>${message ? message : "An error occured"}</h3>
|
<h3>${message ? message : "An error occured"}</h3>
|
||||||
${
|
${
|
||||||
helps
|
helps
|
||||||
? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
|
? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
|
||||||
</p>`
|
</p>`
|
||||||
: ``
|
: ``
|
||||||
}
|
}
|
||||||
`}</Text>
|
`}</Text>
|
||||||
<Button
|
<Button
|
||||||
on={{
|
on={{
|
||||||
clicked() {
|
clicked() {
|
||||||
reload();
|
reload();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
text="Reload"
|
text="Reload"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlaceholderApplet;
|
export default PlaceholderApplet;
|
||||||
|
@ -15,49 +15,56 @@ import CachedImage from "./CachedImage";
|
|||||||
import IconButton from "./IconButton";
|
import IconButton from "./IconButton";
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
interface PlaylistCardProps {
|
||||||
playlist: SpotifyApi.PlaylistObjectSimplified;
|
playlist: SpotifyApi.PlaylistObjectSimplified;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
||||||
const preferences = useContext(preferencesContext);
|
const preferences = useContext(preferencesContext);
|
||||||
const thumbnail = playlist.images[0].url;
|
const thumbnail = playlist.images[0].url;
|
||||||
const { id, description, name, images } = playlist;
|
const { id, description, name } = playlist;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } =
|
||||||
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), {
|
useContext(playerContext);
|
||||||
initialData: [],
|
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
|
||||||
enabled: false,
|
[QueryCacheKeys.playlistTracks, id],
|
||||||
});
|
(spotifyApi) =>
|
||||||
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
|
spotifyApi.getPlaylistTracks(id).then((track) => track.body.items),
|
||||||
|
{
|
||||||
|
initialData: [],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
|
||||||
|
|
||||||
const handlePlaylistPlayPause = async () => {
|
const handlePlaylistPlayPause = async () => {
|
||||||
try {
|
try {
|
||||||
const { data: tracks, isSuccess } = await refetch();
|
const { data: tracks, isSuccess } = await refetch();
|
||||||
if (currentPlaylist?.id !== id && isSuccess && tracks) {
|
if (currentPlaylist?.id !== id && isSuccess && tracks) {
|
||||||
setCurrentPlaylist({ tracks, id, name, thumbnail });
|
setCurrentPlaylist({ tracks, id, name, thumbnail });
|
||||||
setCurrentTrack(tracks[0].track);
|
setCurrentTrack(tracks[0].track);
|
||||||
} else {
|
} else {
|
||||||
await audioPlayer.stop();
|
await audioPlayer.stop();
|
||||||
setCurrentTrack(undefined);
|
setCurrentTrack(undefined);
|
||||||
setCurrentPlaylist(undefined);
|
setCurrentPlaylist(undefined);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, "[Failed adding playlist to queue]: ");
|
showError(error, "[Failed adding playlist to queue]: ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function gotoPlaylist(native?: any) {
|
||||||
|
const key = new QMouseEvent(native);
|
||||||
|
if (key.button() === 1) {
|
||||||
|
history.push(`/playlist/${id}`, { name, thumbnail });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
function gotoPlaylist(native?: any) {
|
const bgColor1 = useMemo(() => generateRandomColor(), []);
|
||||||
const key = new QMouseEvent(native);
|
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
|
||||||
if (key.button() === 1) {
|
|
||||||
history.push(`/playlist/${id}`, { name, thumbnail });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgColor1 = useMemo(() => generateRandomColor(), []);
|
const playlistStyleSheet = `
|
||||||
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
|
|
||||||
|
|
||||||
const playlistStyleSheet = `
|
|
||||||
#playlist-container, #img-container{
|
#playlist-container, #img-container{
|
||||||
width: 150px;
|
width: 150px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@ -76,70 +83,85 @@ const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
|||||||
border: 5px solid green;
|
border: 5px solid green;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const playlistAction = `
|
const playlistAction = `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
background-color: ${color};
|
background-color: ${color};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const playlistActions = (
|
const playlistActions = (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"}
|
style={preferences.playlistImages ? "" : playlistAction + "left: '55%'"}
|
||||||
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
|
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
|
||||||
on={{
|
on={{
|
||||||
clicked() {
|
clicked() {
|
||||||
reactToPlaylist(playlist);
|
reactToPlaylist(playlist);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"}
|
style={preferences.playlistImages ? "" : playlistAction + "left: '80%'"}
|
||||||
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
|
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
|
||||||
on={{
|
on={{
|
||||||
clicked() {
|
clicked() {
|
||||||
handlePlaylistPlayPause();
|
handlePlaylistPlayPause();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const hovers = {
|
const hovers = {
|
||||||
HoverEnter() {
|
HoverEnter() {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
},
|
},
|
||||||
HoverLeave() {
|
HoverLeave() {
|
||||||
setHovered(false);
|
setHovered(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
id={preferences.playlistImages ? "img-container" : "playlist-container"}
|
id={preferences.playlistImages ? "img-container" : "playlist-container"}
|
||||||
cursor={CursorShape.PointingHandCursor}
|
cursor={CursorShape.PointingHandCursor}
|
||||||
styleSheet={playlistStyleSheet}
|
styleSheet={playlistStyleSheet}
|
||||||
on={{
|
on={{
|
||||||
MouseButtonRelease: gotoPlaylist,
|
MouseButtonRelease: gotoPlaylist,
|
||||||
...hovers,
|
...hovers,
|
||||||
}}>
|
}}
|
||||||
{preferences.playlistImages && <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />}
|
>
|
||||||
|
{preferences.playlistImages && (
|
||||||
|
<CachedImage
|
||||||
|
src={thumbnail}
|
||||||
|
maxSize={{ height: 150, width: 150 }}
|
||||||
|
scaledContents
|
||||||
|
alt={name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}>
|
<Text
|
||||||
{`
|
style={`color: ${color};`}
|
||||||
|
wordWrap
|
||||||
|
on={{ MouseButtonRelease: gotoPlaylist, ...hovers }}
|
||||||
|
>
|
||||||
|
{`
|
||||||
<center>
|
<center>
|
||||||
<h3>${name}</h3>
|
<h3>${name}</h3>
|
||||||
<p>${description}</p>
|
<p>${description}</p>
|
||||||
</center>
|
</center>
|
||||||
`}
|
`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{(hovered || currentPlaylist?.id === id) && !preferences.playlistImages && playlistActions}
|
{(hovered || currentPlaylist?.id === id) &&
|
||||||
{preferences.playlistImages &&
|
!preferences.playlistImages &&
|
||||||
<View style="flex: 1; justify-content: 'space-around';">{playlistActions}
|
playlistActions}
|
||||||
|
{preferences.playlistImages && (
|
||||||
|
<View style="flex: 1; justify-content: 'space-around';">
|
||||||
|
{playlistActions}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
}
|
);
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PlaylistCard;
|
export default PlaylistCard;
|
||||||
|
@ -4,41 +4,42 @@ import { CheckBoxProps } from "@nodegui/react-nodegui/dist/components/CheckBox/R
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> {
|
export interface SwitchProps extends Omit<CheckBoxProps, "on" | "icon" | "text"> {
|
||||||
onChange?(checked: boolean): void;
|
onChange?(checked: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
|
function Switch({ checked: derivedChecked, onChange, ...props }: SwitchProps) {
|
||||||
const [checked, setChecked] = useState<boolean>(false);
|
const [checked, setChecked] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (derivedChecked) {
|
if (derivedChecked) {
|
||||||
setChecked(derivedChecked);
|
setChecked(derivedChecked);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slider
|
<Slider
|
||||||
value={checked ? 1 : 0}
|
value={checked ? 1 : 0}
|
||||||
hasTracking
|
hasTracking
|
||||||
mouseTracking
|
mouseTracking
|
||||||
orientation={Orientation.Horizontal}
|
orientation={Orientation.Horizontal}
|
||||||
maximum={1}
|
maximum={1}
|
||||||
minimum={0}
|
minimum={0}
|
||||||
maxSize={{ width: 30, height: 20 }}
|
maxSize={{ width: 30, height: 20 }}
|
||||||
on={{
|
on={{
|
||||||
valueChanged(value) {
|
valueChanged(value) {
|
||||||
onChange && onChange(value===1);
|
onChange && onChange(value === 1);
|
||||||
},
|
},
|
||||||
MouseButtonRelease(native: any) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const mouse = new QMouseEvent(native);
|
MouseButtonRelease(native: any) {
|
||||||
if (mouse.button() === 1) {
|
const mouse = new QMouseEvent(native);
|
||||||
setChecked(!checked);
|
if (mouse.button() === 1) {
|
||||||
}
|
setChecked(!checked);
|
||||||
},
|
}
|
||||||
}}
|
},
|
||||||
{...props}
|
}}
|
||||||
/>
|
{...props}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Switch;
|
export default Switch;
|
||||||
|
@ -8,71 +8,83 @@ import { heart, heartRegular, pause, play } from "../../icons";
|
|||||||
import IconButton from "./IconButton";
|
import IconButton from "./IconButton";
|
||||||
|
|
||||||
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
|
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
|
||||||
follower?: SpotifyApi.FollowersObject;
|
follower?: SpotifyApi.FollowersObject;
|
||||||
tracks: SpotifyApi.PagingObject<SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject>;
|
tracks: SpotifyApi.PagingObject<
|
||||||
|
SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackButtonProps {
|
export interface TrackButtonProps {
|
||||||
track: SpotifyApi.TrackObjectFull;
|
track: SpotifyApi.TrackObjectFull;
|
||||||
playlist?: TrackButtonPlaylistObject;
|
playlist?: TrackButtonPlaylistObject;
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
|
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
|
||||||
const { reactToTrack, isFavorite } = useTrackReaction();
|
const { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } =
|
||||||
const handlePlaylistPlayPause = (index: number) => {
|
useContext(playerContext);
|
||||||
if (playlist && currentPlaylist?.id !== playlist.id) {
|
const handlePlaylistPlayPause = (index: number) => {
|
||||||
const globalPlaylistObj = { id: playlist.id, name: playlist.name, thumbnail: playlist.images[0].url, tracks: playlist.tracks.items };
|
if (playlist && currentPlaylist?.id !== playlist.id) {
|
||||||
setCurrentPlaylist(globalPlaylistObj);
|
const globalPlaylistObj = {
|
||||||
setCurrentTrack(playlist.tracks.items[index].track);
|
id: playlist.id,
|
||||||
}
|
name: playlist.name,
|
||||||
};
|
thumbnail: playlist.images[0].url,
|
||||||
|
tracks: playlist.tracks.items,
|
||||||
|
};
|
||||||
|
setCurrentPlaylist(globalPlaylistObj);
|
||||||
|
setCurrentTrack(playlist.tracks.items[index].track);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(track);
|
||||||
};
|
};
|
||||||
|
|
||||||
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
|
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
|
||||||
const active = (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) || currentTrack?.id === track.id;
|
const active =
|
||||||
return (
|
(currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) ||
|
||||||
<View
|
currentTrack?.id === track.id;
|
||||||
id={active ? "active" : "track-button"}
|
return (
|
||||||
styleSheet={trackButtonStyle}
|
<View
|
||||||
on={{
|
id={active ? "active" : "track-button"}
|
||||||
MouseButtonRelease(native: any) {
|
styleSheet={trackButtonStyle}
|
||||||
if (new QMouseEvent(native).button() === 1 && playlist) {
|
on={{
|
||||||
handlePlaylistPlayPause(index);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
}
|
MouseButtonRelease(native: any) {
|
||||||
},
|
if (new QMouseEvent(native).button() === 1 && playlist) {
|
||||||
}}>
|
handlePlaylistPlayPause(index);
|
||||||
<Text style="padding: 5px;">{index + 1}</Text>
|
}
|
||||||
<View style="flex-direction: 'column'; width: '35%';">
|
},
|
||||||
<Text>{`<h3>${track.name}</h3>`}</Text>
|
}}
|
||||||
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
|
>
|
||||||
</View>
|
<Text style="padding: 5px;">{index + 1}</Text>
|
||||||
<Text style="width: '25%';">{track.album.name}</Text>
|
<View style="flex-direction: 'column'; width: '35%';">
|
||||||
<Text style="width: '15%';">{duration}</Text>
|
<Text>{`<h3>${track.name}</h3>`}</Text>
|
||||||
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
|
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
|
||||||
<IconButton
|
</View>
|
||||||
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
|
<Text style="width: '25%';">{track.album.name}</Text>
|
||||||
on={{
|
<Text style="width: '15%';">{duration}</Text>
|
||||||
clicked() {
|
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
|
||||||
reactToTrack({ track, added_at: "" });
|
<IconButton
|
||||||
},
|
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
|
||||||
}}
|
on={{
|
||||||
/>
|
clicked() {
|
||||||
<IconButton
|
reactToTrack({ track, added_at: "" });
|
||||||
icon={new QIcon(active ? pause : play)}
|
},
|
||||||
on={{
|
}}
|
||||||
clicked() {
|
/>
|
||||||
trackClickHandler(track);
|
<IconButton
|
||||||
},
|
icon={new QIcon(active ? pause : play)}
|
||||||
}}
|
on={{
|
||||||
/>
|
clicked() {
|
||||||
</View>
|
trackClickHandler(track);
|
||||||
</View>
|
},
|
||||||
);
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackButtonStyle = `
|
const trackButtonStyle = `
|
||||||
|
35
src/conf.ts
35
src/conf.ts
@ -2,7 +2,7 @@ import dotenv from "dotenv";
|
|||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
const env = dotenv.config({ path: join(process.cwd(), ".env") }).parsed as any;
|
dotenv.config({ path: join(process.cwd(), ".env") }).parsed;
|
||||||
export const clientId = "";
|
export const clientId = "";
|
||||||
export const trace = process.argv.find((arg) => arg === "--trace") ?? false;
|
export const trace = process.argv.find((arg) => arg === "--trace") ?? false;
|
||||||
export const redirectURI = "http://localhost:4304/auth/spotify/callback";
|
export const redirectURI = "http://localhost:4304/auth/spotify/callback";
|
||||||
@ -10,23 +10,24 @@ export const confDir = join(homedir(), ".config", "spotube");
|
|||||||
export const cacheDir = join(homedir(), ".cache", "spotube");
|
export const cacheDir = join(homedir(), ".cache", "spotube");
|
||||||
|
|
||||||
export enum QueryCacheKeys {
|
export enum QueryCacheKeys {
|
||||||
categories = "categories",
|
categories = "categories",
|
||||||
categoryPlaylists = "categoryPlaylists",
|
categoryPlaylists = "categoryPlaylists",
|
||||||
featuredPlaylists = "featuredPlaylists",
|
featuredPlaylists = "featuredPlaylists",
|
||||||
genrePlaylists = "genrePlaylists",
|
genrePlaylists = "genrePlaylists",
|
||||||
playlistTracks = "playlistTracks",
|
playlistTracks = "playlistTracks",
|
||||||
userPlaylists = "user-palylists",
|
userPlaylists = "user-palylists",
|
||||||
userSavedTracks = "user-saved-tracks",
|
userSavedTracks = "user-saved-tracks",
|
||||||
search = "search",
|
search = "search",
|
||||||
searchPlaylist = "searchPlaylist",
|
searchPlaylist = "searchPlaylist",
|
||||||
searchSongs = "searchSongs",
|
searchSongs = "searchSongs",
|
||||||
|
followedArtists = "followed-artists",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LocalStorageKeys {
|
export enum LocalStorageKeys {
|
||||||
credentials = "credentials",
|
credentials = "credentials",
|
||||||
refresh_token = "refresh_token",
|
refresh_token = "refresh_token",
|
||||||
preferences = "user-preferences",
|
preferences = "user-preferences",
|
||||||
volume = "volume",
|
volume = "volume",
|
||||||
cachedPlaylist = "cached-playlist",
|
cachedPlaylist = "cached-playlist",
|
||||||
cachedTrack = "cached-track"
|
cachedTrack = "cached-track",
|
||||||
}
|
}
|
||||||
|
@ -2,23 +2,29 @@ import React, { Dispatch, SetStateAction } from "react";
|
|||||||
import { Credentials } from "../app";
|
import { Credentials } from "../app";
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
|
setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
setCredentials: Dispatch<SetStateAction<Credentials>>
|
setCredentials: Dispatch<SetStateAction<Credentials>>;
|
||||||
setAccess_token: Dispatch<SetStateAction<string>>;
|
setAccess_token: Dispatch<SetStateAction<string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authContext = React.createContext<AuthContext>({
|
const authContext = React.createContext<AuthContext>({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
setIsLoggedIn() {},
|
setIsLoggedIn() {
|
||||||
access_token: "",
|
return;
|
||||||
clientId: "",
|
},
|
||||||
clientSecret: "",
|
access_token: "",
|
||||||
setCredentials(){},
|
clientId: "",
|
||||||
setAccess_token() {},
|
clientSecret: "",
|
||||||
|
setCredentials() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
setAccess_token() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default authContext;
|
export default authContext;
|
||||||
|
@ -2,15 +2,27 @@ import React, { Dispatch, SetStateAction } from "react";
|
|||||||
|
|
||||||
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
||||||
|
|
||||||
export type CurrentPlaylist = { tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[]; id: string; name: string; thumbnail: string };
|
export type CurrentPlaylist = {
|
||||||
|
tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[];
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface PlayerContext {
|
export interface PlayerContext {
|
||||||
currentPlaylist?: CurrentPlaylist;
|
currentPlaylist?: CurrentPlaylist;
|
||||||
currentTrack?: CurrentTrack;
|
currentTrack?: CurrentTrack;
|
||||||
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
|
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
|
||||||
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
|
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerContext = React.createContext<PlayerContext>({ setCurrentPlaylist() {}, setCurrentTrack() {} });
|
const playerContext = React.createContext<PlayerContext>({
|
||||||
|
setCurrentPlaylist() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
setCurrentTrack() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default playerContext;
|
export default playerContext;
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import React, { Dispatch, SetStateAction } from "react";
|
import React, { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export interface PreferencesContextProperties {
|
export interface PreferencesContextProperties {
|
||||||
playlistImages: boolean;
|
playlistImages: boolean;
|
||||||
}
|
}
|
||||||
export interface PreferencesContext extends PreferencesContextProperties {
|
export interface PreferencesContext extends PreferencesContextProperties {
|
||||||
setPreferences: Dispatch<SetStateAction<PreferencesContextProperties>>;
|
setPreferences: Dispatch<SetStateAction<PreferencesContextProperties>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferencesContext = React.createContext<PreferencesContext>({
|
const preferencesContext = React.createContext<PreferencesContext>({
|
||||||
playlistImages: false,
|
playlistImages: false,
|
||||||
setPreferences() { }
|
setPreferences() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default preferencesContext;
|
export default preferencesContext;
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import color from "color";
|
import color from "color";
|
||||||
|
|
||||||
export function generateRandomColor(lightness: number=70): string {
|
export function generateRandomColor(lightness = 70): string {
|
||||||
return "hsl(" + 360 * Math.random() + "," + (25 + 70 * Math.random()) + "%," + (lightness + 10 * Math.random()) + "%)";
|
return (
|
||||||
|
"hsl(" +
|
||||||
|
360 * Math.random() +
|
||||||
|
"," +
|
||||||
|
(25 + 70 * Math.random()) +
|
||||||
|
"%," +
|
||||||
|
(lightness + 10 * Math.random()) +
|
||||||
|
"%)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDarkenForeground(hslcolor: string): string {
|
export function getDarkenForeground(hslcolor: string): string {
|
||||||
const adjustedColor = color(hslcolor);
|
const adjustedColor = color(hslcolor);
|
||||||
return adjustedColor.darken(.5).hex();
|
return adjustedColor.darken(0.5).hex();
|
||||||
}
|
}
|
||||||
|
@ -1,49 +1,80 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import htmlToText from "html-to-text";
|
import htmlToText from "html-to-text";
|
||||||
import showError from "./showError";
|
import showError from "./showError";
|
||||||
const delim1 = '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">';
|
const delim1 =
|
||||||
const delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
|
'</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">';
|
||||||
|
const delim2 =
|
||||||
|
'</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
|
||||||
const url = "https://www.google.com/search?q=";
|
const url = "https://www.google.com/search?q=";
|
||||||
|
|
||||||
export default async function fetchLyrics(artists: string, title: string) {
|
export default async function fetchLyrics(artists: string, title: string) {
|
||||||
let lyrics;
|
let lyrics;
|
||||||
try {
|
|
||||||
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+lyrics`);
|
|
||||||
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+lyrics`, { responseType: "text" })).data;
|
|
||||||
[, lyrics] = lyrics.split(delim1);
|
|
||||||
[lyrics] = lyrics.split(delim2);
|
|
||||||
} catch (err) {
|
|
||||||
showError(err, "[Lyric Query Error]: ");
|
|
||||||
try {
|
try {
|
||||||
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`);
|
console.log(
|
||||||
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`)).data;
|
"[lyric query]:",
|
||||||
[, lyrics] = lyrics.split(delim1);
|
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
|
||||||
[lyrics] = lyrics.split(delim2);
|
);
|
||||||
} catch (err_1) {
|
lyrics = (
|
||||||
showError(err_1, "[Lyric Query Error]: ");
|
await axios.get<string>(
|
||||||
try {
|
`${url}${encodeURIComponent(title + " " + artists)}+lyrics`,
|
||||||
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}+song`);
|
{ responseType: "text" },
|
||||||
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song`)).data;
|
)
|
||||||
|
).data;
|
||||||
[, lyrics] = lyrics.split(delim1);
|
[, lyrics] = lyrics.split(delim1);
|
||||||
[lyrics] = lyrics.split(delim2);
|
[lyrics] = lyrics.split(delim2);
|
||||||
} catch (err_2) {
|
} catch (err) {
|
||||||
showError(err_2, "[Lyric Query Error]: ");
|
showError(err, "[Lyric Query Error]: ");
|
||||||
try {
|
try {
|
||||||
console.log("[lyric query]:", `${url}${encodeURIComponent(title + " " + artists)}`);
|
console.log(
|
||||||
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}`)).data;
|
"[lyric query]:",
|
||||||
[, lyrics] = lyrics.split(delim1);
|
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
|
||||||
[lyrics] = lyrics.split(delim2);
|
);
|
||||||
} catch (err_3) {
|
lyrics = (
|
||||||
showError(err_3, "[Lyric Query Error]: ");
|
await axios.get<string>(
|
||||||
lyrics = "Not Found";
|
`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`,
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
[, lyrics] = lyrics.split(delim1);
|
||||||
|
[lyrics] = lyrics.split(delim2);
|
||||||
|
} catch (err_1) {
|
||||||
|
showError(err_1, "[Lyric Query Error]: ");
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
"[lyric query]:",
|
||||||
|
`${url}${encodeURIComponent(title + " " + artists)}+song`,
|
||||||
|
);
|
||||||
|
lyrics = (
|
||||||
|
await axios.get<string>(
|
||||||
|
`${url}${encodeURIComponent(title + " " + artists)}+song`,
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
[, lyrics] = lyrics.split(delim1);
|
||||||
|
[lyrics] = lyrics.split(delim2);
|
||||||
|
} catch (err_2) {
|
||||||
|
showError(err_2, "[Lyric Query Error]: ");
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
"[lyric query]:",
|
||||||
|
`${url}${encodeURIComponent(title + " " + artists)}`,
|
||||||
|
);
|
||||||
|
lyrics = (
|
||||||
|
await axios.get<string>(
|
||||||
|
`${url}${encodeURIComponent(title + " " + artists)}`,
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
[, lyrics] = lyrics.split(delim1);
|
||||||
|
[lyrics] = lyrics.split(delim2);
|
||||||
|
} catch (err_3) {
|
||||||
|
showError(err_3, "[Lyric Query Error]: ");
|
||||||
|
lyrics = "Not Found";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
const rets = lyrics.split("\n");
|
||||||
const rets = lyrics.split("\n");
|
let final = "";
|
||||||
let final = "";
|
for (const ret of rets) {
|
||||||
for (const ret of rets) {
|
final = `${final}${htmlToText.htmlToText(ret)}\n`;
|
||||||
final = `${final}${htmlToText.htmlToText(ret)}\n`;
|
}
|
||||||
}
|
return final.trim();
|
||||||
return final.trim();
|
|
||||||
}
|
}
|
||||||
|
@ -9,59 +9,83 @@ import du from "du";
|
|||||||
import { cacheDir } from "../conf";
|
import { cacheDir } from "../conf";
|
||||||
|
|
||||||
interface ImageDimensions {
|
interface ImageDimensions {
|
||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fsm = fs.promises;
|
const fsm = fs.promises;
|
||||||
|
|
||||||
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> {
|
export async function getCachedImageBuffer(
|
||||||
try {
|
name: string,
|
||||||
const MB_5 = 5000000; //5 Megabytes
|
dims?: ImageDimensions,
|
||||||
const cacheImgFolder = path.join(cacheDir, "images");
|
): Promise<Buffer> {
|
||||||
// for clearing up the cache if it reaches out of the size
|
try {
|
||||||
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
|
const MB_5 = 5000000; //5 Megabytes
|
||||||
const cacheImgPath = path.join(cacheImgFolder, cacheName);
|
const cacheImgFolder = path.join(cacheDir, "images");
|
||||||
// checking if the cached image already exists or not
|
// for clearing up the cache if it reaches out of the size
|
||||||
if (fs.existsSync(cacheImgPath)) {
|
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
|
||||||
// automatically removing cache after a certain 50 MB oversize
|
const cacheImgPath = path.join(cacheImgFolder, cacheName);
|
||||||
if ((await du(cacheImgFolder)) > MB_5) {
|
// checking if the cached image already exists or not
|
||||||
fs.rmdirSync(cacheImgFolder, { recursive: true });
|
if (fs.existsSync(cacheImgPath)) {
|
||||||
}
|
// automatically removing cache after a certain 50 MB oversize
|
||||||
const cachedImg = await fsm.readFile(cacheImgPath);
|
if ((await du(cacheImgFolder)) > MB_5) {
|
||||||
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
|
fs.rmdirSync(cacheImgFolder, { recursive: true });
|
||||||
|
}
|
||||||
|
const cachedImg = await fsm.readFile(cacheImgPath);
|
||||||
|
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
|
||||||
|
|
||||||
// if the dimensions are changed then the previously cached
|
// if the dimensions are changed then the previously cached
|
||||||
// images are removed and replaced with a new one
|
// images are removed and replaced with a new one
|
||||||
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) {
|
if (
|
||||||
fs.unlinkSync(cacheImgPath);
|
dims &&
|
||||||
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims });
|
(cachedImgMeta.height !== dims.height ||
|
||||||
}
|
cachedImgMeta.width !== dims?.width)
|
||||||
return cachedImg;
|
) {
|
||||||
} else {
|
fs.unlinkSync(cacheImgPath);
|
||||||
// finding no cache image fetching it through axios
|
return await imageResizeAndWrite(cachedImg, {
|
||||||
const { data: imgData } = await axios.get<Stream>(name, { responseType: "stream" });
|
cacheFolder: cacheImgFolder,
|
||||||
// converting axios stream to buffer
|
cacheName,
|
||||||
const resImgBuf = await streamToBuffer(imgData);
|
dims,
|
||||||
// creating cache_dir
|
});
|
||||||
await fsm.mkdir(cacheImgFolder, { recursive: true });
|
}
|
||||||
if (dims) {
|
return cachedImg;
|
||||||
return await imageResizeAndWrite(resImgBuf, { cacheFolder: cacheImgFolder, cacheName, dims });
|
} else {
|
||||||
}
|
// finding no cache image fetching it through axios
|
||||||
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
|
const { data: imgData } = await axios.get<Stream>(name, {
|
||||||
return resImgBuf;
|
responseType: "stream",
|
||||||
|
});
|
||||||
|
// converting axios stream to buffer
|
||||||
|
const resImgBuf = await streamToBuffer(imgData);
|
||||||
|
// creating cache_dir
|
||||||
|
await fsm.mkdir(cacheImgFolder, { recursive: true });
|
||||||
|
if (dims) {
|
||||||
|
return await imageResizeAndWrite(resImgBuf, {
|
||||||
|
cacheFolder: cacheImgFolder,
|
||||||
|
cacheName,
|
||||||
|
dims,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
|
||||||
|
return resImgBuf;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Error in Image Cache]: ", error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("[Error in Image Cache]: ", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> {
|
async function imageResizeAndWrite(
|
||||||
// caching the images by resizing if the max/fixed (Width/Height)
|
img: Buffer,
|
||||||
// is available in the args
|
{
|
||||||
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);
|
cacheFolder,
|
||||||
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
|
cacheName,
|
||||||
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
|
dims,
|
||||||
return resizedImgBuffer;
|
}: { dims: ImageDimensions; cacheFolder: string; cacheName: string },
|
||||||
|
): Promise<Buffer> {
|
||||||
|
// caching the images by resizing if the max/fixed (Width/Height)
|
||||||
|
// is available in the args
|
||||||
|
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);
|
||||||
|
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
|
||||||
|
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
|
||||||
|
return resizedImgBuffer;
|
||||||
}
|
}
|
||||||
|
@ -10,42 +10,69 @@ import { CurrentTrack } from "../context/playerContext";
|
|||||||
* @param {(Array<string | number>)} matches
|
* @param {(Array<string | number>)} matches
|
||||||
* @return {*} {number}
|
* @return {*} {number}
|
||||||
*/
|
*/
|
||||||
export function includePercentage(src: string | Array<string | number>, matches: Array<string | number>): number {
|
export function includePercentage(
|
||||||
let count = 0;
|
src: string | Array<string | number>,
|
||||||
matches.forEach((match) => {
|
matches: Array<string | number>,
|
||||||
if (src.includes(match.toString())) count++;
|
): number {
|
||||||
});
|
let count = 0;
|
||||||
return (count / matches.length) * 100;
|
matches.forEach((match) => {
|
||||||
|
if (src.includes(match.toString())) count++;
|
||||||
|
});
|
||||||
|
return (count / matches.length) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YoutubeTrack extends CurrentTrack {
|
export interface YoutubeTrack extends CurrentTrack {
|
||||||
youtube_uri: string;
|
youtube_uri: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promise<YoutubeTrack> {
|
export async function getYoutubeTrack(
|
||||||
try {
|
track: SpotifyApi.TrackObjectFull,
|
||||||
const artistsName = track.artists.map((ar) => ar.name);
|
): Promise<YoutubeTrack> {
|
||||||
const queryString = `${artistsName[0]} - ${track.name}${artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``}`;
|
try {
|
||||||
console.log("Youtube Query String:", queryString);
|
const artistsName = track.artists.map((ar) => ar.name);
|
||||||
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
|
const queryString = `${artistsName[0]} - ${track.name}${
|
||||||
const tracksWithRelevance = result
|
artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``
|
||||||
.map((video) => {
|
}`;
|
||||||
// percentage of matched track {name, artists} matched with
|
console.log("Youtube Query String:", queryString);
|
||||||
// title of the youtube search results
|
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
|
||||||
const matchPercentage = includePercentage(video.title, [track.name, ...artistsName]);
|
const tracksWithRelevance = result
|
||||||
// keeps only those tracks which are from the same artist channel
|
.map((video) => {
|
||||||
const sameChannel = video.channel.name.includes(artistsName[0]) || artistsName[0].includes(video.channel.name);
|
// percentage of matched track {name, artists} matched with
|
||||||
return { url: `http://www.youtube.com/watch?v=${video.id}`, matchPercentage, sameChannel, id: track.id };
|
// title of the youtube search results
|
||||||
})
|
const matchPercentage = includePercentage(video.title, [
|
||||||
.sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1));
|
track.name,
|
||||||
const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel);
|
...artistsName,
|
||||||
|
]);
|
||||||
|
// keeps only those tracks which are from the same artist channel
|
||||||
|
const sameChannel =
|
||||||
|
video.channel.name.includes(artistsName[0]) ||
|
||||||
|
artistsName[0].includes(video.channel.name);
|
||||||
|
return {
|
||||||
|
url: `http://www.youtube.com/watch?v=${video.id}`,
|
||||||
|
matchPercentage,
|
||||||
|
sameChannel,
|
||||||
|
id: track.id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1));
|
||||||
|
const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel);
|
||||||
|
|
||||||
const rarestTrack = result.map((res) => ({ url: `http://www.youtube.com/watch?v=${res.id}`, id: res.id }));
|
const rarestTrack = result.map((res) => ({
|
||||||
|
url: `http://www.youtube.com/watch?v=${res.id}`,
|
||||||
|
id: res.id,
|
||||||
|
}));
|
||||||
|
|
||||||
const finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance.length > 0 ? tracksWithRelevance : rarestTrack)[0].url };
|
const finalTrack = {
|
||||||
return finalTrack;
|
...track,
|
||||||
} catch (error) {
|
youtube_uri: (sameChannelTracks.length > 0
|
||||||
console.error("Failed to resolve track's youtube url: ", error);
|
? sameChannelTracks
|
||||||
throw error;
|
: tracksWithRelevance.length > 0
|
||||||
}
|
? tracksWithRelevance
|
||||||
|
: rarestTrack)[0].url,
|
||||||
|
};
|
||||||
|
return finalTrack;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resolve track's youtube url: ", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export function msToMinAndSec(ms: number) {
|
export function msToMinAndSec(ms: number) {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds:number = parseInt(((ms % 60000) / 1000).toFixed(0));
|
const seconds: number = parseInt(((ms % 60000) / 1000).toFixed(0));
|
||||||
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
|
return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { trace } from "../conf";
|
import { trace } from "../conf";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
|
||||||
function showError(error: any, message: any="[Error]: ") {
|
function showError(error: Error | TypeError, message = "[Error]: ") {
|
||||||
console.error(chalk.red(message), trace ? error : error.message);
|
console.error(chalk.red(message), trace ? error : error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default showError;
|
export default showError;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export function shuffleArray<T>(array:T[]):T[] {
|
export function shuffleArray<T>(array: T[]): T[] {
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
const temp = array[i];
|
const temp = array[i];
|
||||||
@ -6,4 +6,4 @@ export function shuffleArray<T>(array:T[]):T[] {
|
|||||||
array[j] = temp;
|
array[j] = temp;
|
||||||
}
|
}
|
||||||
return array;
|
return array;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import { Stream } from "stream";
|
import { Stream } from "stream";
|
||||||
|
|
||||||
export function streamToBuffer(stream: Stream): Promise<Buffer> {
|
export function streamToBuffer(stream: Stream): Promise<Buffer> {
|
||||||
let buffArr: any[] = [];
|
const buffArr: Uint8Array[] = [];
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
stream.on("data", (data) => {
|
stream.on("data", (data) => {
|
||||||
buffArr.push(data);
|
buffArr.push(data);
|
||||||
|
});
|
||||||
|
stream.on("end", async () => {
|
||||||
|
resolve(Buffer.concat(buffArr));
|
||||||
|
});
|
||||||
|
stream.on("error", (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
stream.on("end", async () => {
|
|
||||||
resolve(Buffer.concat(buffArr));
|
|
||||||
});
|
|
||||||
stream.on("error", (error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
@ -8,52 +8,52 @@ import playerContext from "../context/playerContext";
|
|||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
|
|
||||||
function useDownloadQueue() {
|
function useDownloadQueue() {
|
||||||
const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]);
|
const [downloadQueue, setDownloadQueue] = useState<YoutubeTrack[]>([]);
|
||||||
const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]);
|
const [completedQueue, setCompletedQueue] = useState<YoutubeTrack[]>([]);
|
||||||
const { currentTrack } = useContext(playerContext);
|
const { currentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
function addToQueue(obj: YoutubeTrack) {
|
function addToQueue(obj: YoutubeTrack) {
|
||||||
setDownloadQueue([...downloadQueue, obj]);
|
setDownloadQueue([...downloadQueue, obj]);
|
||||||
}
|
}
|
||||||
const completedTrackIds = completedQueue.map((x) => x.id);
|
const completedTrackIds = completedQueue.map((x) => x.id);
|
||||||
const downloadingTrackIds = downloadQueue.map((x) => x.id);
|
const downloadingTrackIds = downloadQueue.map((x) => x.id);
|
||||||
|
|
||||||
function isActiveDownloading() {
|
function isActiveDownloading() {
|
||||||
return downloadingTrackIds.includes(currentTrack?.id ?? "");
|
return downloadingTrackIds.includes(currentTrack?.id ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFinishedDownloading() {
|
function isFinishedDownloading() {
|
||||||
return completedTrackIds.includes(currentTrack?.id ?? "");
|
return completedTrackIds.includes(currentTrack?.id ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
downloadQueue.forEach(async (el) => {
|
downloadQueue.forEach(async (el) => {
|
||||||
if (!completedTrackIds.includes(el.id)) {
|
if (!completedTrackIds.includes(el.id)) {
|
||||||
ytdl(el.youtube_uri, {
|
ytdl(el.youtube_uri, {
|
||||||
filter: "audioonly",
|
filter: "audioonly",
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
fs.createWriteStream(
|
fs.createWriteStream(
|
||||||
join(
|
join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
"Music",
|
"Music",
|
||||||
`${el.name} - ${el.artists
|
`${el.name} - ${el.artists
|
||||||
.map((x) => x.name)
|
.map((x) => x.name)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
.trim()}.mp3`
|
.trim()}.mp3`,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
showError(err, `[failed to download ${el.name}]: `);
|
showError(err, `[failed to download ${el.name}]: `);
|
||||||
})
|
})
|
||||||
.on("finish", () => {
|
.on("finish", () => {
|
||||||
setCompletedQueue([...completedQueue, el]);
|
setCompletedQueue([...completedQueue, el]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [downloadQueue]);
|
}, [downloadQueue]);
|
||||||
return { addToQueue, isFinishedDownloading, isActiveDownloading };
|
return { addToQueue, isFinishedDownloading, isActiveDownloading };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useDownloadQueue;
|
export default useDownloadQueue;
|
||||||
|
@ -4,51 +4,72 @@ import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
|
|||||||
import useSpotifyMutation from "./useSpotifyMutation";
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
|
|
||||||
function usePlaylistReaction() {
|
function usePlaylistReaction() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: favoritePagedPlaylists } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(QueryCacheKeys.userPlaylists, (spotifyApi, { pageParam }) =>
|
const { data: favoritePagedPlaylists } =
|
||||||
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => {
|
useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
|
||||||
return userPlaylists.body;
|
QueryCacheKeys.userPlaylists,
|
||||||
})
|
(spotifyApi, { pageParam }) =>
|
||||||
);
|
spotifyApi
|
||||||
const favoritePlaylists = favoritePagedPlaylists?.pages
|
.getUserPlaylists({ limit: 20, offset: pageParam })
|
||||||
.map((playlist) => playlist.items)
|
.then((userPlaylists) => {
|
||||||
.filter(Boolean)
|
return userPlaylists.body;
|
||||||
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
}),
|
||||||
|
);
|
||||||
|
const favoritePlaylists = favoritePagedPlaylists?.pages
|
||||||
|
.map((playlist) => playlist.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
||||||
|
|
||||||
function updateFunction(playlist: SpotifyApi.PlaylistObjectSimplified, old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
|
function updateFunction(
|
||||||
const obj: typeof old = {
|
playlist: SpotifyApi.PlaylistObjectSimplified,
|
||||||
pageParams: old?.pageParams ?? [],
|
old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>,
|
||||||
pages:
|
): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
|
||||||
old?.pages.map(
|
const obj: typeof old = {
|
||||||
(oldPage, index): SpotifyApi.ListOfUsersPlaylistsResponse => {
|
pageParams: old?.pageParams ?? [],
|
||||||
const isPlaylistFavorite = isFavorite(playlist.id);
|
pages:
|
||||||
if (index === 0 && !isPlaylistFavorite) {
|
old?.pages.map(
|
||||||
return { ...oldPage, items: [...oldPage.items, playlist] };
|
(oldPage, index): SpotifyApi.ListOfUsersPlaylistsResponse => {
|
||||||
} else if (isPlaylistFavorite) {
|
const isPlaylistFavorite = isFavorite(playlist.id);
|
||||||
return { ...oldPage, items: oldPage.items.filter((oldPlaylist) => oldPlaylist.id !== playlist.id) };
|
if (index === 0 && !isPlaylistFavorite) {
|
||||||
}
|
return { ...oldPage, items: [...oldPage.items, playlist] };
|
||||||
return oldPage;
|
} else if (isPlaylistFavorite) {
|
||||||
}
|
return {
|
||||||
) ?? [],
|
...oldPage,
|
||||||
};
|
items: oldPage.items.filter(
|
||||||
return obj;
|
(oldPlaylist) => oldPlaylist.id !== playlist.id,
|
||||||
}
|
),
|
||||||
|
};
|
||||||
const { mutate: reactToPlaylist } = useSpotifyMutation<{}, SpotifyApi.PlaylistObjectSimplified>(
|
}
|
||||||
(spotifyApi, { id }) => spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then((res) => res.body),
|
return oldPage;
|
||||||
{
|
},
|
||||||
onSuccess(_, playlist) {
|
) ?? [],
|
||||||
queryClient.setQueryData<InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
|
};
|
||||||
},
|
return obj;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);
|
|
||||||
|
|
||||||
function isFavorite(playlistId: string) {
|
const { mutate: reactToPlaylist } = useSpotifyMutation<
|
||||||
return favoritePlaylistIds?.includes(playlistId);
|
unknown,
|
||||||
}
|
SpotifyApi.PlaylistObjectSimplified
|
||||||
|
>(
|
||||||
|
(spotifyApi, { id }) =>
|
||||||
|
spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then(
|
||||||
|
(res) => res.body,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
onSuccess(_, playlist) {
|
||||||
|
queryClient.setQueryData<
|
||||||
|
InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>
|
||||||
|
>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);
|
||||||
|
|
||||||
return { reactToPlaylist, isFavorite, favoritePlaylists };
|
function isFavorite(playlistId: string) {
|
||||||
|
return favoritePlaylistIds?.includes(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactToPlaylist, isFavorite, favoritePlaylists };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default usePlaylistReaction;
|
export default usePlaylistReaction;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import chalk from "chalk";
|
|
||||||
import { useContext, useEffect } from "react";
|
import { useContext, useEffect } from "react";
|
||||||
import { LocalStorageKeys } from "../conf";
|
import { LocalStorageKeys } from "../conf";
|
||||||
import authContext from "../context/authContext";
|
import authContext from "../context/authContext";
|
||||||
@ -6,35 +5,30 @@ import showError from "../helpers/showError";
|
|||||||
import spotifyApi from "../initializations/spotifyApi";
|
import spotifyApi from "../initializations/spotifyApi";
|
||||||
|
|
||||||
function useSpotifyApi() {
|
function useSpotifyApi() {
|
||||||
const {
|
const { access_token, clientId, clientSecret, isLoggedIn, setAccess_token } =
|
||||||
access_token,
|
useContext(authContext);
|
||||||
clientId,
|
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
|
||||||
clientSecret,
|
|
||||||
isLoggedIn,
|
|
||||||
setAccess_token,
|
|
||||||
} = useContext(authContext);
|
|
||||||
const refreshToken = localStorage.getItem(LocalStorageKeys.refresh_token);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && clientId && clientSecret && refreshToken) {
|
if (isLoggedIn && clientId && clientSecret && refreshToken) {
|
||||||
spotifyApi.setClientId(clientId);
|
spotifyApi.setClientId(clientId);
|
||||||
spotifyApi.setClientSecret(clientSecret);
|
spotifyApi.setClientSecret(clientSecret);
|
||||||
spotifyApi.setRefreshToken(refreshToken);
|
spotifyApi.setRefreshToken(refreshToken);
|
||||||
if (!access_token) {
|
if (!access_token) {
|
||||||
spotifyApi
|
spotifyApi
|
||||||
.refreshAccessToken()
|
.refreshAccessToken()
|
||||||
.then((token) => {
|
.then((token) => {
|
||||||
setAccess_token(token.body.access_token);
|
setAccess_token(token.body.access_token);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
showError(error);
|
showError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
spotifyApi.setAccessToken(access_token);
|
spotifyApi.setAccessToken(access_token);
|
||||||
}
|
}
|
||||||
}, [access_token, clientId, clientSecret, isLoggedIn]);
|
}, [access_token, clientId, clientSecret, isLoggedIn]);
|
||||||
|
|
||||||
return spotifyApi;
|
return spotifyApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSpotifyApi;
|
export default useSpotifyApi;
|
||||||
|
@ -5,25 +5,29 @@ import authContext from "../context/authContext";
|
|||||||
import showError from "../helpers/showError";
|
import showError from "../helpers/showError";
|
||||||
|
|
||||||
function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
|
function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
|
||||||
const { setAccess_token, isLoggedIn } = useContext(authContext);
|
const { setAccess_token, isLoggedIn } = useContext(authContext);
|
||||||
return async (error: any | Error | TypeError) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const isUnauthorized = error.message === "Unauthorized";
|
return async (error: SpotifyApi.ErrorObject | any) => {
|
||||||
const status401 = error.status === 401;
|
const isUnauthorized = error.message === "Unauthorized";
|
||||||
const bodyStatus401 = error.body.error.status === 401;
|
const status401 = error.status === 401;
|
||||||
const noToken = error.body.error.message === "No token provided";
|
const bodyStatus401 = error.body.error.status === 401;
|
||||||
const expiredToken = error.body.error.message === "The access token expired";
|
const noToken = error.body.error.message === "No token provided";
|
||||||
if ((isUnauthorized && isLoggedIn && status401) || ((noToken || expiredToken) && bodyStatus401)) {
|
const expiredToken = error.body.error.message === "The access token expired";
|
||||||
try {
|
if (
|
||||||
console.log(chalk.bgYellow.blackBright("Refreshing Access token"));
|
(isUnauthorized && isLoggedIn && status401) ||
|
||||||
const {
|
((noToken || expiredToken) && bodyStatus401)
|
||||||
body: { access_token: refreshedAccessToken },
|
) {
|
||||||
} = await spotifyApi.refreshAccessToken();
|
try {
|
||||||
setAccess_token(refreshedAccessToken);
|
console.log(chalk.bgYellow.blackBright("Refreshing Access token"));
|
||||||
} catch (error) {
|
const {
|
||||||
showError(error, "[Authorization Failure]: ");
|
body: { access_token: refreshedAccessToken },
|
||||||
}
|
} = await spotifyApi.refreshAccessToken();
|
||||||
}
|
setAccess_token(refreshedAccessToken);
|
||||||
};
|
} catch (error) {
|
||||||
|
showError(error, "[Authorization Failure]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSpotifyApiError;
|
export default useSpotifyApiError;
|
||||||
|
@ -1,28 +1,41 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { QueryFunctionContext, QueryKey, useInfiniteQuery, UseInfiniteQueryOptions, UseInfiniteQueryResult } from "react-query";
|
import {
|
||||||
|
QueryFunctionContext,
|
||||||
|
QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
UseInfiniteQueryResult,
|
||||||
|
} from "react-query";
|
||||||
import SpotifyWebApi from "spotify-web-api-node";
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
import useSpotifyApi from "./useSpotifyApi";
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
import useSpotifyApiError from "./useSpotifyApiError";
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi, pageArgs: QueryFunctionContext) => Promise<TQueryData>;
|
type SpotifyQueryFn<TQueryData> = (
|
||||||
|
spotifyApi: SpotifyWebApi,
|
||||||
|
pageArgs: QueryFunctionContext,
|
||||||
|
) => Promise<TQueryData>;
|
||||||
|
|
||||||
function useSpotifyInfiniteQuery<TQueryData = unknown>(
|
function useSpotifyInfiniteQuery<TQueryData = unknown>(
|
||||||
queryKey: QueryKey,
|
queryKey: QueryKey,
|
||||||
queryHandler: SpotifyQueryFn<TQueryData>,
|
queryHandler: SpotifyQueryFn<TQueryData>,
|
||||||
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
|
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
|
||||||
): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
||||||
const spotifyApi = useSpotifyApi();
|
const spotifyApi = useSpotifyApi();
|
||||||
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, (pageArgs) => queryHandler(spotifyApi, pageArgs), options);
|
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(
|
||||||
const { isError, error } = query;
|
queryKey,
|
||||||
|
(pageArgs) => queryHandler(spotifyApi, pageArgs),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const { isError, error } = query;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isError && error) {
|
if (isError && error) {
|
||||||
handleSpotifyError(error);
|
handleSpotifyError(error);
|
||||||
}
|
}
|
||||||
}, [isError, error]);
|
}, [isError, error]);
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSpotifyInfiniteQuery;
|
export default useSpotifyInfiniteQuery;
|
||||||
|
@ -4,21 +4,30 @@ import SpotifyWebApi from "spotify-web-api-node";
|
|||||||
import useSpotifyApi from "./useSpotifyApi";
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
import useSpotifyApiError from "./useSpotifyApiError";
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (spotifyApi: SpotifyWebApi, variables: TVariables) => Promise<TData>;
|
type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (
|
||||||
|
spotifyApi: SpotifyWebApi,
|
||||||
|
variables: TVariables,
|
||||||
|
) => Promise<TData>;
|
||||||
|
|
||||||
function useSpotifyMutation<TData = unknown, TVariable = unknown>(mutationFn: SpotifyMutationFn<TData, TVariable>, options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>) {
|
function useSpotifyMutation<TData = unknown, TVariable = unknown>(
|
||||||
const spotifyApi = useSpotifyApi();
|
mutationFn: SpotifyMutationFn<TData, TVariable>,
|
||||||
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>,
|
||||||
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>((arg) => mutationFn(spotifyApi, arg), options);
|
) {
|
||||||
const { isError, error } = mutation;
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>(
|
||||||
|
(arg) => mutationFn(spotifyApi, arg),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const { isError, error } = mutation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isError && error) {
|
if (isError && error) {
|
||||||
handleSpotifyError(error);
|
handleSpotifyError(error);
|
||||||
}
|
}
|
||||||
}, [isError, error]);
|
}, [isError, error]);
|
||||||
|
|
||||||
return mutation;
|
return mutation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSpotifyMutation;
|
export default useSpotifyMutation;
|
||||||
|
@ -7,23 +7,26 @@ import useSpotifyApiError from "./useSpotifyApiError";
|
|||||||
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
|
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
|
||||||
|
|
||||||
function useSpotifyQuery<TQueryData = unknown>(
|
function useSpotifyQuery<TQueryData = unknown>(
|
||||||
queryKey: QueryKey,
|
queryKey: QueryKey,
|
||||||
queryHandler: SpotifyQueryFn<TQueryData>,
|
queryHandler: SpotifyQueryFn<TQueryData>,
|
||||||
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
|
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {},
|
||||||
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
||||||
const spotifyApi = useSpotifyApi();
|
const spotifyApi = useSpotifyApi();
|
||||||
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, ()=>queryHandler(spotifyApi), options);
|
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(
|
||||||
const { isError, error } = query;
|
queryKey,
|
||||||
|
() => queryHandler(spotifyApi),
|
||||||
|
options,
|
||||||
useEffect(() => {
|
);
|
||||||
if (isError && error) {
|
const { isError, error } = query;
|
||||||
handleSpotifyError(error);
|
|
||||||
}
|
|
||||||
}, [isError, error]);
|
|
||||||
|
|
||||||
return query;
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSpotifyQuery;
|
export default useSpotifyQuery;
|
||||||
|
@ -4,49 +4,68 @@ import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
|
|||||||
import useSpotifyMutation from "./useSpotifyMutation";
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
|
|
||||||
function useTrackReaction() {
|
function useTrackReaction() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: userSavedTracks } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(QueryCacheKeys.userSavedTracks, (spotifyApi, { pageParam }) =>
|
const { data: userSavedTracks } =
|
||||||
spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body)
|
useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
|
||||||
);
|
QueryCacheKeys.userSavedTracks,
|
||||||
const favoriteTracks = userSavedTracks?.pages
|
(spotifyApi, { pageParam }) =>
|
||||||
?.map((page) => page.items)
|
spotifyApi
|
||||||
.filter(Boolean)
|
.getMySavedTracks({ limit: 50, offset: pageParam })
|
||||||
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
.then((res) => res.body),
|
||||||
|
);
|
||||||
|
const favoriteTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
|
|
||||||
function updateFunction(track: SpotifyApi.SavedTrackObject, old?: InfiniteData<SpotifyApi.UsersSavedTracksResponse>): InfiniteData<SpotifyApi.UsersSavedTracksResponse> {
|
function updateFunction(
|
||||||
const obj: typeof old = {
|
track: SpotifyApi.SavedTrackObject,
|
||||||
pageParams: old?.pageParams ?? [],
|
old?: InfiniteData<SpotifyApi.UsersSavedTracksResponse>,
|
||||||
pages:
|
): InfiniteData<SpotifyApi.UsersSavedTracksResponse> {
|
||||||
old?.pages.map(
|
const obj: typeof old = {
|
||||||
(oldPage, index): SpotifyApi.UsersSavedTracksResponse => {
|
pageParams: old?.pageParams ?? [],
|
||||||
const isTrackFavorite = isFavorite(track.track.id);
|
pages:
|
||||||
if (index === 0 && !isTrackFavorite) {
|
old?.pages.map((oldPage, index): SpotifyApi.UsersSavedTracksResponse => {
|
||||||
return { ...oldPage, items: [...oldPage.items, track] };
|
const isTrackFavorite = isFavorite(track.track.id);
|
||||||
} else if (isTrackFavorite) {
|
if (index === 0 && !isTrackFavorite) {
|
||||||
return { ...oldPage, items: oldPage.items.filter((oldTrack) => oldTrack.track.id !== track.track.id) };
|
return { ...oldPage, items: [...oldPage.items, track] };
|
||||||
}
|
} else if (isTrackFavorite) {
|
||||||
return oldPage;
|
return {
|
||||||
}
|
...oldPage,
|
||||||
) ?? [],
|
items: oldPage.items.filter(
|
||||||
};
|
(oldTrack) => oldTrack.track.id !== track.track.id,
|
||||||
return obj;
|
),
|
||||||
}
|
};
|
||||||
|
}
|
||||||
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>(
|
return oldPage;
|
||||||
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
|
}) ?? [],
|
||||||
{
|
};
|
||||||
onSuccess(_, track) {
|
return obj;
|
||||||
queryClient.setQueryData<InfiniteData<SpotifyApi.UsersSavedTracksResponse>>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old));
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);
|
|
||||||
|
|
||||||
function isFavorite(trackId: string) {
|
const { mutate: reactToTrack } = useSpotifyMutation<
|
||||||
return favoriteTrackIds?.includes(trackId);
|
unknown,
|
||||||
}
|
SpotifyApi.SavedTrackObject
|
||||||
|
>(
|
||||||
|
(spotifyApi, { track }) =>
|
||||||
|
spotifyApi[
|
||||||
|
isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"
|
||||||
|
]([track.id]).then((res) => res.body),
|
||||||
|
{
|
||||||
|
onSuccess(_, track) {
|
||||||
|
queryClient.setQueryData<
|
||||||
|
InfiniteData<SpotifyApi.UsersSavedTracksResponse>
|
||||||
|
>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);
|
||||||
|
|
||||||
return { reactToTrack, isFavorite, favoriteTracks };
|
function isFavorite(trackId: string) {
|
||||||
|
return favoriteTrackIds?.includes(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactToTrack, isFavorite, favoriteTracks };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useTrackReaction;
|
export default useTrackReaction;
|
||||||
|
26
src/icons.ts
26
src/icons.ts
@ -1,18 +1,18 @@
|
|||||||
import _play from "../assets/play-solid.svg";
|
import _play from "../assets/play-solid.svg";
|
||||||
import _pause from "../assets/pause-solid.svg"
|
import _pause from "../assets/pause-solid.svg";
|
||||||
import _angleLeft from "../assets/angle-left-solid.svg"
|
import _angleLeft from "../assets/angle-left-solid.svg";
|
||||||
import _backward from "../assets/backward-solid.svg"
|
import _backward from "../assets/backward-solid.svg";
|
||||||
import _forward from "../assets/forward-solid.svg"
|
import _forward from "../assets/forward-solid.svg";
|
||||||
import _heartRegular from "../assets/heart-regular.svg"
|
import _heartRegular from "../assets/heart-regular.svg";
|
||||||
import _heart from "../assets/heart-solid.svg"
|
import _heart from "../assets/heart-solid.svg";
|
||||||
import _random from "../assets/random-solid.svg"
|
import _random from "../assets/random-solid.svg";
|
||||||
import _stop from "../assets/stop-solid.svg"
|
import _stop from "../assets/stop-solid.svg";
|
||||||
import _search from "../assets/search-solid.svg";
|
import _search from "../assets/search-solid.svg";
|
||||||
import _loadingSpinner from "../assets/loading-spinner.gif";
|
import _loadingSpinner from "../assets/loading-spinner.gif";
|
||||||
import _settingsCog from "../assets/setting-cog.svg"
|
import _settingsCog from "../assets/setting-cog.svg";
|
||||||
import _times from "../assets/times-solid.svg"
|
import _times from "../assets/times-solid.svg";
|
||||||
import _musicNode from "../assets/music-solid.svg"
|
import _musicNode from "../assets/music-solid.svg";
|
||||||
import _download from "../assets/download-solid.svg"
|
import _download from "../assets/download-solid.svg";
|
||||||
|
|
||||||
export const play = _play;
|
export const play = _play;
|
||||||
export const pause = _pause;
|
export const pause = _pause;
|
||||||
@ -28,4 +28,4 @@ export const loadingSpinner = _loadingSpinner;
|
|||||||
export const settingsCog = _settingsCog;
|
export const settingsCog = _settingsCog;
|
||||||
export const times = _times;
|
export const times = _times;
|
||||||
export const musicNode = _musicNode;
|
export const musicNode = _musicNode;
|
||||||
export const download = _download;
|
export const download = _download;
|
||||||
|
@ -4,15 +4,15 @@ import App from "./app";
|
|||||||
|
|
||||||
process.title = "Spotube";
|
process.title = "Spotube";
|
||||||
Renderer.render(<App />, {
|
Renderer.render(<App />, {
|
||||||
onInit(reconciler) {
|
onInit(reconciler) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
require("@nodegui/devtools").connectReactDevtools(reconciler);
|
require("@nodegui/devtools").connectReactDevtools(reconciler);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// This is for hot reloading (this will be stripped off in production by webpack)
|
// This is for hot reloading (this will be stripped off in production by webpack)
|
||||||
if (module.hot) {
|
if (module.hot) {
|
||||||
module.hot.accept(["./app"], function () {
|
module.hot.accept(["./app"], function () {
|
||||||
Renderer.forceUpdate();
|
Renderer.forceUpdate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -3,4 +3,4 @@ import { redirectURI } from "../conf";
|
|||||||
|
|
||||||
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
|
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
|
||||||
|
|
||||||
export default spotifyApi;
|
export default spotifyApi;
|
||||||
|
@ -12,50 +12,54 @@ import Search from "./components/Search";
|
|||||||
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
|
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
|
||||||
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
|
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
|
||||||
import Settings from "./components/Settings";
|
import Settings from "./components/Settings";
|
||||||
|
import Artist from "./components/Artist";
|
||||||
|
|
||||||
function Routes() {
|
function Routes() {
|
||||||
const { isLoggedIn } = useContext(authContext);
|
const { isLoggedIn } = useContext(authContext);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
<Redirect from="/" to="/home" />
|
<Redirect from="/" to="/home" />
|
||||||
<TabMenu />
|
<TabMenu />
|
||||||
<Route exact path="/home">
|
<Route exact path="/home">
|
||||||
<Home />
|
<Home />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/playlist/:id">
|
||||||
|
<PlaylistView />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/genre/playlists/:id">
|
||||||
|
<PlaylistGenreView />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Login />
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
<Route exact path="/playlist/:id">
|
<Route path="/currently">
|
||||||
<PlaylistView />
|
<CurrentPlaylist />
|
||||||
</Route>
|
</Route>
|
||||||
<Route exact path="/genre/playlists/:id">
|
<Route path="/library">
|
||||||
<PlaylistGenreView />
|
<Library />
|
||||||
</Route>
|
</Route>
|
||||||
</>
|
<Route path="/artist">
|
||||||
) : (
|
<Artist />
|
||||||
<Login />
|
</Route>
|
||||||
)}
|
<Route exact path="/search">
|
||||||
</Route>
|
<Search />
|
||||||
<Route path="/currently">
|
</Route>
|
||||||
<CurrentPlaylist />
|
<Route exact path="/search/playlists">
|
||||||
</Route>
|
<SearchResultPlaylistCollection />
|
||||||
<Route path="/library">
|
</Route>
|
||||||
<Library />
|
<Route exact path="/search/songs">
|
||||||
</Route>
|
<SearchResultSongsCollection />
|
||||||
<Route exact path="/search">
|
</Route>
|
||||||
<Search />
|
<Route exact path="/settings/">
|
||||||
</Route>
|
<Settings />
|
||||||
<Route exact path="/search/playlists">
|
</Route>
|
||||||
<SearchResultPlaylistCollection />
|
</>
|
||||||
</Route>
|
);
|
||||||
<Route exact path="/search/songs">
|
|
||||||
<SearchResultSongsCollection />
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/settings/">
|
|
||||||
<Settings />
|
|
||||||
</Route>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Routes;
|
export default Routes;
|
||||||
|
2090
tsconfig.tsbuildinfo
2090
tsconfig.tsbuildinfo
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user