This commit is contained in:
KRTirtho 2021-05-03 13:52:49 -07:00
commit 65596e3527
10 changed files with 205 additions and 132 deletions

View File

@ -14,7 +14,7 @@ Following are the features that currently spotube offers:
- Playback control is on user's machine instead of server based
- Small size & less data hungry
- No spotify or youtube ads since it uses all public & free APIs (But it's recommended to support the creators by watching/liking/subscribing to the artists youtube channel or add as favourite track in spotify. Mostly buying spotify premium is the best way to support their valuable creations)
- Lyric Seek (WIP)
- Lyric Seek
## Requirements
@ -149,11 +149,12 @@ $ npm start
There will be some glitches, lags & stuck motions because of the library Spotube is currently using under the hood. It has some issues with layouts thus sometime some contents aren't shown or overflows out of the window. But resizing the window would fix this issue. Soon there will be some updates fixing this sort of layout related problems
## TODO:
- Compile, Debug & Build for **MacOS**
- Add seek Lyric for currently playing track
- Support for playing/streaming podcasts/shows
- Easy installation procedure/mechanism for simplicity
- [ ] Compile, Debug & Build for **MacOS**
- [x] Compile, Debug & Build for **Windows**
- [x] Add seek Lyric for currently playing track
- [ ] Support for playing/streaming podcasts/shows
- [ ] Easy installation procedure/mechanism for simplicity in Windows
- [ ] Artist, User & Album pages
## Things that don't work
- Shows & Podcasts aren't supported as it'd require premium anyway

View File

@ -10,9 +10,11 @@
"dev": "cross-env TSC_WATCHFILE=UseFsEvents webpack --mode=development",
"start": "qode ./dist/index.js",
"start:watch": "nodemon -e node -w ./*.babelrc -x \"npm start\"",
"start-dev": "concurrently -n \"webpack,spotube\" -p \"{name}-{pid}\" -c \"bgBlue,bgGreen\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\"",
"start-dev": "concurrently -n \"webpack,spotube\" -p \"{name}-{pid}\" -c \"bgBlue.black.bold,bgGreen.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run start:watch\"",
"debug-dev": "concurrently -n \"webpack,spotube\" -p \"{name}-{pid}\" -c \"bgBlue.black.bold,bgGreen.black.bold\" -i --default-input-target spotube \"npm run dev\" \"npm run debug:watch\"",
"start:trace": "qode ./dist/index.js --trace",
"debug": "qode --inspect ./dist/index.js",
"debug:watch": "nodemon -e node -w ./*.babelrc -x \"npm run debug\"",
"pack": "nodegui-packer -p ./dist",
"pack-deb": "node scripts/build-deb.js",
"pack-win32": "powershell.exe -ExecutionPolicy Unrestricted -Command \". '.\\scripts\\build-win32.ps1'\""

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import { Window, hot, View, useEventHandler } from "@nodegui/react-nodegui";
import { QIcon, QKeyEvent, QMainWindow, QMainWindowSignals, WidgetEventTypes, WindowState } from "@nodegui/nodegui";
import { Window, hot, View } from "@nodegui/react-nodegui";
import { QIcon, QMainWindow, WidgetEventTypes, WindowState, QShortcut, QKeySequence } from "@nodegui/nodegui";
import { MemoryRouter } from "react-router";
import Routes from "./routes";
import { LocalStorage } from "node-localstorage";
@ -14,17 +14,10 @@ import spotifyApi from "./initializations/spotifyApi";
import showError from "./helpers/showError";
import fs from "fs";
import path from "path";
import { confDir } from "./conf";
import { confDir, LocalStorageKeys } from "./conf";
import spotubeIcon from "../assets/icon.svg";
import preferencesContext, { PreferencesContextProperties } from "./context/preferencesContext";
export enum LocalStorageKeys {
credentials = "credentials",
refresh_token = "refresh_token",
preferences = "user-preferences",
volume = "volume"
}
export interface Credentials {
clientId: string;
clientSecret: string;
@ -54,36 +47,6 @@ const initialCredentials: Credentials = { clientId: "", clientSecret: "" };
function RootApp() {
const windowRef = useRef<QMainWindow>();
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
const windowEvents = useEventHandler<QMainWindowSignals>(
{
async KeyRelease(nativeEv) {
try {
if (nativeEv) {
const event = new QKeyEvent(nativeEv);
const eventKey = event.key();
if (audioPlayer.isRunning() && currentTrack)
switch (eventKey) {
case 32: //space
(await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause();
break;
case 16777236: //arrow-right
(await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5));
break;
case 16777234: //arrow-left
(await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5));
break;
default:
break;
}
}
} catch (error) {
console.error("Error in window events: ", error);
}
},
},
[currentTrack]
);
// cache
const cachedPreferences = localStorage.getItem(LocalStorageKeys.preferences);
const cachedCredentials = localStorage.getItem(LocalStorageKeys.credentials);
@ -160,13 +123,67 @@ function RootApp() {
};
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;
};
}
});
return (
<Window ref={windowRef} on={windowEvents} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
<MemoryRouter>
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials, setCredentials }}>
<preferencesContext.Provider value={{ ...preferences, setPreferences }}>

View File

@ -1,12 +1,10 @@
import React from "react";
import React, { useEffect } from "react";
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
import { useHistory } from "react-router";
import { CursorShape, QMouseEvent } from "@nodegui/nodegui";
import { QueryCacheKeys } from "../conf";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import CategoryCardView from "./shared/CategoryCardView";
function Home() {
const { data: pagedCategories, isError, refetch, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.CategoryObject>>(
@ -25,7 +23,7 @@ function Home() {
.map((page) => page.items)
.filter(Boolean)
.flat(1);
categories?.unshift({ href: "", icons: [], id: "featured", name: "Featured" });
return (
<ScrollArea style={`flex-grow: 1; border: none;`}>
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
@ -33,12 +31,7 @@ function Home() {
{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}
/>}
{hasNextPage && <Button on={{ clicked: () => fetchNextPage() }} text="Load More" enabled={!isFetchingNextPage} />}
</View>
</ScrollArea>
);
@ -51,55 +44,21 @@ interface CategoryCardProps {
name: string;
}
const categoryStylesheet = `
#container{
flex: 1;
flex-direction: 'column';
justify-content: 'center';
}
#anchor-heading{
background: transparent;
padding: 10px;
border: none;
outline: none;
font-size: 20px;
font-weight: bold;
align-self: flex-start;
}
#child-view{
flex: 1;
}
#anchor-heading:hover{
border: none;
outline: none;
text-decoration: underline;
}
`;
const CategoryCard = ({ id, name }: CategoryCardProps) => {
const history = useHistory();
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
[QueryCacheKeys.categoryPlaylists, id],
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id, { limit: 4 }).then((playlistsRes) => playlistsRes.body.playlists.items),
async (spotifyApi) => {
const option = { limit: 4 };
let res;
if (id === "featured") {
res = await spotifyApi.getFeaturedPlaylists(option);
} else {
res = await spotifyApi.getPlaylistsForCategory(id, option);
}
return res.body.playlists.items;
},
{ initialData: [] }
);
function goToGenre(native: any) {
const mouse = new QMouseEvent(native);
if (mouse.button() === 1) {
history.push(`/genre/playlists/${id}`, { name });
}
}
if (isError) {
return <></>;
}
return (
<View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
<View id="child-view">
{playlists?.map((playlist, index) => {
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
})}
</View>
</View>
);
return <CategoryCardView url={`/genre/playlists/${id}`} isError={isError} name={name} playlists={playlists ?? []} />;
};

View File

@ -4,14 +4,14 @@ import React, { ReactElement, useContext, useEffect, useRef, useState } from "re
import playerContext, { CurrentPlaylist } from "../context/playerContext";
import { shuffleArray } from "../helpers/shuffleArray";
import NodeMpv from "node-mpv";
import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
import { getYoutubeTrack, YoutubeTrack } from "../helpers/getYoutubeTrack";
import PlayerProgressBar from "./PlayerProgressBar";
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular, heart, musicNode } from "../icons";
import IconButton from "./shared/IconButton";
import showError from "../helpers/showError";
import useTrackReaction from "../hooks/useTrackReaction";
import ManualLyricDialog from "./ManualLyricDialog";
import { LocalStorageKeys } from "../app";
import { LocalStorageKeys } from "../conf";
export const audioPlayer = new NodeMpv(
{
@ -35,6 +35,7 @@ function Player(): ReactElement {
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
const [isStopped, setIsStopped] = useState<boolean>(false);
const [openLyrics, setOpenLyrics] = useState<boolean>(false);
const [currentYtTrack, setCurrentYtTrack] = useState<YoutubeTrack>();
const playlistTracksIds = currentPlaylist?.tracks.map((t) => t.track.id);
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
{
@ -49,6 +50,8 @@ function Player(): ReactElement {
);
const playerRunning = audioPlayer.isRunning();
const titleRef = useRef<QLabel>();
const cachedPlaylist = localStorage.getItem(LocalStorageKeys.cachedPlaylist);
const cachedTrack = localStorage.getItem(LocalStorageKeys.cachedTrack);
// initial Effect
useEffect(() => {
@ -61,21 +64,31 @@ function Player(): ReactElement {
} 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: any) => console.log(e));
audioPlayer.quit().catch((e: unknown) => console.log(e));
}
};
}, []);
// track change effect
useEffect(() => {
// caching current track
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);
@ -94,6 +107,8 @@ function Player(): ReactElement {
// changing shuffle to default
useEffect(() => {
setShuffle(false);
// caching playlist
localStorage.setItem(LocalStorageKeys.cachedPlaylist, JSON.stringify(currentPlaylist ?? ""));
}, [currentPlaylist]);
useEffect(() => {
@ -191,10 +206,10 @@ function Player(): ReactElement {
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
<GridRow>
<GridColumn width={2}>
<Text ref={titleRef} wordWrap>
<Text ref={titleRef} wordWrap openExternalLinks>
{artistsNames && currentTrack
? `
<p><b>${currentTrack.name}</b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "feat. " + artistsNames.slice(1).join(", ") : ""}</p>
<p><b><a href="${currentYtTrack?.youtube_uri}"}>${currentTrack.name}</a></b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "feat. " + artistsNames.slice(1).join(", ") : ""}</p>
`
: `<b>Oh, dear don't waste time</b>`}
</Text>

View File

@ -13,7 +13,16 @@ function PlaylistGenreView() {
const location = useLocation<{ name: string }>();
const { data: pagedPlaylists, isError, isLoading, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.PagingObject<SpotifyApi.PlaylistObjectSimplified>>(
[QueryCacheKeys.genrePlaylists, id],
(spotifyApi, { pageParam }) => spotifyApi.getPlaylistsForCategory(id, { limit: 20, offset: pageParam }).then((playlistsRes) => playlistsRes.body.playlists),
async (spotifyApi, { pageParam }) => {
const option = { limit: 20, offset: pageParam };
let res;
if (id === "featured") {
res = await spotifyApi.getFeaturedPlaylists(option);
} else {
res = await spotifyApi.getPlaylistsForCategory(id, option);
}
return res.body.playlists;
},
{
getNextPageParam(lastPage) {
if (lastPage.next) {

View File

@ -0,0 +1,61 @@
import { QMouseEvent, CursorShape } from "@nodegui/nodegui";
import { View, Button } from "@nodegui/react-nodegui";
import React, { FC } from "react";
import { useHistory } from "react-router";
import PlaylistCard from "./PlaylistCard";
interface CategoryCardProps {
url: string;
name: string;
isError: boolean;
playlists: SpotifyApi.PlaylistObjectSimplified[];
}
const categoryStylesheet = `
#container{
flex: 1;
flex-direction: 'column';
justify-content: 'center';
}
#anchor-heading{
background: transparent;
padding: 10px;
border: none;
outline: none;
font-size: 20px;
font-weight: bold;
align-self: flex-start;
}
#child-view{
flex: 1;
}
#anchor-heading:hover{
border: none;
outline: none;
text-decoration: underline;
}
`;
const CategoryCard: FC<CategoryCardProps> = ({ name, isError, playlists, url }) => {
const history = useHistory();
function goToGenre(native: any) {
const mouse = new QMouseEvent(native);
if (mouse.button() === 1) {
history.push(url, { name });
}
}
if (isError) {
return <></>;
}
return (
<View id="container" styleSheet={categoryStylesheet}>
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
<View id="child-view">
{playlists.map((playlist, index) => {
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
})}
</View>
</View>
);
};
export default CategoryCard;

View File

@ -1,22 +1,32 @@
import dotenv from "dotenv"
import dotenv from "dotenv";
import { homedir } from "os";
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 trace = process.argv.find(arg => arg === "--trace") ?? false;
export const redirectURI = "http://localhost:4304/auth/spotify/callback"
export const confDir = join(homedir(), ".config", "spotube")
export const cacheDir = join(homedir(), ".cache", "spotube")
export const trace = process.argv.find((arg) => arg === "--trace") ?? false;
export const redirectURI = "http://localhost:4304/auth/spotify/callback";
export const confDir = join(homedir(), ".config", "spotube");
export const cacheDir = join(homedir(), ".cache", "spotube");
export enum QueryCacheKeys {
categories = "categories",
categoryPlaylists = "categoryPlaylists",
featuredPlaylists = "featuredPlaylists",
genrePlaylists = "genrePlaylists",
playlistTracks = "playlistTracks",
userPlaylists = "user-palylists",
userSavedTracks = "user-saved-tracks",
search = "search",
searchPlaylist = "searchPlaylist",
searchSongs = "searchSongs"
searchSongs = "searchSongs",
}
export enum LocalStorageKeys {
credentials = "credentials",
refresh_token = "refresh_token",
preferences = "user-preferences",
volume = "volume",
cachedPlaylist = "cached-playlist",
cachedTrack = "cached-track"
}

View File

@ -1,6 +1,6 @@
import path from "path";
import isUrl from "is-url";
import * as fs from "fs"
import fs from "fs";
import axios from "axios";
import { Stream } from "stream";
import { streamToBuffer } from "./streamToBuffer";
@ -8,7 +8,6 @@ import Jimp from "jimp";
import du from "du";
import { cacheDir } from "../conf";
interface ImageDimensions {
height: number;
width: number;
@ -22,20 +21,20 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
const cacheImgFolder = path.join(cacheDir, "images");
// for clearing up the cache if it reaches out of the size
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
const cachePath = path.join(cacheImgFolder, cacheName);
const cacheImgPath = path.join(cacheImgFolder, cacheName);
// checking if the cached image already exists or not
if (fs.existsSync(cachePath)) {
if (fs.existsSync(cacheImgPath)) {
// automatically removing cache after a certain 50 MB oversize
if ((await du(cacheImgFolder)) > MB_5) {
fs.rmSync(cacheImgFolder, { recursive: true, force: true });
fs.rmdirSync(cacheImgFolder, { recursive: true });
}
const cachedImg = await fsm.readFile(cachePath);
const cachedImg = await fsm.readFile(cacheImgPath);
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
// if the dimensions are changed then the previously cached
// images are removed and replaced with a new one
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) {
fs.rmSync(cachePath);
fs.unlinkSync(cacheImgPath);
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, cacheName, dims });
}
return cachedImg;
@ -61,7 +60,7 @@ export async function getCachedImageBuffer(name: string, dims?: ImageDimensions)
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> {
// caching the images by resizing if the max/fixed (Width/Height)
// is available in the args
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height)
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height);
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
return resizedImgBuffer;

View File

@ -1,6 +1,6 @@
import chalk from "chalk";
import { useContext, useEffect } from "react";
import { LocalStorageKeys } from "../app";
import { LocalStorageKeys } from "../conf";
import authContext from "../context/authContext";
import showError from "../helpers/showError";
import spotifyApi from "../initializations/spotifyApi";