- Lyric seek with continuos pop up on track change
- Settings (UI only)
- Switch (as toggler)
Modified:
- Home
- PlaylistGenreView
- TabMenu
All above was modified to adjust compatibility with
Lyric View & Settings
This commit is contained in:
KRTirtho 2021-03-30 09:52:03 +06:00
parent 87bd1a9abd
commit 09467e6e9a
18 changed files with 2062 additions and 291 deletions

1
assets/music-solid.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="music" class="svg-inline--fa fa-music fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M470.38 1.51L150.41 96A32 32 0 0 0 128 126.51v261.41A139 139 0 0 0 96 384c-53 0-96 28.66-96 64s43 64 96 64 96-28.66 96-64V214.32l256-75v184.61a138.4 138.4 0 0 0-32-3.93c-53 0-96 28.66-96 64s43 64 96 64 96-28.65 96-64V32a32 32 0 0 0-41.62-30.49z"></path></svg>

After

Width:  |  Height:  |  Size: 474 B

1
assets/setting-cog.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="cog" class="svg-inline--fa fa-cog fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M487.4 315.7l-42.6-24.6c4.3-23.2 4.3-47 0-70.2l42.6-24.6c4.9-2.8 7.1-8.6 5.5-14-11.1-35.6-30-67.8-54.7-94.6-3.8-4.1-10-5.1-14.8-2.3L380.8 110c-17.9-15.4-38.5-27.3-60.8-35.1V25.8c0-5.6-3.9-10.5-9.4-11.7-36.7-8.2-74.3-7.8-109.2 0-5.5 1.2-9.4 6.1-9.4 11.7V75c-22.2 7.9-42.8 19.8-60.8 35.1L88.7 85.5c-4.9-2.8-11-1.9-14.8 2.3-24.7 26.7-43.6 58.9-54.7 94.6-1.7 5.4.6 11.2 5.5 14L67.3 221c-4.3 23.2-4.3 47 0 70.2l-42.6 24.6c-4.9 2.8-7.1 8.6-5.5 14 11.1 35.6 30 67.8 54.7 94.6 3.8 4.1 10 5.1 14.8 2.3l42.6-24.6c17.9 15.4 38.5 27.3 60.8 35.1v49.2c0 5.6 3.9 10.5 9.4 11.7 36.7 8.2 74.3 7.8 109.2 0 5.5-1.2 9.4-6.1 9.4-11.7v-49.2c22.2-7.9 42.8-19.8 60.8-35.1l42.6 24.6c4.9 2.8 11 1.9 14.8-2.3 24.7-26.7 43.6-58.9 54.7-94.6 1.5-5.5-.7-11.3-5.6-14.1zM256 336c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
assets/times-solid.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="times" class="svg-inline--fa fa-times fa-w-11" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>

After

Width:  |  Height:  |  Size: 645 B

2083
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
"build": "webpack --mode=production",
"dev": "cross-env TSC_WATCHFILE=UseFsEvents webpack --mode=development",
"start": "qode ./dist/index.js",
"start-dev": "concurrently -n 'webpack,spotube' -p '{name}-{pid}' -c 'bgBlue,bgGreen' -i --default-input-target spotube 'npm run dev' 'nodemon -e node -w ./*.babelrc -x \"npm start\"'",
"start:trace": "qode ./dist/index.js --trace",
"debug": "qode --inspect ./dist/index.js",
"pack": "nodegui-packer -p ./dist",
@ -25,6 +26,7 @@
"dotenv": "^8.2.0",
"du": "^1.0.0",
"express": "^4.17.1",
"html-to-text": "^7.0.0",
"is-url": "^1.2.4",
"jimp": "^0.16.1",
"node-localstorage": "^2.1.6",
@ -39,14 +41,16 @@
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/core": "^7.13.10",
"@babel/preset-env": "^7.13.12",
"@babel/preset-react": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
"@babel/preset-typescript": "^7.13.0",
"@nodegui/devtools": "^1.0.1",
"@nodegui/packer": "^1.4.1",
"@types/color": "^3.0.1",
"@types/du": "^1.0.0",
"@types/express": "^4.17.11",
"@types/html-to-text": "^6.0.0",
"@types/is-url": "^1.2.28",
"@types/node": "^14.11.1",
"@types/node-localstorage": "^1.3.0",
@ -57,10 +61,12 @@
"@types/webpack-env": "^1.15.3",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"concurrently": "^6.0.0",
"cross-env": "^7.0.3",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^6.2.0",
"native-addon-loader": "^2.0.1",
"nodemon": "^2.0.7",
"typescript": "^4.2.3",
"webpack": "^5.27.0",
"webpack-cli": "^4.4.0"

View File

@ -15,8 +15,8 @@ function Home() {
);
return (
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
<View style={`flex-direction: 'column'; justify-content: 'center'; flex: 1;`}>
<ScrollArea style={`flex-grow: 1; border: none;`}>
<View style={`flex-direction: 'column'; align-items: 'center'; flex: 1;`}>
<PlaceholderApplet error={isError} message="Failed to query genres" reload={refetch} helps loading={isLoading} />
{categories?.map((category, index) => {
return <CategoryCard key={index + category.id} id={category.id} name={category.name} />;
@ -36,9 +36,8 @@ interface CategoryCardProps {
const categoryStylesheet = `
#container{
flex: 1;
flex-direction: column;
flex-direction: 'column';
justify-content: 'center';
margin-bottom: 20px;
}
#anchor-heading{
background: transparent;
@ -51,8 +50,6 @@ const categoryStylesheet = `
}
#child-view{
flex: 1;
justify-content: 'space-around';
align-items: 'center';
}
#anchor-heading:hover{
border: none;

View File

@ -4,19 +4,17 @@ import { Redirect, Route } from "react-router";
import { QueryCacheKeys } from "../conf";
import playerContext from "../context/playerContext";
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
import useSpotifyQuery from "../hooks/useSpotifyQuery";
import { GenreView } from "./PlaylistGenreView";
import { PlaylistSimpleControls, TrackTableIndex } from "./PlaylistView";
import PlaceholderApplet from "./shared/PlaceholderApplet";
import PlaylistCard from "./shared/PlaylistCard";
import { TrackButton, TrackButtonPlaylistObject } from "./shared/TrackButton";
import { TabMenuItem } from "./TabMenu";
function Library() {
return (
<View style="flex: 1; flex-direction: 'row';">
<View style="flex: 1; flex-direction: 'column';">
<Redirect from="/library" to="/library/saved-tracks" />
<View style="flex-direction: 'column'; flex: 1; max-width: 150px;">
<View style="max-width: 350px; justify-content: 'space-evenly'">
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
<TabMenuItem title="Playlists" url="/library/playlists" />
</View>

View File

@ -0,0 +1,61 @@
import { FlexLayout, QDialog, QLabel, QScrollArea, QWidget, TextFormat } from "@nodegui/nodegui";
import React, { PropsWithChildren, useEffect, useState } from "react";
import showError from "../helpers/showError";
import fetchLyrics from "../helpers/fetchLyrics";
interface ManualLyricDialogProps extends PropsWithChildren<{}> {
open: boolean;
onClose?: (closed:boolean) => void;
track: SpotifyApi.TrackObjectSimplified | SpotifyApi.TrackObjectFull;
}
function ManualLyricDialog({ open, track, onClose }: ManualLyricDialogProps) {
const dialog = new QDialog();
const areaContainer = new QWidget();
const scrollArea = new QScrollArea();
const titleLabel = new QLabel();
const lyricLabel = new QLabel();
const [lyrics, setLyrics] = useState<string>("");
const artists = track.artists.map((artist) => artist.name).join(", ");
useEffect(() => {
// title label
titleLabel.setText(`
<center>
<h2>${track.name}</h2>
<p>- ${artists}</p>
</center>
`);
// lyric label
lyricLabel.setText(lyrics);
lyricLabel.setTextFormat(TextFormat.PlainText);
// area container
areaContainer.setLayout(new FlexLayout());
areaContainer.setInlineStyle("flex: 1; flex-direction: 'column'; padding: 10px;");
areaContainer.layout?.addWidget(titleLabel);
areaContainer.layout?.addWidget(lyricLabel);
// scroll area
scrollArea.setInlineStyle("flex: 1;");
scrollArea.setWidget(areaContainer);
// dialog
dialog.setWindowTitle("Lyrics");
dialog.setLayout(new FlexLayout());
dialog.layout?.addWidget(scrollArea);
open ? dialog.open() : dialog.close();
open && fetchLyrics(artists, track.name)
.then((lyrics: string) => {
setLyrics(lyrics);
})
.catch((e: Error) => {
showError(e, `[Finding lyrics for ${track.name} failed]: `);
setLyrics("No lyrics found, rare track :)");
});
return () => {
dialog.hide()
}
}, [open, track, lyrics]);
return <></>;
}
export default ManualLyricDialog;

View File

@ -6,10 +6,11 @@ import { shuffleArray } from "../helpers/shuffleArray";
import NodeMpv from "node-mpv";
import { getYoutubeTrack } from "../helpers/getYoutubeTrack";
import PlayerProgressBar from "./PlayerProgressBar";
import { random as shuffleIcon, play, pause, backward, forward, stop, heartRegular, heart } from "../icons";
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";
export const audioPlayer = new NodeMpv(
{
@ -22,7 +23,6 @@ export const audioPlayer = new NodeMpv(
},
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"]
);
function Player(): ReactElement {
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
const { reactToTrack, isFavorite } = useTrackReaction();
@ -32,6 +32,7 @@ function Player(): ReactElement {
const [shuffle, setShuffle] = useState<boolean>(false);
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
const [isStopped, setIsStopped] = useState<boolean>(false);
const [openLyrics, setOpenLyrics] = useState<boolean>(false);
const playlistTracksIds = currentPlaylist?.tracks.map((t) => t.track.id);
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
{
@ -91,7 +92,7 @@ function Player(): ReactElement {
// changing shuffle to default
useEffect(() => {
setShuffle(false);
}, [currentPlaylist])
}, [currentPlaylist]);
useEffect(() => {
if (playerRunning) {
@ -198,6 +199,7 @@ function Player(): ReactElement {
</GridColumn>
<GridColumn width={4}>
<BoxView direction={Direction.TopToBottom} style={`max-width: 600px; min-width: 380px;`}>
{currentTrack && <ManualLyricDialog open={openLyrics} track={currentTrack} />}
<PlayerProgressBar audioPlayer={audioPlayer} totalDuration={totalDuration} />
<BoxView direction={Direction.LeftToRight}>
@ -221,6 +223,11 @@ function Player(): ReactElement {
}}
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>

View File

@ -35,14 +35,13 @@ interface GenreViewProps {
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: GenreViewProps) {
const playlistGenreViewStylesheet = `
#genre-container{
flex-direction: column;
flex-direction: 'column';
flex: 1;
}
#heading {
padding: 10px;
}
#scroll-view{
flex: 1;
flex-grow: 1;
border: none;
}

View File

@ -0,0 +1,36 @@
import { Text, View } from "@nodegui/react-nodegui";
import React from "react";
import Switch from "./shared/Switch";
function Settings() {
return (
<View style="flex: 1; flex-direction: 'column'; justify-content: 'flex-start';">
<Text>{`<center><h2>Settings</h2></center>`}</Text>
<View style="width: '100%'; flex-direction: 'column'; justify-content: 'flex-start';">
<SettingsCheckTile title="Use images instead of colors for playlist" subtitle="This will increase memory usage" />
<SettingsCheckTile title="Some unknown settings" />
</View>
</View>
);
}
export default Settings;
interface SettingsCheckTileProps {
title: string;
subtitle?: string;
}
export function SettingsCheckTile({ title, subtitle = "" }: SettingsCheckTileProps) {
return (
<View style="flex: 1; align-items: 'center'; padding: 15px 0; justify-content: 'space-between';">
<Text>
{`
<b>${title}</b>
<p>${subtitle}</p>
`}
</Text>
<Switch checked/>
</View>
);
}

View File

@ -1,8 +1,12 @@
import React from "react";
import { View, Button, Text } from "@nodegui/react-nodegui";
import { useHistory, useLocation } from "react-router";
import { settingsCog } from "../icons";
import IconButton from "./shared/IconButton";
import { QIcon } from "@nodegui/nodegui";
function TabMenu() {
const history = useHistory();
return (
<View id="tabmenu" styleSheet={tabBarStylesheet}>
@ -13,6 +17,14 @@ function TabMenu() {
<TabMenuItem url="/library" title="Library" />
<TabMenuItem url="/currently" title="Currently Playing" />
<TabMenuItem url="/search" title="Search" />
<IconButton
icon={new QIcon(settingsCog)}
on={{
clicked() {
history.push("/settings");
},
}}
/>
</View>
);
}

View File

@ -0,0 +1,42 @@
import { Orientation, QMouseEvent } from "@nodegui/nodegui";
import { Slider } from "@nodegui/react-nodegui";
import { CheckBoxProps } from "@nodegui/react-nodegui/dist/components/CheckBox/RNCheckBox";
import React, { useEffect, useState } from "react";
interface SwitchProps extends Omit<CheckBoxProps, "on" |"icon" | "text">{
onChange?(checked:boolean): void
}
function Switch({ checked, onChange, ...props }: SwitchProps) {
const [value, setValue] = useState<0|1>(0);
useEffect(() => {
setValue(checked ? 1 : 0);
}, [checked])
return (
<Slider
value={value}
hasTracking
mouseTracking
orientation={Orientation.Horizontal}
maximum={1}
minimum={0}
maxSize={{ width: 30, height: 20 }}
on={{
valueChanged(value) {
onChange && onChange(value === 1);
},
MouseButtonRelease(native: any) {
const mouse = new QMouseEvent(native);
if (mouse.button() === 1) {
setValue(value===1?0:1)
}
}
}
}
{...props}
/>);
}
export default Switch;

View File

@ -0,0 +1,41 @@
import axios from 'axios';
import htmlToText from 'html-to-text';
const delim1 = '</div></div></div></div><div class="hwc"><div class="BNeawe tAd8D AP7Wnd"><div><div class="BNeawe tAd8D AP7Wnd">';
const delim2 = '</div></div></div></div></div><div><span class="hwc"><div class="BNeawe uEec3 AP7Wnd">';
const url = "https://www.google.com/search?q=";
export default async function fetchLyrics(artists:string, title: string) {
let lyrics;
try {
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+lyrics`, {responseType: "text"})).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (m) {
try {
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song+lyrics`)).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (n) {
try {
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}+song`)).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (o) {
try {
lyrics = (await axios.get<string>(`${url}${encodeURIComponent(title + " " + artists)}`)).data;
[, lyrics] = lyrics.split(delim1);
[lyrics] = lyrics.split(delim2);
} catch (p) {
lyrics = 'Not Found';
}
}
}
}
const rets = lyrics.split('\n');
let final = '';
for (const ret of rets) {
final = `${final}${htmlToText.htmlToText(ret)}\n`;
}
return final.trim();
}

View File

@ -26,7 +26,7 @@ export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promis
try {
const artistsName = track.artists.map((ar) => ar.name);
const queryString = `${artistsName[0]} - ${track.name}${artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``}`;
console.log('Youtube Query String:', queryString);
console.log("Youtube Query String:", queryString);
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
const tracksWithRelevance = result
.map((video) => {
@ -40,7 +40,9 @@ export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promis
.sort((a, b) => (a.matchPercentage > b.matchPercentage ? -1 : 1));
const sameChannelTracks = tracksWithRelevance.filter((tr) => tr.sameChannel);
const finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance)[0].url };
const rarestTrack = result.map((res) => ({ url: `http://www.youtube.com/watch?v=${res.id}`, id: res.id }));
const finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance.length > 0 ? tracksWithRelevance : rarestTrack)[0].url };
return finalTrack;
} catch (error) {
console.error("Failed to resolve track's youtube url: ", error);

View File

@ -9,6 +9,9 @@ import _random from "../assets/random-solid.svg"
import _stop from "../assets/stop-solid.svg"
import _search from "../assets/search-solid.svg";
import _loadingSpinner from "../assets/loading-spinner.gif";
import _settingsCog from "../assets/setting-cog.svg"
import _times from "../assets/times-solid.svg"
import _musicNode from "../assets/music-solid.svg"
export const play = _play;
export const pause = _pause;
@ -21,3 +24,6 @@ export const random = _random;
export const stop = _stop;
export const search = _search;
export const loadingSpinner = _loadingSpinner;
export const settingsCog = _settingsCog;
export const times = _times;
export const musicNode = _musicNode;

View File

@ -2,8 +2,14 @@ import { Renderer } from "@nodegui/react-nodegui";
import React from "react";
import App from "./app";
process.title = "My NodeGui App";
Renderer.render(<App />);
process.title = "Spotube";
Renderer.render(<App />, {
onInit(reconciler) {
if (process.env.NODE_ENV === "development") {
require("@nodegui/devtools").connectReactDevtools(reconciler);
}
},
});
// This is for hot reloading (this will be stripped off in production by webpack)
if (module.hot) {
module.hot.accept(["./app"], function () {

View File

@ -11,6 +11,7 @@ import Library from "./components/Library";
import Search from "./components/Search";
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
import Settings from "./components/Settings";
function Routes() {
const { isLoggedIn } = useContext(authContext);
@ -50,6 +51,9 @@ function Routes() {
<Route exact path="/search/songs">
<SearchResultSongsCollection />
</Route>
<Route exact path="/settings/">
<Settings />
</Route>
</>
);
}