mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
Fixed unintentional Home component render on track change, implemented new mechanism with caching for spotifyAPI using react-query & refresh access token method. Moved to BoxView from View for frame bugs of react-nodegui
This commit is contained in:
parent
5a0486bc4f
commit
faea24e86e
12
.vscode/tasks.json
vendored
Normal file
12
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "npm: start",
|
||||||
|
"detail": "qode ./dist/index.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
8216
package-lock.json
generated
8216
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@
|
|||||||
"build": "webpack --mode=production",
|
"build": "webpack --mode=production",
|
||||||
"dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development",
|
"dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development",
|
||||||
"start": "qode ./dist/index.js",
|
"start": "qode ./dist/index.js",
|
||||||
|
"start:trace": "qode ./dist/index.js --trace",
|
||||||
"debug": "qode --inspect ./dist/index.js"
|
"debug": "qode --inspect ./dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -16,6 +17,7 @@
|
|||||||
"@nodegui/react-nodegui": "^0.10.0",
|
"@nodegui/react-nodegui": "^0.10.0",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"base64url": "^3.0.1",
|
"base64url": "^3.0.1",
|
||||||
|
"chalk": "^4.1.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"du": "^1.0.0",
|
"du": "^1.0.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
@ -25,9 +27,12 @@
|
|||||||
"node-mpv": "^2.0.0-beta.1",
|
"node-mpv": "^2.0.0-beta.1",
|
||||||
"open": "^7.4.1",
|
"open": "^7.4.1",
|
||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"react-query": "^3.12.0",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"scrape-yt": "^1.4.7",
|
"scrape-yt": "^1.4.7",
|
||||||
"spotify-web-api-node": "^5.0.2"
|
"spotify-web-api-node": "^5.0.2",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.11.6",
|
"@babel/core": "^7.11.6",
|
||||||
@ -43,6 +48,7 @@
|
|||||||
"@types/react": "^16.9.49",
|
"@types/react": "^16.9.49",
|
||||||
"@types/react-router": "^5.1.11",
|
"@types/react-router": "^5.1.11",
|
||||||
"@types/spotify-web-api-node": "^5.0.0",
|
"@types/spotify-web-api-node": "^5.0.0",
|
||||||
|
"@types/uuid": "^8.3.0",
|
||||||
"@types/webpack-env": "^1.15.3",
|
"@types/webpack-env": "^1.15.3",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
|
26
src/app.tsx
26
src/app.tsx
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Window, hot, View, useEventHandler } from "@nodegui/react-nodegui";
|
import { Window, hot, View, useEventHandler, BoxView } from "@nodegui/react-nodegui";
|
||||||
import { QIcon, QKeyEvent, QMainWindow, QMainWindowSignals, WidgetEventTypes, WindowState } from "@nodegui/nodegui";
|
import { Direction, QIcon, QKeyEvent, QMainWindow, QMainWindowSignals, WidgetEventTypes, WindowState } from "@nodegui/nodegui";
|
||||||
import nodeguiIcon from "../assets/nodegui.jpg";
|
import nodeguiIcon from "../assets/nodegui.jpg";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import Routes from "./routes";
|
import Routes from "./routes";
|
||||||
@ -8,9 +8,11 @@ import { LocalStorage } from "node-localstorage";
|
|||||||
import authContext from "./context/authContext";
|
import authContext from "./context/authContext";
|
||||||
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
|
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
|
||||||
import Player, { audioPlayer } from "./components/Player";
|
import Player, { audioPlayer } from "./components/Player";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import open from "open";
|
import open from "open";
|
||||||
import spotifyApi from "./initializations/spotifyApi";
|
import spotifyApi from "./initializations/spotifyApi";
|
||||||
|
import showError from "./helpers/showError";
|
||||||
|
|
||||||
export enum CredentialKeys {
|
export enum CredentialKeys {
|
||||||
credentials = "credentials",
|
credentials = "credentials",
|
||||||
@ -25,6 +27,15 @@ export interface Credentials {
|
|||||||
const minSize = { width: 700, height: 750 };
|
const minSize = { width: 700, height: 750 };
|
||||||
const winIcon = new QIcon(nodeguiIcon);
|
const winIcon = new QIcon(nodeguiIcon);
|
||||||
global.localStorage = new LocalStorage("./local");
|
global.localStorage = new LocalStorage("./local");
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
onError(error) {
|
||||||
|
showError(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function RootApp() {
|
function RootApp() {
|
||||||
const windowRef = useRef<QMainWindow>();
|
const windowRef = useRef<QMainWindow>();
|
||||||
@ -66,10 +77,9 @@ function RootApp() {
|
|||||||
const [expires_in, setExpires_in] = useState<number>(0);
|
const [expires_in, setExpires_in] = useState<number>(0);
|
||||||
const [access_token, setAccess_token] = useState<string>("");
|
const [access_token, setAccess_token] = useState<string>("");
|
||||||
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||||
|
|
||||||
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
||||||
|
|
||||||
const setExpireTime = (expirationDuration: number) => setExpires_in(Date.now() + expirationDuration);
|
const setExpireTime = (expirationDuration: number) => setExpires_in(Date.now() + expirationDuration * 1000 /* 1s = 1000 ms */);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoggedIn(!!cachedCredentials);
|
setIsLoggedIn(!!cachedCredentials);
|
||||||
@ -128,10 +138,14 @@ function RootApp() {
|
|||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, expires_in, setAccess_token, setExpires_in: setExpireTime, ...credentials }}>
|
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, expires_in, setAccess_token, setExpires_in: setExpireTime, ...credentials }}>
|
||||||
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
||||||
<View style={`flex: 1; flex-direction: 'column'; justify-content: 'center'; align-items: 'stretch'; height: '100%';`}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{/* <View style={`flex: 1; flex-direction: 'column'; justify-content: 'center'; align-items: 'stretch'; height: '100%';`}> */}
|
||||||
|
<BoxView direction={Direction.TopToBottom}>
|
||||||
<Routes />
|
<Routes />
|
||||||
{isLoggedIn && <Player />}
|
{isLoggedIn && <Player />}
|
||||||
</View>
|
</BoxView>
|
||||||
|
{/* </View> */}
|
||||||
|
</QueryClientProvider>
|
||||||
</playerContext.Provider>
|
</playerContext.Provider>
|
||||||
</authContext.Provider>
|
</authContext.Provider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
|
@ -1,39 +1,38 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import { Button, View, ScrollArea } from "@nodegui/react-nodegui";
|
import { Button, ScrollArea, BoxView } from "@nodegui/react-nodegui";
|
||||||
import playerContext from "../context/playerContext";
|
|
||||||
import authContext from "../context/authContext";
|
|
||||||
import { useHistory } from "react-router";
|
import { useHistory } from "react-router";
|
||||||
import CachedImage from "./shared/CachedImage";
|
import CachedImage from "./shared/CachedImage";
|
||||||
import { CursorShape } from "@nodegui/nodegui";
|
import { CursorShape, Direction } from "@nodegui/nodegui";
|
||||||
import useSpotifyApi from "../hooks/useSpotifyApi";
|
import useSpotifyApi from "../hooks/useSpotifyApi";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
import authContext from "../context/authContext";
|
||||||
|
import useSpotifyApiError from "../hooks/useSpotifyApiError";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { currentPlaylist } = useContext(playerContext);
|
|
||||||
const spotifyApi = useSpotifyApi();
|
const spotifyApi = useSpotifyApi();
|
||||||
const { access_token } = useContext(authContext);
|
const { access_token } = useContext(authContext);
|
||||||
const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]);
|
const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]);
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (access_token) {
|
if (categories.length === 0) {
|
||||||
(async () => {
|
spotifyApi
|
||||||
try {
|
.getCategories({ country: "US" })
|
||||||
const categoriesReceived = await spotifyApi.getCategories({ country: "US" });
|
.then((categoriesReceived) => setCategories(categoriesReceived.body.categories.items))
|
||||||
setCategories(categoriesReceived.body.categories.items);
|
.catch((error) => {
|
||||||
} catch (error) {
|
showError(error, "[Spotify genre loading failed]: ");
|
||||||
console.error("Spotify featured playlist loading failed: ", error);
|
handleSpotifyError(error);
|
||||||
}
|
});
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
}, [access_token]);
|
}, [access_token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
|
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
||||||
<View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}>
|
<BoxView direction={Direction.TopToBottom}>
|
||||||
{currentPlaylist && <CategoryCard key={((Math.random() * Date.now()) / Math.random()) * 100} id="current" name="Currently Playing" />}
|
|
||||||
{categories.map((category, index) => {
|
{categories.map((category, index) => {
|
||||||
return <CategoryCard key={((index * Date.now()) / Math.random()) * 100} {...category} />;
|
return <CategoryCard key={index+category.id} id={category.id} name={category.name} />;
|
||||||
})}
|
})}
|
||||||
</View>
|
</BoxView>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -45,39 +44,42 @@ interface CategoryCardProps {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryCard({ id, name }: CategoryCardProps) {
|
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
||||||
const { currentPlaylist } = useContext(playerContext);
|
|
||||||
const spotifyApi = useSpotifyApi();
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (playlists.length === 0) {
|
||||||
|
spotifyApi
|
||||||
(async () => {
|
.getPlaylistsForCategory(id, { limit: 4 })
|
||||||
try {
|
.then((playlistsRes) => setPlaylists(playlistsRes.body.playlists.items))
|
||||||
if (id !== "current") {
|
.catch((error) => {
|
||||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 4 });
|
showError(error, `[Failed to get playlists of category ${name}]: `);
|
||||||
mounted && setPlaylists(playlistsRes.body.playlists.items);
|
handleSpotifyError(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to get playlists of category ${name} for: `, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function goToGenre() {
|
function goToGenre() {
|
||||||
history.push(`/genre/playlists/${id}`, { name });
|
history.push(`/genre/playlists/${id}`, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryStylesheet = `
|
return (
|
||||||
|
<BoxView id="container" styleSheet={categoryStylesheet} direction={Direction.TopToBottom}>
|
||||||
|
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
||||||
|
<BoxView direction={Direction.LeftToRight}>
|
||||||
|
{playlists.map((playlist, index) => {
|
||||||
|
return <PlaylistCard key={index+playlist.id} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />;
|
||||||
|
})}
|
||||||
|
</BoxView>
|
||||||
|
</BoxView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryStylesheet = `
|
||||||
#container{
|
#container{
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: 'center';
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
#anchor-heading{
|
#anchor-heading{
|
||||||
@ -87,41 +89,22 @@ function CategoryCard({ id, name }: CategoryCardProps) {
|
|||||||
outline: none;
|
outline: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
align-self: 'flex-start';
|
text-align: left;
|
||||||
}
|
}
|
||||||
#anchor-heading:hover{
|
#anchor-heading:hover{
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
#child-view{
|
|
||||||
flex: 1;
|
|
||||||
justify-content: 'space-evenly';
|
|
||||||
align-items: 'center';
|
|
||||||
flex-wrap: 'wrap';
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
|
||||||
<View id="container" styleSheet={categoryStylesheet}>
|
|
||||||
{(playlists.length > 0 || id === "current") && <Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />}
|
|
||||||
<View id="child-view">
|
|
||||||
{id === "current" && currentPlaylist && <PlaylistCard key={(Date.now() / Math.random()) * 100} {...currentPlaylist} />}
|
|
||||||
{playlists.map((playlist, index) => {
|
|
||||||
return <PlaylistCard key={((index * Date.now()) / Math.random()) * 100} id={playlist.id} name={playlist.name} thumbnail={playlist.images[0].url} />;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
interface PlaylistCardProps {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistCard({ id, name, thumbnail }: PlaylistCardProps) {
|
export const PlaylistCard = React.memo(({ id, name, thumbnail }: PlaylistCardProps) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
function gotoPlaylist() {
|
function gotoPlaylist() {
|
||||||
@ -130,9 +113,9 @@ export function PlaylistCard({ id, name, thumbnail }: PlaylistCardProps) {
|
|||||||
|
|
||||||
const playlistStyleSheet = `
|
const playlistStyleSheet = `
|
||||||
#playlist-container{
|
#playlist-container{
|
||||||
max-width: 250px;
|
max-width: 150px;
|
||||||
flex-direction: column;
|
max-height: 150px;
|
||||||
padding: 2px;
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
#playlist-container:hover{
|
#playlist-container:hover{
|
||||||
border: 1px solid green;
|
border: 1px solid green;
|
||||||
@ -143,8 +126,8 @@ export function PlaylistCard({ id, name, thumbnail }: PlaylistCardProps) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View id="playlist-container" cursor={CursorShape.PointingHandCursor} styleSheet={playlistStyleSheet} on={{ MouseButtonRelease: gotoPlaylist }}>
|
<BoxView size={{height: 150, width: 150, fixed: true}} direction={Direction.TopToBottom} id="playlist-container" cursor={CursorShape.PointingHandCursor} styleSheet={playlistStyleSheet} on={{ MouseButtonRelease: gotoPlaylist }}>
|
||||||
<CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />
|
<CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />
|
||||||
</View>
|
</BoxView>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
@ -10,6 +10,7 @@ import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegul
|
|||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
import authContext from "../context/authContext";
|
import authContext from "../context/authContext";
|
||||||
import useSpotifyApi from "../hooks/useSpotifyApi";
|
import useSpotifyApi from "../hooks/useSpotifyApi";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
|
||||||
export const audioPlayer = new NodeMpv(
|
export const audioPlayer = new NodeMpv(
|
||||||
{
|
{
|
||||||
@ -59,6 +60,7 @@ function Player(): ReactElement {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start audio player", error);
|
console.error("Failed to start audio player", error);
|
||||||
|
showError(error, "[Failed starting audio player]: ")
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -98,7 +100,7 @@ function Player(): ReactElement {
|
|||||||
setIsStopped(true);
|
setIsStopped(true);
|
||||||
setIsPaused(true);
|
setIsPaused(true);
|
||||||
}
|
}
|
||||||
console.error(error);
|
showError(error, "[Failure at track change]: ");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [currentTrack]);
|
}, [currentTrack]);
|
||||||
@ -170,14 +172,13 @@ function Player(): ReactElement {
|
|||||||
setIsPaused(true);
|
setIsPaused(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
showError(error, "[Track control failed]: ");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevOrNext = (constant: number) => {
|
const prevOrNext = (constant: number) => {
|
||||||
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
||||||
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
|
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
|
||||||
console.log("index:", index);
|
|
||||||
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track);
|
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -188,7 +189,7 @@ function Player(): ReactElement {
|
|||||||
await audioPlayer.stop();
|
await audioPlayer.stop();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop the audio player: ", error);
|
showError(error, "[Failed at audio-player stop]: ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,9 @@ import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import { useLocation, useParams } from "react-router";
|
import { useLocation, useParams } from "react-router";
|
||||||
import authContext from "../context/authContext";
|
import authContext from "../context/authContext";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
import useSpotifyApi from "../hooks/useSpotifyApi";
|
import useSpotifyApi from "../hooks/useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "../hooks/useSpotifyApiError";
|
||||||
import BackButton from "./BackButton";
|
import BackButton from "./BackButton";
|
||||||
import { PlaylistCard } from "./Home";
|
import { PlaylistCard } from "./Home";
|
||||||
|
|
||||||
@ -12,23 +14,18 @@ function PlaylistGenreView() {
|
|||||||
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
||||||
const { access_token, isLoggedIn } = useContext(authContext);
|
const { access_token, isLoggedIn } = useContext(authContext);
|
||||||
const spotifyApi = useSpotifyApi();
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (playlists.length === 0 && access_token) {
|
||||||
|
spotifyApi
|
||||||
(async () => {
|
.getPlaylistsForCategory(id)
|
||||||
try {
|
.then((playlistsRes) => setPlaylists(playlistsRes.body.playlists.items))
|
||||||
if (access_token) {
|
.catch((error) => {
|
||||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id);
|
showError(error, `[Failed to get playlists of category ${location.state.name} for]: `);
|
||||||
mounted && setPlaylists(playlistsRes.body.playlists.items);
|
handleSpotifyError(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to get playlists of category ${name} for: `, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [access_token]);
|
}, [access_token]);
|
||||||
|
|
||||||
const playlistGenreViewStylesheet = `
|
const playlistGenreViewStylesheet = `
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import React, { FC, useContext, useEffect, useState } from "react";
|
import React, { FC, useContext } from "react";
|
||||||
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
import { BoxView, Button, ScrollArea, Text } from "@nodegui/react-nodegui";
|
||||||
import BackButton from "./BackButton";
|
import BackButton from "./BackButton";
|
||||||
import { useLocation, useParams } from "react-router";
|
import { useLocation, useParams } from "react-router";
|
||||||
import { QAbstractButtonSignals, QIcon } from "@nodegui/nodegui";
|
import { Direction, QAbstractButtonSignals, QIcon } from "@nodegui/nodegui";
|
||||||
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
||||||
import authContext from "../context/authContext";
|
|
||||||
import playerContext from "../context/playerContext";
|
import playerContext from "../context/playerContext";
|
||||||
import IconButton from "./shared/IconButton";
|
import IconButton from "./shared/IconButton";
|
||||||
import { heartRegular, play, stop } from "../icons";
|
import { heartRegular, play, stop } from "../icons";
|
||||||
import { audioPlayer } from "./Player";
|
import { audioPlayer } from "./Player";
|
||||||
import useSpotifyApi from "../hooks/useSpotifyApi";
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
|
||||||
export interface PlaylistTrackRes {
|
export interface PlaylistTrackRes {
|
||||||
name: string;
|
name: string;
|
||||||
@ -18,28 +18,18 @@ export interface PlaylistTrackRes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistView: FC = () => {
|
const PlaylistView: FC = () => {
|
||||||
const { isLoggedIn } = useContext(authContext);
|
|
||||||
const { setCurrentTrack, currentPlaylist, currentTrack, setCurrentPlaylist } = useContext(playerContext);
|
const { setCurrentTrack, currentPlaylist, currentTrack, setCurrentPlaylist } = useContext(playerContext);
|
||||||
const spotifyApi = useSpotifyApi();
|
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const location = useLocation<{ name: string; thumbnail: string }>();
|
const location = useLocation<{ name: string; thumbnail: string }>();
|
||||||
const [tracks, setTracks] = useState<SpotifyApi.PlaylistTrackObject[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: tracks, isSuccess, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
|
||||||
if (isLoggedIn) {
|
[QueryCacheKeys.playlistTracks, params.id],
|
||||||
(async () => {
|
(spotifyApi) => spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
|
||||||
try {
|
{ initialData: [] }
|
||||||
const tracks = await spotifyApi.getPlaylistTracks(params.id);
|
);
|
||||||
setTracks(tracks.body.items);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to get tracks from ${params.id}: `, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePlaylistPlayPause = () => {
|
const handlePlaylistPlayPause = () => {
|
||||||
if (currentPlaylist?.id !== params.id) {
|
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 {
|
||||||
@ -49,32 +39,38 @@ const PlaylistView: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackClickHandler = async (track: SpotifyApi.TrackObjectFull) => {
|
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
||||||
try {
|
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(track);
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to resolve track's youtube url: ", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={`flex-direction: 'column'; flex-grow: 1;`}>
|
<BoxView direction={Direction.TopToBottom}>
|
||||||
<View style={`justify-content: 'space-between'; padding-bottom: 10px; padding-left: 10px;`}>
|
<BoxView style={`max-width: 150px;`}>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<View style={`height: 50px; justify-content: 'space-between'; width: 100px; padding-right: 20px;`}>
|
|
||||||
<IconButton icon={new QIcon(heartRegular)} />
|
<IconButton icon={new QIcon(heartRegular)} />
|
||||||
<IconButton style={`background-color: #00be5f; color: white;`} on={{ clicked: handlePlaylistPlayPause }} icon={new QIcon(currentPlaylist?.id === params.id ? stop : play)} />
|
<IconButton style={`background-color: #00be5f; color: white;`} on={{ clicked: handlePlaylistPlayPause }} icon={new QIcon(currentPlaylist?.id === params.id ? stop : play)} />
|
||||||
</View>
|
</BoxView>
|
||||||
</View>
|
|
||||||
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
||||||
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
||||||
<View style={`flex-direction:column;`}>
|
<BoxView /* style={`flex-direction:column;`} */ direction={Direction.TopToBottom}>
|
||||||
{isLoggedIn &&
|
{isLoading && <Text>{`Loading Tracks...`}</Text>}
|
||||||
tracks.length > 0 &&
|
{isError && (
|
||||||
tracks.map(({ track }, index) => {
|
<>
|
||||||
|
<Text>{`Failed to load ${location.state.name} tracks`}</Text>
|
||||||
|
<Button
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
text="Retry"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tracks?.map(({ track }, index) => {
|
||||||
return (
|
return (
|
||||||
<TrackButton
|
<TrackButton
|
||||||
key={index * ((Date.now() / Math.random()) * 100)}
|
key={index+track.id}
|
||||||
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
||||||
artist={track.artists.map((x) => x.name).join(", ")}
|
artist={track.artists.map((x) => x.name).join(", ")}
|
||||||
name={track.name}
|
name={track.name}
|
||||||
@ -82,9 +78,9 @@ const PlaylistView: FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</BoxView>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</View>
|
</BoxView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { Image, Text, View } from "@nodegui/react-nodegui";
|
|||||||
import { QLabel } from "@nodegui/nodegui";
|
import { QLabel } from "@nodegui/nodegui";
|
||||||
import { ImageProps } from "@nodegui/react-nodegui/dist/components/Image/RNImage";
|
import { ImageProps } from "@nodegui/react-nodegui/dist/components/Image/RNImage";
|
||||||
import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
|
import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
|
||||||
|
import showError from "../../helpers/showError";
|
||||||
|
|
||||||
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
|
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
|
||||||
src: string;
|
src: string;
|
||||||
@ -15,18 +16,17 @@ function CachedImage({ src, alt, ...props }: CachedImageProps) {
|
|||||||
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
|
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (imageBuffer===undefined) {
|
||||||
(async () => {
|
getCachedImageBuffer(src, props.maxSize ?? props.size)
|
||||||
try {
|
.then((buffer) => setImageBuffer(buffer))
|
||||||
mounted && setImageBuffer(await getCachedImageBuffer(src, props.maxSize ?? props.size));
|
.catch((error) => {
|
||||||
} catch (error) {
|
setImageProcessError(false);
|
||||||
mounted && setImageProcessError(false);
|
showError(error, "[Cached Image Error]: ");
|
||||||
console.log("Cached Image Error:", error);
|
});
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
return () => {
|
return () => {
|
||||||
imgRef.current?.close();
|
imgRef.current?.close();
|
||||||
mounted = false;
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return !imageProcessError && imageBuffer ? (
|
return !imageProcessError && imageBuffer ? (
|
||||||
|
@ -3,4 +3,11 @@ import { join } from "path";
|
|||||||
|
|
||||||
const env = dotenv.config({path: join(process.cwd(), ".env")}).parsed as any
|
const env = dotenv.config({path: join(process.cwd(), ".env")}).parsed as any
|
||||||
export const clientId = "";
|
export const clientId = "";
|
||||||
|
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"
|
||||||
|
|
||||||
|
export enum QueryCacheKeys{
|
||||||
|
categories="categories",
|
||||||
|
categoryPlaylists = "categoryPlaylists",
|
||||||
|
playlistTracks="playlistTracks"
|
||||||
|
}
|
8
src/helpers/showError.ts
Normal file
8
src/helpers/showError.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { trace } from "../conf";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
function showError(error: any, message: any="[Error]: ") {
|
||||||
|
console.error(chalk.red(message), trace ? error : error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default showError;
|
@ -1,30 +1,22 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
import { useContext, useEffect } from "react";
|
import { useContext, useEffect } from "react";
|
||||||
import { CredentialKeys } from "../app";
|
import { CredentialKeys } from "../app";
|
||||||
import authContext from "../context/authContext";
|
import authContext from "../context/authContext";
|
||||||
import spotifyApi from "../initializations/spotifyApi";
|
import spotifyApi from "../initializations/spotifyApi";
|
||||||
|
|
||||||
function useSpotifyApi() {
|
function useSpotifyApi() {
|
||||||
const { access_token, clientId, clientSecret, expires_in, isLoggedIn, setExpires_in, setAccess_token } = useContext(authContext);
|
const { access_token, clientId, clientSecret, isLoggedIn } = useContext(authContext);
|
||||||
const refreshToken = localStorage.getItem(CredentialKeys.refresh_token);
|
const refreshToken = localStorage.getItem(CredentialKeys.refresh_token);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn && clientId && clientSecret) {
|
if (isLoggedIn && clientId && clientSecret && refreshToken) {
|
||||||
|
console.log(chalk.bgCyan.black("Setting up spotify credentials"))
|
||||||
spotifyApi.setClientId(clientId);
|
spotifyApi.setClientId(clientId);
|
||||||
spotifyApi.setClientSecret(clientSecret);
|
spotifyApi.setClientSecret(clientSecret);
|
||||||
|
spotifyApi.setRefreshToken(refreshToken);
|
||||||
spotifyApi.setAccessToken(access_token);
|
spotifyApi.setAccessToken(access_token);
|
||||||
}
|
}
|
||||||
const isExpiredToken = Date.now() > expires_in;
|
}, [access_token, clientId, clientSecret, isLoggedIn]);
|
||||||
if (isLoggedIn && isExpiredToken && refreshToken) {
|
|
||||||
spotifyApi.setRefreshToken(refreshToken);
|
|
||||||
spotifyApi
|
|
||||||
.refreshAccessToken()
|
|
||||||
.then(({ body: { access_token, expires_in } }) => {
|
|
||||||
setAccess_token(access_token);
|
|
||||||
setExpires_in(expires_in);
|
|
||||||
})
|
|
||||||
.catch();
|
|
||||||
}
|
|
||||||
}, [access_token, clientId, clientSecret]);
|
|
||||||
|
|
||||||
return spotifyApi;
|
return spotifyApi;
|
||||||
}
|
}
|
||||||
|
23
src/hooks/useSpotifyApiError.ts
Normal file
23
src/hooks/useSpotifyApiError.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import authContext from "../context/authContext";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
|
||||||
|
function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
|
||||||
|
const { setAccess_token, isLoggedIn } = useContext(authContext);
|
||||||
|
return async (error: any | Error | TypeError) => {
|
||||||
|
if ((error.message === "Unauthorized" && error.status === 401 && isLoggedIn) || (error.body.error.message === "No token provided" && error.body.error.status===401)) {
|
||||||
|
try {
|
||||||
|
console.log(chalk.bgYellow.blackBright("Refreshing Access token"))
|
||||||
|
const { body:{access_token: refreshedAccessToken}} = await spotifyApi.refreshAccessToken();
|
||||||
|
setAccess_token(refreshedAccessToken);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Authorization Failure]: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default useSpotifyApiError;
|
29
src/hooks/useSpotifyQuery.ts
Normal file
29
src/hooks/useSpotifyQuery.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { QueryFunction, QueryKey, useQuery, UseQueryOptions, UseQueryResult } from "react-query";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
|
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
|
||||||
|
|
||||||
|
function useSpotifyQuery<TQueryData = unknown>(
|
||||||
|
queryKey: QueryKey,
|
||||||
|
queryHandler: SpotifyQueryFn<TQueryData>,
|
||||||
|
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
|
||||||
|
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
||||||
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, ()=>queryHandler(spotifyApi), options);
|
||||||
|
const { isError, error } = query;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyQuery;
|
@ -15,8 +15,8 @@ function Routes() {
|
|||||||
<Route exact path="/">
|
<Route exact path="/">
|
||||||
{isLoggedIn ? <Home /> : <Login />}
|
{isLoggedIn ? <Home /> : <Login />}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/playlist/:id"><PlaylistView/></Route>
|
<Route exact path="/playlist/:id"><PlaylistView/></Route>
|
||||||
<Route path="/genre/playlists/:id"><PlaylistGenreView/></Route>
|
<Route exact path="/genre/playlists/:id"><PlaylistGenreView/></Route>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user