Finished bootstraping basic UI
8
.babelrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "node": "12" } }],
|
||||
"@babel/preset-typescript",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": []
|
||||
}
|
3
.directory
Normal file
@ -0,0 +1,3 @@
|
||||
[Dolphin]
|
||||
Timestamp=2021,2,13,23,13,52
|
||||
Version=4
|
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist/
|
||||
*.log
|
||||
cache/
|
||||
local/
|
63
README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# react-nodegui-starter
|
||||
|
||||
**Clone and run for a quick way to see React NodeGui in action.**
|
||||
|
||||
<img alt="logo" src="https://github.com/nodegui/react-nodegui-starter/raw/master/assets/demo.png" height="500" />
|
||||
|
||||
## To Use
|
||||
|
||||
To clone and run this repository you'll need [Git](https://git-scm.com) and [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) installed on your computer. From your command line:
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/nodegui/react-nodegui-starter
|
||||
# Install CMake
|
||||
brew install cmake
|
||||
# Go into the repository
|
||||
cd react-nodegui-starter
|
||||
# Install dependencies
|
||||
npm install
|
||||
# Run the dev server
|
||||
npm run dev
|
||||
# Open andother terminal and run the app
|
||||
npm start
|
||||
```
|
||||
|
||||
## Installation & Resources for learning React NodeGui
|
||||
|
||||
- [Documentation](https://react.nodegui.org) - all of React NodeGui's documentation.
|
||||
- [NodeGui](https://nodegui.org) - all of NodeGui's documentation.
|
||||
|
||||
## Packaging app as a distributable
|
||||
|
||||
In order to distribute your finished app, you can use [@nodegui/packer](https://github.com/nodegui/packer)
|
||||
|
||||
### Step 1: (_**Run this command only once**_)
|
||||
|
||||
```sh
|
||||
npx nodegui-packer --init MyAppName
|
||||
```
|
||||
|
||||
This will produce the deploy directory containing the template. You can modify this to suite your needs. Like add icons, change the name, description and add other native features or dependencies. Make sure you commit this directory.
|
||||
|
||||
### Step 2: (_**Run this command every time you want to build a new distributable**_)
|
||||
|
||||
Next you can run the pack command:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will produce the js bundle along with assets inside the `./dist` directory
|
||||
|
||||
```sh
|
||||
npx nodegui-packer --pack ./dist
|
||||
```
|
||||
|
||||
This will build the distributable using @nodegui/packer based on your template. The output of the command is found under the build directory. You should gitignore the build directory.
|
||||
|
||||
More details about packer can be found here: https://github.com/nodegui/packer
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
6
assets.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module "*.svg";
|
||||
declare module "*.png";
|
||||
declare module "*.jpg";
|
||||
declare module "*.jpeg";
|
||||
declare module "*.gif";
|
||||
declare module "*.bmp";
|
1
assets/angle-left-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-left" class="svg-inline--fa fa-angle-left fa-w-8" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path fill="currentColor" d="M31.7 239l136-136c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9L127.9 256l96.4 96.4c9.4 9.4 9.4 24.6 0 33.9L201.7 409c-9.4 9.4-24.6 9.4-33.9 0l-136-136c-9.5-9.4-9.5-24.6-.1-34z"></path></svg>
|
After Width: | Height: | Size: 427 B |
1
assets/backward-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="backward" class="svg-inline--fa fa-backward fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M11.5 280.6l192 160c20.6 17.2 52.5 2.8 52.5-24.6V96c0-27.4-31.9-41.8-52.5-24.6l-192 160c-15.3 12.8-15.3 36.4 0 49.2zm256 0l192 160c20.6 17.2 52.5 2.8 52.5-24.6V96c0-27.4-31.9-41.8-52.5-24.6l-192 160c-15.3 12.8-15.3 36.4 0 49.2z"></path></svg>
|
After Width: | Height: | Size: 463 B |
BIN
assets/demo.png
Normal file
After Width: | Height: | Size: 163 KiB |
1
assets/forward-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="forward" class="svg-inline--fa fa-forward fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"></path></svg>
|
After Width: | Height: | Size: 454 B |
1
assets/heart-regular.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="heart" class="svg-inline--fa fa-heart fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"></path></svg>
|
After Width: | Height: | Size: 640 B |
1
assets/heart-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="heart" class="svg-inline--fa fa-heart fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"></path></svg>
|
After Width: | Height: | Size: 437 B |
BIN
assets/nodegui.jpg
Normal file
After Width: | Height: | Size: 58 KiB |
1
assets/pause-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="pause" class="svg-inline--fa fa-pause fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"></path></svg>
|
After Width: | Height: | Size: 444 B |
1
assets/play-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="play" class="svg-inline--fa fa-play fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"></path></svg>
|
After Width: | Height: | Size: 339 B |
1
assets/random-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="random" class="svg-inline--fa fa-random fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M504.971 359.029c9.373 9.373 9.373 24.569 0 33.941l-80 79.984c-15.01 15.01-40.971 4.49-40.971-16.971V416h-58.785a12.004 12.004 0 0 1-8.773-3.812l-70.556-75.596 53.333-57.143L352 336h32v-39.981c0-21.438 25.943-31.998 40.971-16.971l80 79.981zM12 176h84l52.781 56.551 53.333-57.143-70.556-75.596A11.999 11.999 0 0 0 122.785 96H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12zm372 0v39.984c0 21.46 25.961 31.98 40.971 16.971l80-79.984c9.373-9.373 9.373-24.569 0-33.941l-80-79.981C409.943 24.021 384 34.582 384 56.019V96h-58.785a12.004 12.004 0 0 0-8.773 3.812L96 336H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12h110.785c3.326 0 6.503-1.381 8.773-3.812L352 176h32z"></path></svg>
|
After Width: | Height: | Size: 904 B |
1
assets/stop-solid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" class="svg-inline--fa fa-stop fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg>
|
After Width: | Height: | Size: 333 B |
726
node-mpv.d.ts
vendored
Normal file
@ -0,0 +1,726 @@
|
||||
// Type definitions for node-mpv 2.0-beta.0
|
||||
// Project: node-mpv <https://github.com/j-holub/Node-MPV>
|
||||
// Definitions by: leonekmi <me@leonekmi.fr>
|
||||
|
||||
declare module "node-mpv" {
|
||||
import EventEmitter = NodeJS.EventEmitter;
|
||||
|
||||
interface NodeMpvOptions {
|
||||
// Print debug lines
|
||||
debug?: boolean;
|
||||
// Print more lines
|
||||
verbose?: boolean;
|
||||
// Specify socket
|
||||
socket?: string;
|
||||
// Don't open video display
|
||||
audio_only?: boolean;
|
||||
// Auto-restart on a crash
|
||||
auto_restart?: boolean;
|
||||
// Time update for timeposition event
|
||||
time_update?: number;
|
||||
// Path to mpv binary
|
||||
binary?: string;
|
||||
}
|
||||
|
||||
/*interface NodeMpvError {
|
||||
|
||||
}*/
|
||||
|
||||
// these are the emitted events
|
||||
type EventNames = "crashed" | "getrequest" | "seek" | "started" | "stopped" | "paused" | "resumed" | "status" | "timeposition" | "quit";
|
||||
type VoidCallback = () => void;
|
||||
type VoidCallbackWithData<ArgType> = (arg: ArgType) => void;
|
||||
type VoidCallbackWithData2<ArgType, ArgType2> = (arg: ArgType, arg2: ArgType2) => void;
|
||||
|
||||
type LoadMode = "replace" | "append";
|
||||
type MediaLoadMode = LoadMode | "append-play";
|
||||
type AudioFlag = "select" | "auto" | "cached";
|
||||
type SeekMode = "relative" | "absolute";
|
||||
type RepeatMode = number | "inf" | "no";
|
||||
type PlaylistMode = "weak" | "force";
|
||||
|
||||
interface TimePosition {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
type StatusObjectProperties = "mute" | "pause" | "duration" | "volume" | "filename" | "path" | "media-title" | "playlist-pos" | "playlist-count" | "loop" | "fullscreen" | "sub-visibility";
|
||||
|
||||
interface StatusObject {
|
||||
property: StatusObjectProperties;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
type EventListenerArgs<EventName extends EventNames> = [EventName, VoidCallback];
|
||||
type EventListenerArgsWithData<EventName extends EventNames, DataType> = [EventName, VoidCallbackWithData<DataType>];
|
||||
type EventListenerArgsWithMultipleData<EventName extends EventNames, DataType, DataType1> = [EventName, VoidCallbackWithData2<DataType, DataType1>];
|
||||
|
||||
export default class NodeMpv implements EventEmitter {
|
||||
/**
|
||||
* Listen to certain events which are emitted after any kind of
|
||||
* status change of mpv player.
|
||||
* @param args
|
||||
* @see for Events https://github.com/j-holub/Node-MPV#events
|
||||
*/
|
||||
|
||||
addListener(...args: EventListenerArgs<"crashed">): this;
|
||||
addListener(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
addListener(...args: EventListenerArgs<"paused">): this;
|
||||
addListener(...args: EventListenerArgs<"quit">): this;
|
||||
addListener(...args: EventListenerArgs<"resumed">): this;
|
||||
addListener(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
addListener(...args: EventListenerArgs<"started">): this;
|
||||
addListener(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
addListener(...args: EventListenerArgs<"stopped">): this;
|
||||
addListener(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
/**
|
||||
* Listen to certain events which are emitted after any kind of
|
||||
* status change of mpv player
|
||||
* @param args
|
||||
* @see for Events https://github.com/j-holub/Node-MPV#events
|
||||
*/
|
||||
on(...args: EventListenerArgs<"crashed">): this;
|
||||
on(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
on(...args: EventListenerArgs<"paused">): this;
|
||||
on(...args: EventListenerArgs<"quit">): this;
|
||||
on(...args: EventListenerArgs<"resumed">): this;
|
||||
on(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
on(...args: EventListenerArgs<"started">): this;
|
||||
on(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
on(...args: EventListenerArgs<"stopped">): this;
|
||||
on(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
/**
|
||||
* Listen to certain events which are emitted after any kind of
|
||||
* status change of mpv player
|
||||
* @param args
|
||||
* @see for Events https://github.com/j-holub/Node-MPV#events
|
||||
*/
|
||||
once(...args: EventListenerArgs<"crashed">): this;
|
||||
once(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
once(...args: EventListenerArgs<"paused">): this;
|
||||
once(...args: EventListenerArgs<"quit">): this;
|
||||
once(...args: EventListenerArgs<"resumed">): this;
|
||||
once(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
once(...args: EventListenerArgs<"started">): this;
|
||||
once(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
once(...args: EventListenerArgs<"stopped">): this;
|
||||
once(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
/**
|
||||
* Remove listener that is listening to the provided event `eventName`
|
||||
* @param args
|
||||
*/
|
||||
off(...args: EventListenerArgs<"crashed">): this;
|
||||
off(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
off(...args: EventListenerArgs<"paused">): this;
|
||||
off(...args: EventListenerArgs<"quit">): this;
|
||||
off(...args: EventListenerArgs<"resumed">): this;
|
||||
off(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
off(...args: EventListenerArgs<"started">): this;
|
||||
off(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
off(...args: EventListenerArgs<"stopped">): this;
|
||||
off(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
/**
|
||||
* Remove listener that is listening to the provided event `eventName`
|
||||
* @param args
|
||||
*/
|
||||
removeListener(...args: EventListenerArgs<"crashed">): this;
|
||||
removeListener(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
removeListener(...args: EventListenerArgs<"paused">): this;
|
||||
removeListener(...args: EventListenerArgs<"quit">): this;
|
||||
removeListener(...args: EventListenerArgs<"resumed">): this;
|
||||
removeListener(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
removeListener(...args: EventListenerArgs<"started">): this;
|
||||
removeListener(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
removeListener(...args: EventListenerArgs<"stopped">): this;
|
||||
removeListener(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
/**
|
||||
* Removes all listeners listening to a particular event `eventName`
|
||||
* @param {EventNames?} event - Event names
|
||||
*/
|
||||
|
||||
removeAllListeners(event?: EventNames): this;
|
||||
|
||||
setMaxListeners(n: number): this;
|
||||
|
||||
getMaxListeners(): number;
|
||||
|
||||
listeners(event: EventNames): Function[];
|
||||
|
||||
rawListeners(event: EventNames): Function[];
|
||||
emit(event: EventNames, ...args: any[]): boolean;
|
||||
listenerCount(event: EventNames): number;
|
||||
// Added in Node 6...
|
||||
prependListener(...args: EventListenerArgs<"crashed">): this;
|
||||
prependListener(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
prependListener(...args: EventListenerArgs<"paused">): this;
|
||||
prependListener(...args: EventListenerArgs<"quit">): this;
|
||||
prependListener(...args: EventListenerArgs<"resumed">): this;
|
||||
prependListener(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
prependListener(...args: EventListenerArgs<"started">): this;
|
||||
prependListener(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
prependListener(...args: EventListenerArgs<"stopped">): this;
|
||||
prependListener(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
prependOnceListener(...args: EventListenerArgs<"crashed">): this;
|
||||
prependOnceListener(...args: EventListenerArgsWithMultipleData<"getrequest", string, any>): this;
|
||||
prependOnceListener(...args: EventListenerArgs<"paused">): this;
|
||||
prependOnceListener(...args: EventListenerArgs<"quit">): this;
|
||||
prependOnceListener(...args: EventListenerArgs<"resumed">): this;
|
||||
prependOnceListener(...args: EventListenerArgsWithData<"seek", TimePosition>): this;
|
||||
prependOnceListener(...args: EventListenerArgs<"started">): this;
|
||||
prependOnceListener(...args: EventListenerArgsWithData<"status", StatusObject>): this;
|
||||
prependOnceListener(...args: EventListenerArgs<"stopped">): this;
|
||||
prependOnceListener(...args: EventListenerArgsWithData<"timeposition", number>): this;
|
||||
|
||||
eventNames(): Array<string | symbol>;
|
||||
|
||||
/**
|
||||
* A mpv wrapper for node
|
||||
*
|
||||
* @param options - Tweak NodeMPV behaviour
|
||||
* @param mpv_args - Arrays of CLI options to pass to mpv. IPC options are automatically appended.
|
||||
*/
|
||||
constructor(options?: NodeMpvOptions, mpv_args?: string[]);
|
||||
|
||||
/**
|
||||
* Loads a file into MPV
|
||||
*
|
||||
* @param source - Path to the media file
|
||||
* @param mode
|
||||
* `replace`: replace the current media
|
||||
*
|
||||
* `append`: Append at the end of the playlist
|
||||
*
|
||||
* `append-play`: Append after the current song
|
||||
* @param options - Options formatted as option=label
|
||||
*/
|
||||
load(source: string, mode?: MediaLoadMode, options?: string[]): Promise<void>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_audio.js
|
||||
/**
|
||||
* Add an audio track to the media file
|
||||
*
|
||||
* @param file - Path to the audio track
|
||||
* @param flag - Flag to use (select, auto, cached)
|
||||
* @param title - Title in OSD/OSC
|
||||
* @param lang - Language
|
||||
*/
|
||||
addAudioTrack(file: string, flag?: AudioFlag, title?: string, lang?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove an audio track based on its id.
|
||||
*
|
||||
* @param id - ID of the audio track to remove
|
||||
*/
|
||||
removeAudioTrack(id: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Select an audio track based on its id.
|
||||
*
|
||||
* @param id - ID of the audio track to select
|
||||
*/
|
||||
selectAudioTrack(id: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cycles through the audio track
|
||||
*/
|
||||
cycleAudioTracks(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Adjust audio timing
|
||||
* @param seconds - Delay in seconds
|
||||
*/
|
||||
adjustAudioTiming(seconds: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set playback speed
|
||||
* @param factor - 0.1 - 100: percentage of playback speed
|
||||
*/
|
||||
speed(factor: number): Promise<void>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_controls.js
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
togglePause(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Pauses playback
|
||||
*/
|
||||
pause(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resumes playback
|
||||
*/
|
||||
resume(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Play the file in playlist
|
||||
*/
|
||||
play(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop playback immediately
|
||||
*/
|
||||
stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*
|
||||
* @param volume
|
||||
*/
|
||||
volume(volume: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Increase/Decrease volume
|
||||
*
|
||||
* @param volume
|
||||
*/
|
||||
adjustVolume(volume: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mute
|
||||
*
|
||||
* @param set - setMute, if not specified, cycles
|
||||
*/
|
||||
mute(set?: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Seek
|
||||
*
|
||||
* @param seconds - Seconds
|
||||
* @param mode - Relative, absolute
|
||||
* @see for info about seek https://mpv.io/manual/stable/#command-interface-seek-%3Ctarget%3E-[%3Cflags%3E]
|
||||
*/
|
||||
seek(seconds: number, mode?: SeekMode): Promise<void>;
|
||||
|
||||
/**
|
||||
* Shorthand for absolute seek
|
||||
*
|
||||
* @param seconds - Seconds
|
||||
*/
|
||||
goToPosition(seconds: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set loop mode for current file
|
||||
*
|
||||
* @param times - either a number of loop iterations, 'inf' for infinite loop or 'no' to disable loop.
|
||||
* If it's not specified, the property will cycle through inf and no.
|
||||
*/
|
||||
loop(times?: RepeatMode): Promise<void>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_commands.js
|
||||
// List of mpv properties are available here: https://mpv.io/manual/stable/#property-list
|
||||
/**
|
||||
* Retrieve a property
|
||||
*
|
||||
* @param property
|
||||
*/
|
||||
getProperty(property: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Set a property
|
||||
*
|
||||
* @param property
|
||||
* @param value
|
||||
*/
|
||||
setProperty(property: string, value: any): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set a set of properties
|
||||
*
|
||||
* @param properties - {property1: value1, property2: value2}
|
||||
*/
|
||||
setMultipleProperties(properties: object): Promise<void>;
|
||||
|
||||
/**
|
||||
* Add value to a property (only on number properties)
|
||||
*
|
||||
* @param property
|
||||
* @param value
|
||||
*/
|
||||
addProperty(property: string, value: number): Promise<void>;
|
||||
/**
|
||||
* Multiply a property by value (only on number properties)
|
||||
*
|
||||
* @param property
|
||||
* @param value
|
||||
*/
|
||||
multiplyProperty(property: string, value: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cycle through different modes of a property (boolean, enum)
|
||||
*
|
||||
* @param property
|
||||
*/
|
||||
cycleProperty(property: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send a custom command to mpv
|
||||
*
|
||||
* @param command Command name
|
||||
* @param args Array of arguments
|
||||
*/
|
||||
command(command: string, args: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send a custom payload to mpv
|
||||
*
|
||||
* @param command the JSON command to send to mpv
|
||||
*/
|
||||
commandJSON(command: object): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send a custom payload to mpv (no JSON encode)
|
||||
*
|
||||
* @param command the JSON encoded command to send to mpv
|
||||
*/
|
||||
freeCommand(command: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Observe a property
|
||||
* You can receive events with the 'status' event
|
||||
*
|
||||
* @param property The property to observe
|
||||
*/
|
||||
observeProperty(property: string): any;
|
||||
|
||||
/**
|
||||
* Unobserve a property
|
||||
*
|
||||
* @param property
|
||||
*/
|
||||
unobserveProperty(property: string): any;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_information.js
|
||||
/**
|
||||
* Returns the mute status of mpv
|
||||
*/
|
||||
isMuted(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns the pause status of mpv
|
||||
*/
|
||||
isPaused(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns the seekable property of the loaded media
|
||||
* Some medias are not seekable (livestream, unbuffered media)
|
||||
*/
|
||||
isSeekable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Retrieve the duration of the loaded media
|
||||
*/
|
||||
getDuration(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieve the current time position of the loaded media
|
||||
*/
|
||||
getTimePosition(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieve the current time position (in percentage) of the loaded media
|
||||
*/
|
||||
getPercentPosition(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieve the time remaining of the loaded media
|
||||
*/
|
||||
getTimeRemaining(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieve the metadata of the loaded media
|
||||
*/
|
||||
getMetadata(): Promise<object>;
|
||||
|
||||
/**
|
||||
* Retrieve the title of the loaded media
|
||||
*/
|
||||
getTitle(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieve the artist of the loaded media
|
||||
*/
|
||||
getArtist(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieve the album of the loaded media
|
||||
*/
|
||||
getAlbum(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieve the year of the loaded media
|
||||
*/
|
||||
getYear(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Retrieve the filename of the loaded media
|
||||
*
|
||||
* @param format 'stripped' remove the extension, default to 'full'
|
||||
*/
|
||||
getFilename(format?: "full" | "stripped"): Promise<string>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_playlist.js
|
||||
/**
|
||||
* Load a playlist file
|
||||
*
|
||||
* @param playlist Path to the playlist file
|
||||
* @param mode 'append' adds the playlist to the existing one. Defaults to 'replace'
|
||||
*/
|
||||
loadPlaylist(playlist: string, mode?: LoadMode): Promise<void>;
|
||||
|
||||
/**
|
||||
* Add a song to the playlist
|
||||
*
|
||||
* @param source File path of media
|
||||
* @param mode
|
||||
* replace: replace the current media
|
||||
* append: Append at the end of the playlist
|
||||
* append-play: Append after the current song
|
||||
* @param options
|
||||
*/
|
||||
append(source: string, mode?: MediaLoadMode, options?: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Load next element in playlist
|
||||
*
|
||||
* @param mode - 'force' may go into an undefined index of the playlist
|
||||
*/
|
||||
next(mode?: PlaylistMode): Promise<void>;
|
||||
|
||||
/**
|
||||
* Load previous element in playlist
|
||||
*
|
||||
* @param mode - 'force' may go into an undefined index of the playlist
|
||||
*/
|
||||
prev(mode?: PlaylistMode): Promise<void>;
|
||||
|
||||
/**
|
||||
* Jump to position in playlist
|
||||
*
|
||||
* @param position
|
||||
*/
|
||||
jump(position: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Empty the playlist
|
||||
*/
|
||||
clearPlaylist(): Promise<void>;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param index
|
||||
*/
|
||||
playlistRemove(index: number): Promise<void>;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param index1
|
||||
* @param index2
|
||||
*/
|
||||
playlistMove(index1: number, index2: number): Promise<void>;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
shuffle(): Promise<void>;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getPlaylistSize(): Promise<number>;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getPlaylistPosition(): Promise<number>;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getPlaylistPosition1(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Set loop mode for playlist
|
||||
*
|
||||
* @param times - either a number of loop iterations, 'inf' for infinite loop or 'no' to disable loop.
|
||||
* If it's not specified, the property will cycle through inf and no.
|
||||
*/
|
||||
loopPlaylist(times?: RepeatMode): Promise<void>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_startStop.js
|
||||
/**
|
||||
* Starts mpv, by spawning a child process or by attaching to existing socket
|
||||
*/
|
||||
start(): Promise<void>;
|
||||
/**
|
||||
* Closes mpv
|
||||
*
|
||||
* [Important!] Calling method `quit` doesn't trigger the event `quit`
|
||||
*/
|
||||
quit(): Promise<void>;
|
||||
/**
|
||||
* Returns the status of mpv
|
||||
*/
|
||||
isRunning(): boolean;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_subtitle.js
|
||||
/**
|
||||
* Loads a subtitle file into the current media file
|
||||
*
|
||||
* @param file Path to the subtitle file
|
||||
* @param flag
|
||||
* Select: Select the loaded file
|
||||
* Auto: Let mpv decide
|
||||
* Cached: Don't select the loaded file
|
||||
* @param title Title to show in OSD/OSC
|
||||
* @param lang Language of the subtitles
|
||||
*/
|
||||
addSubtitles(file: string, flag?: "select" | "auto" | "cached", title?: string, lang?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove subtitles
|
||||
*
|
||||
* @param id Index of subtitles to delete
|
||||
*/
|
||||
removeSubtitles(id: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cycle through available subtitles
|
||||
*/
|
||||
cycleSubtitles(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Select subtitles by its id
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
selectSubtitles(id: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Toggle subtitles visibility
|
||||
*/
|
||||
toggleSubtitleVisibility(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Show the subtitles on the screen
|
||||
*/
|
||||
showSubtitles(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Hide the subtitles on the screen
|
||||
*/
|
||||
hideSubtitles(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Adjust the subtitles offset to seconds
|
||||
*
|
||||
* @param seconds Offset to apply in seconds
|
||||
*/
|
||||
adjustSubtitleTiming(seconds: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Seek based on subtitles lines
|
||||
*
|
||||
* @param lines
|
||||
*/
|
||||
subtitleSeek(lines: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Scale the font of subtitles based on scale
|
||||
*
|
||||
* @param scale
|
||||
*/
|
||||
subtitleScale(scale: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Show a text using ASS renderer
|
||||
*
|
||||
* @param ass an ass string
|
||||
* @param duration duration in seconds
|
||||
* @param position ASS alignment
|
||||
*/
|
||||
displayASS(ass: string, duration: number, position?: number): Promise<void>;
|
||||
|
||||
// https://github.com/j-holub/Node-MPV/blob/master/lib/mpv/_video.js
|
||||
/**
|
||||
* Enter fullscreen
|
||||
*/
|
||||
fullscreen(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Leave fullscreen
|
||||
*/
|
||||
leaveFullscreen(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Toggle fullscreen
|
||||
*/
|
||||
toggleFullscreen(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Take a screenshot
|
||||
*
|
||||
* @param file
|
||||
* @param option
|
||||
* Subtitles: show subtitles
|
||||
* Video: hide subtitles/osd/osc
|
||||
* Window: Take the screen at the size of the window
|
||||
*/
|
||||
screenshot(file: string, option: "subtitles" | "video" | "window"): Promise<void>;
|
||||
|
||||
/**
|
||||
* Rotate the video
|
||||
*
|
||||
* @param degrees
|
||||
*/
|
||||
rotateVideo(degrees: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Zoom the video, 0 means no zoom, 1 means x2
|
||||
*
|
||||
* @param factor
|
||||
*/
|
||||
zoomVideo(factor: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set Brightness
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
brightness(value: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set Contrast
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
contrast(value: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set saturation
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
saturation(value: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set gamme on media
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
gamma(value: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set Hue
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
hue(value: number): Promise<void>;
|
||||
}
|
||||
}
|
12275
package-lock.json
generated
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "spotube",
|
||||
"version": "0.0.1",
|
||||
"main": "index.js",
|
||||
"author": "KR Tirtho",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack -p",
|
||||
"dev": "TSC_WATCHFILE=UseFsEvents webpack --mode=development",
|
||||
"start": "qode ./dist/index.js",
|
||||
"debug": "qode --inspect ./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodegui/nodegui": "^0.27.0",
|
||||
"@nodegui/react-nodegui": "^0.10.0",
|
||||
"axios": "^0.21.1",
|
||||
"du": "^1.0.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-localstorage": "^2.1.6",
|
||||
"node-mpv": "^2.0.0-beta.1",
|
||||
"react": "^16.14.0",
|
||||
"react-router": "^5.2.0",
|
||||
"scrape-yt": "^1.4.7",
|
||||
"sharp": "^0.27.1",
|
||||
"spotify-web-api-node": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-env": "^7.11.5",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@babel/preset-typescript": "^7.10.4",
|
||||
"@nodegui/packer": "^1.4.1",
|
||||
"@types/du": "^1.0.0",
|
||||
"@types/is-url": "^1.2.28",
|
||||
"@types/node": "^14.11.1",
|
||||
"@types/node-localstorage": "^1.3.0",
|
||||
"@types/react": "^16.9.49",
|
||||
"@types/react-router": "^5.1.11",
|
||||
"@types/sharp": "^0.27.1",
|
||||
"@types/spotify-web-api-node": "^5.0.0",
|
||||
"@types/webpack-env": "^1.15.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.1.0",
|
||||
"native-addon-loader": "^2.0.1",
|
||||
"typescript": "^4.0.3",
|
||||
"webpack": "^5.18.0",
|
||||
"webpack-cli": "^4.4.0"
|
||||
}
|
||||
}
|
87
src/app.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Window, hot, BoxView, View } from "@nodegui/react-nodegui";
|
||||
import { Direction, QIcon, QMainWindow, WidgetEventTypes, WindowState } from "@nodegui/nodegui";
|
||||
import nodeguiIcon from "../assets/nodegui.jpg";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import Routes from "./routes";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import { LocalStorage } from "node-localstorage";
|
||||
import authContext from "./context/authContext";
|
||||
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
|
||||
import Player, { audioPlayer } from "./components/Player";
|
||||
|
||||
export enum CredentialKeys {
|
||||
credentials = "credentials",
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
const minSize = { width: 700, height: 520 };
|
||||
const winIcon = new QIcon(nodeguiIcon);
|
||||
global.localStorage = new LocalStorage("./local");
|
||||
|
||||
function RootApp() {
|
||||
const windowRef = useRef<QMainWindow>();
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
||||
const [spotifyAuth, setSpotifyAuth] = useState({ clientId: "", clientSecret: "" });
|
||||
const [access_token, setAccess_token] = useState<string>("");
|
||||
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
|
||||
|
||||
const spotifyApi = new SpotifyWebApi({ ...spotifyAuth });
|
||||
const credentialStr = localStorage.getItem(CredentialKeys.credentials);
|
||||
|
||||
useEffect(() => {
|
||||
windowRef.current?.addEventListener(WidgetEventTypes.Close, () => {
|
||||
if (audioPlayer.isRunning()) {
|
||||
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e));
|
||||
}
|
||||
});
|
||||
setIsLoggedIn(!!credentialStr);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
spotifyApi
|
||||
.clientCredentialsGrant()
|
||||
.then(({ body: { access_token } }) => {
|
||||
setAccess_token(access_token);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Spotify Client Credential not granted for: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (!credentialStr) {
|
||||
return;
|
||||
}
|
||||
const credentials = JSON.parse(credentialStr) as Credentials;
|
||||
setSpotifyAuth(credentials);
|
||||
}, [isLoggedIn]);
|
||||
|
||||
return (
|
||||
<Window ref={windowRef} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
|
||||
<MemoryRouter>
|
||||
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token }}>
|
||||
<playerContext.Provider value={{ spotifyApi, currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
||||
<BoxView direction={Direction.TopToBottom}>
|
||||
<Routes />
|
||||
{isLoggedIn && <Player />}
|
||||
</BoxView>
|
||||
</playerContext.Provider>
|
||||
</authContext.Provider>
|
||||
</MemoryRouter>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
class App extends React.Component {
|
||||
render() {
|
||||
return <RootApp />;
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(App);
|
13
src/components/BackButton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { QIcon } from "@nodegui/nodegui";
|
||||
import React, { ReactElement } from "react";
|
||||
import { useHistory } from "react-router";
|
||||
import { angleLeft } from "../icons";
|
||||
import IconButton from "./shared/IconButton";
|
||||
|
||||
function BackButton(): ReactElement {
|
||||
const history = useHistory();
|
||||
|
||||
return <IconButton style={"align-self: flex-start;"} icon={new QIcon(angleLeft)} on={{ clicked: () => history.goBack() }} />;
|
||||
}
|
||||
|
||||
export default BackButton;
|
159
src/components/Home.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Text, View, ScrollArea } from "@nodegui/react-nodegui";
|
||||
import playerContext from "../context/playerContext";
|
||||
import authContext from "../context/authContext";
|
||||
import { useHistory } from "react-router";
|
||||
import CachedImage from "./shared/CachedImage";
|
||||
import { CursorShape } from "@nodegui/nodegui";
|
||||
|
||||
function Home() {
|
||||
const { spotifyApi, currentPlaylist, currentTrack } = useContext(playerContext);
|
||||
const { isLoggedIn, access_token } = useContext(authContext);
|
||||
const [categories, setCategories] = useState<SpotifyApi.CategoryObject[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (access_token) {
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
const categoriesReceived = await spotifyApi.getCategories({ country: "US" });
|
||||
setCategories(categoriesReceived.body.categories.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Spotify featured playlist loading failed: ", error);
|
||||
}
|
||||
})();
|
||||
}, [access_token]);
|
||||
|
||||
return (
|
||||
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
||||
<View style={`flex-direction: 'column'; justify-content: 'center'; align-items: 'stretch';`}>
|
||||
<CategoryCard key={((Math.random() * Date.now()) / Math.random()) * 100} id="current" name="Currently Playing" />
|
||||
{isLoggedIn &&
|
||||
categories.map(({ id, name }, index) => {
|
||||
return <CategoryCard key={((index * Date.now()) / Math.random()) * 100} id={id} name={name} />;
|
||||
})}
|
||||
</View>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
||||
interface CategoryCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function CategoryCard({ id, name }: CategoryCardProps) {
|
||||
const history = useHistory();
|
||||
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
||||
const { access_token, isLoggedIn } = useContext(authContext);
|
||||
const { spotifyApi, currentPlaylist } = useContext(playerContext);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (access_token) {
|
||||
if (id === "current") {
|
||||
} else {
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
|
||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id, { limit: 5 });
|
||||
setPlaylists(playlistsRes.body.playlists.items);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get playlists of category ${name} for: `, error);
|
||||
}
|
||||
})();
|
||||
}, [access_token]);
|
||||
|
||||
function goToGenre() {
|
||||
history.push(`/genre/playlists/${id}`, { name });
|
||||
}
|
||||
|
||||
const categoryStylesheet = `
|
||||
#container{
|
||||
flex-direction: column;
|
||||
justify-content: 'center';
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#anchor-heading{
|
||||
background: transparent;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
align-self: 'flex-start';
|
||||
}
|
||||
#anchor-heading:hover{
|
||||
border: none;
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
#child-view{
|
||||
justify-content: 'space-evenly';
|
||||
}
|
||||
`;
|
||||
|
||||
if (playlists.length > 0 && id!=="current") {
|
||||
return (
|
||||
<View id="container" styleSheet={categoryStylesheet}>
|
||||
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
||||
<View id="child-view">
|
||||
{isLoggedIn &&
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (id === "current" && currentPlaylist) {
|
||||
return (
|
||||
<View id="container" styleSheet={categoryStylesheet}>
|
||||
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
||||
<View id="child-view">
|
||||
<PlaylistCard key={(Date.now() / Math.random()) * 100} {...currentPlaylist} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
|
||||
interface PlaylistCardProps {
|
||||
thumbnail: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function PlaylistCard({ id, name, thumbnail }: PlaylistCardProps) {
|
||||
const history = useHistory();
|
||||
|
||||
function gotoPlaylist() {
|
||||
history.push(`/playlist/${id}`, { name, thumbnail });
|
||||
}
|
||||
|
||||
const playlistStyleSheet = `
|
||||
#playlist-container{
|
||||
max-width: 250px;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
#playlist-container:hover{
|
||||
border: 1px solid green;
|
||||
}
|
||||
#playlist-container:clicked{
|
||||
border: 5px solid green;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<View id="playlist-container" cursor={CursorShape.PointingHandCursor} styleSheet={playlistStyleSheet} on={{ MouseButtonRelease: gotoPlaylist }}>
|
||||
<CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} />
|
||||
</View>
|
||||
);
|
||||
}
|
74
src/components/Login.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { LineEdit, Text, Button, BoxView } from "@nodegui/react-nodegui";
|
||||
import authContext from "../context/authContext";
|
||||
import { CredentialKeys, Credentials } from "../app";
|
||||
import { Direction } from "@nodegui/nodegui";
|
||||
|
||||
function Login() {
|
||||
const { setIsLoggedIn } = useContext(authContext);
|
||||
const [credentials, setCredentials] = useState({
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
});
|
||||
|
||||
const [touched, setTouched] = useState({
|
||||
clientId: false,
|
||||
clientSecret: false,
|
||||
});
|
||||
|
||||
type fieldNames = "clientId" | "clientSecret";
|
||||
|
||||
function textChanged(text: string, fieldName: fieldNames) {
|
||||
setCredentials({ ...credentials, [fieldName]: text });
|
||||
}
|
||||
|
||||
function textEdited(name: fieldNames) {
|
||||
if (!touched[name]) {
|
||||
setTouched({ ...touched, [name]: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BoxView direction={Direction.TopToBottom}>
|
||||
<Text>{`<center><h1>Add Spotify & Youtube credentials to get started</h1></center>`}</Text>
|
||||
<Text>{`<center><p>Don't worry any of the credentials won't be collected or used for abuses</p></center>`}</Text>
|
||||
<LineEdit
|
||||
on={{
|
||||
textChanged: (t) => textChanged(t, "clientId"),
|
||||
textEdited() {
|
||||
textEdited("clientId");
|
||||
},
|
||||
}}
|
||||
text={credentials.clientId}
|
||||
placeholderText="spotify clientId"
|
||||
/>
|
||||
<LineEdit
|
||||
on={{
|
||||
textChanged: (t) => textChanged(t, "clientSecret"),
|
||||
textEdited() {
|
||||
textEdited("clientSecret");
|
||||
},
|
||||
}}
|
||||
text={credentials.clientSecret}
|
||||
placeholderText="spotify clientSecret"
|
||||
/>
|
||||
<Button
|
||||
on={{
|
||||
clicked: () => {
|
||||
localStorage.setItem(
|
||||
CredentialKeys.credentials,
|
||||
JSON.stringify({
|
||||
clientId: credentials.clientId,
|
||||
clientSecret: credentials.clientSecret,
|
||||
} as Credentials)
|
||||
);
|
||||
setIsLoggedIn(true);
|
||||
},
|
||||
}}
|
||||
text="Add"
|
||||
/>
|
||||
</BoxView>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
197
src/components/Player.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { Direction, Orientation, QAbstractSliderSignals, QIcon, QLabel } from "@nodegui/nodegui";
|
||||
import { BoxView, GridColumn, GridRow, GridView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui";
|
||||
import React, { ReactElement, useContext, useEffect, useRef, useState } from "react";
|
||||
import playerContext, { CurrentPlaylist } from "../context/playerContext";
|
||||
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 } from "../icons";
|
||||
import IconButton from "./shared/IconButton";
|
||||
|
||||
export const audioPlayer = new NodeMpv(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: true,
|
||||
time_update: 1,
|
||||
binary: process.env.MPV_EXECUTABLE ?? "/usr/bin/mpv",
|
||||
debug: true,
|
||||
verbose: true,
|
||||
},
|
||||
["--ytdl-raw-options-set=format=140,http-chunk-size=300000"]
|
||||
);
|
||||
|
||||
function Player(): ReactElement {
|
||||
const { currentTrack, currentPlaylist, setCurrentTrack, setCurrentPlaylist } = useContext(playerContext);
|
||||
const [volume, setVolume] = useState(55);
|
||||
const [totalDuration, setTotalDuration] = useState(0);
|
||||
const [shuffle, setShuffle] = useState<boolean>(false);
|
||||
const [realPlaylist, setRealPlaylist] = useState<CurrentPlaylist["tracks"]>([]);
|
||||
const [isStopped, setIsStopped] = useState<boolean>(false);
|
||||
const playlistTracksIds = currentPlaylist?.tracks.map((t) => t.track.id);
|
||||
const volumeHandler = useEventHandler<QAbstractSliderSignals>(
|
||||
{
|
||||
sliderMoved: (value) => {
|
||||
setVolume(value);
|
||||
},
|
||||
},
|
||||
[]
|
||||
);
|
||||
const playerRunning = audioPlayer.isRunning();
|
||||
const titleRef = useRef<QLabel>();
|
||||
|
||||
// initial Effect
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!playerRunning) {
|
||||
await audioPlayer.start();
|
||||
}
|
||||
await audioPlayer.volume(55);
|
||||
} catch (error) {
|
||||
console.error("Failed to start audio player");
|
||||
console.error(error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
if (playerRunning) {
|
||||
audioPlayer.quit().catch((e: any) => console.log(e));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// track change effect
|
||||
useEffect(() => {
|
||||
// titleRef.current?.setAlignment(AlignmentFlag.AlignLeft);
|
||||
(async () => {
|
||||
try {
|
||||
if (currentTrack && playerRunning) {
|
||||
const youtubeTrack = await getYoutubeTrack(currentTrack);
|
||||
await audioPlayer.load(youtubeTrack.youtube_uri, "replace");
|
||||
await audioPlayer.play();
|
||||
}
|
||||
setIsStopped(false);
|
||||
} catch (error) {
|
||||
if (error.errcode !== 5) {
|
||||
setIsStopped(true);
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
})();
|
||||
}, [currentTrack]);
|
||||
|
||||
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) {
|
||||
const statusListener = (status: { property: string; value: any }) => {
|
||||
if (status?.property === "duration") {
|
||||
setTotalDuration(status.value);
|
||||
}
|
||||
};
|
||||
const stopListener = () => {
|
||||
setIsStopped(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);
|
||||
}
|
||||
};
|
||||
audioPlayer.on("status", statusListener);
|
||||
audioPlayer.on("stopped", stopListener);
|
||||
return () => {
|
||||
audioPlayer.off("status", statusListener);
|
||||
audioPlayer.off("stopped", stopListener);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
try {
|
||||
if ((await audioPlayer.isPaused()) && playerRunning) {
|
||||
await audioPlayer.play();
|
||||
setIsStopped(false);
|
||||
} else {
|
||||
await audioPlayer.pause();
|
||||
setIsStopped(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const prevOrNext = (constant: number) => {
|
||||
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
async function stopPlayback() {
|
||||
try {
|
||||
if (playerRunning) {
|
||||
await audioPlayer.stop();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to stop the audio player: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
const artistsNames = currentTrack?.artists.map((x) => x.name);
|
||||
return (
|
||||
<GridView style="flex: 1; max-height: 100px;">
|
||||
<GridRow>
|
||||
<GridColumn width={2}>
|
||||
<Text ref={titleRef} wordWrap>
|
||||
{artistsNames && currentTrack
|
||||
? `
|
||||
<p><b>${currentTrack.name}</b> - ${artistsNames[0]} feat. ${artistsNames.slice(1).join(" ")}</p>
|
||||
`
|
||||
: `<b>Oh, dear don't waste time</b>`}
|
||||
</Text>
|
||||
</GridColumn>
|
||||
<GridColumn width={4}>
|
||||
<BoxView direction={Direction.TopToBottom} style={`max-width: 600px; min-width: 380px;`}>
|
||||
<PlayerProgressBar audioPlayer={audioPlayer} totalDuration={totalDuration} />
|
||||
|
||||
<BoxView direction={Direction.LeftToRight}>
|
||||
<IconButton style={`background-color: ${shuffle ? "orange" : "rgba(255, 255, 255, 0.055)"}`} on={{ clicked: () => setShuffle(!shuffle) }} icon={new QIcon(shuffleIcon)} />
|
||||
<IconButton on={{ clicked: () => prevOrNext(-1) }} icon={new QIcon(backward)} />
|
||||
<IconButton on={{ clicked: handlePlayPause }} icon={new QIcon(isStopped || !currentTrack ? play : pause)} />
|
||||
<IconButton on={{ clicked: () => prevOrNext(1) }} icon={new QIcon(forward)} />
|
||||
<IconButton icon={new QIcon(stop)} on={{ clicked: stopPlayback }} />
|
||||
</BoxView>
|
||||
</BoxView>
|
||||
</GridColumn>
|
||||
<GridColumn width={2}>
|
||||
<BoxView>
|
||||
<IconButton icon={new QIcon(heartRegular)} />
|
||||
<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;
|
63
src/components/PlayerProgressBar.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Direction, Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
|
||||
import { BoxView, Slider, Text, useEventHandler, View } from "@nodegui/react-nodegui";
|
||||
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
||||
import NodeMpv from "node-mpv";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import playerContext from "../context/playerContext";
|
||||
|
||||
interface PlayerProgressBarProps {
|
||||
audioPlayer: NodeMpv;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
function PlayerProgressBar({ audioPlayer, totalDuration }: PlayerProgressBarProps) {
|
||||
const { currentTrack } = useContext(playerContext);
|
||||
const [trackTime, setTrackTime] = useState(0);
|
||||
const trackSliderEvents = useEventHandler<QAbstractSliderSignals>(
|
||||
{
|
||||
sliderMoved: (value) => {
|
||||
if (audioPlayer.isRunning() && currentTrack) {
|
||||
const newPosition = (totalDuration * value) / 100;
|
||||
setTrackTime(parseInt(newPosition.toString()));
|
||||
}
|
||||
},
|
||||
sliderReleased: () => {
|
||||
(async () => {
|
||||
try {
|
||||
await audioPlayer.goToPosition(trackTime);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
[currentTrack, totalDuration, trackTime]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const progressListener = (seconds: number) => {
|
||||
console.log("seconds", seconds);
|
||||
setTrackTime(seconds);
|
||||
};
|
||||
const statusListener = ({ property }: import("node-mpv").StatusObject): void => {
|
||||
if (property === "filename") {
|
||||
setTrackTime(0);
|
||||
}
|
||||
};
|
||||
audioPlayer.on("status", statusListener);
|
||||
audioPlayer.on("timeposition", progressListener);
|
||||
return () => {
|
||||
audioPlayer.off("status", statusListener);
|
||||
audioPlayer.off("timeposition", progressListener);
|
||||
};
|
||||
});
|
||||
const playbackPercentage = totalDuration > 0 ? (trackTime * 100) / totalDuration : 0;
|
||||
return (
|
||||
<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>{new Date(trackTime * 1000).toISOString().substr(14, 5) + "/" + new Date(totalDuration * 1000).toISOString().substr(14, 5)}</Text>
|
||||
</BoxView>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlayerProgressBar;
|
64
src/components/PlaylistGenreView.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { Direction } from "@nodegui/nodegui";
|
||||
import { BoxView, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
import authContext from "../context/authContext";
|
||||
import playerContext from "../context/playerContext";
|
||||
import BackButton from "./BackButton";
|
||||
import { PlaylistCard } from "./Home";
|
||||
|
||||
function PlaylistGenreView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation<{ name: string }>();
|
||||
const [playlists, setPlaylists] = useState<SpotifyApi.PlaylistObjectSimplified[]>([]);
|
||||
const { access_token, isLoggedIn } = useContext(authContext);
|
||||
const { spotifyApi } = useContext(playerContext);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (access_token) {
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
const playlistsRes = await spotifyApi.getPlaylistsForCategory(id);
|
||||
setPlaylists(playlistsRes.body.playlists.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get playlists of category ${name} for: `, error);
|
||||
}
|
||||
})();
|
||||
}, [access_token]);
|
||||
|
||||
const playlistGenreViewStylesheet = `
|
||||
#heading {
|
||||
padding: 10px;
|
||||
}
|
||||
#scroll-view{
|
||||
flex-grow: 1;
|
||||
border: none;
|
||||
}
|
||||
#child-container {
|
||||
flex-direction: "row";
|
||||
justify-content: "space-evenly";
|
||||
align-items: 'center';
|
||||
flex-wrap: "wrap";
|
||||
width: 330px;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<BoxView direction={Direction.TopToBottom} styleSheet={playlistGenreViewStylesheet}>
|
||||
<BackButton />
|
||||
<Text id="heading">{`<h2>${location.state.name}</h2>`}</Text>
|
||||
<ScrollArea id="scroll-view">
|
||||
<View id="child-container">
|
||||
{isLoggedIn &&
|
||||
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>
|
||||
</ScrollArea>
|
||||
</BoxView>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlaylistGenreView;
|
95
src/components/PlaylistView.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { FC, useContext, useEffect, useState } from "react";
|
||||
import { BoxView, Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||
import BackButton from "./BackButton";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
import { Direction, QAbstractButtonSignals } from "@nodegui/nodegui";
|
||||
import { WidgetEventListeners } from "@nodegui/react-nodegui/dist/components/View/RNView";
|
||||
import authContext from "../context/authContext";
|
||||
import playerContext from "../context/playerContext";
|
||||
|
||||
export interface PlaylistTrackRes {
|
||||
name: string;
|
||||
artists: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlaylistViewProps {
|
||||
// audioPlayer: any;
|
||||
}
|
||||
|
||||
const PlaylistView: FC<PlaylistViewProps> = () => {
|
||||
const { isLoggedIn, access_token } = useContext(authContext);
|
||||
const { spotifyApi, setCurrentTrack, currentPlaylist, currentTrack, setCurrentPlaylist } = useContext(playerContext);
|
||||
const params = useParams<{ id: string }>();
|
||||
const location = useLocation<{ name: string, thumbnail: string }>();
|
||||
const [tracks, setTracks] = useState<SpotifyApi.PlaylistTrackObject[]>([]);
|
||||
|
||||
const trackClickHandler = async (track: SpotifyApi.TrackObjectFull) => {
|
||||
try {
|
||||
setCurrentTrack(track);
|
||||
if (currentPlaylist?.id !== params.id) {
|
||||
setCurrentPlaylist({...params, ...location.state, tracks});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve track's youtube url: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
(async () => {
|
||||
try {
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
const tracks = await spotifyApi.getPlaylistTracks(params.id);
|
||||
setTracks(tracks.body.items);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get tracks from ${params.id}: `, error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BoxView direction={Direction.TopToBottom}>
|
||||
<BackButton />
|
||||
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
||||
<ScrollArea style={`flex-grow: 1; border: none;`}>
|
||||
<View style={`flex-direction:column;`}>
|
||||
{isLoggedIn &&
|
||||
tracks.length > 0 &&
|
||||
tracks.map(({ track }, index) => {
|
||||
return (
|
||||
<TrackButton
|
||||
key={index * ((Date.now() / Math.random()) * 100)}
|
||||
active={currentTrack?.id === track.id && currentPlaylist?.id === params.id}
|
||||
artist={track.artists.map((x) => x.name).join(", ")}
|
||||
name={track.name}
|
||||
on={{ clicked: () => trackClickHandler(track) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollArea>
|
||||
</BoxView>
|
||||
);
|
||||
};
|
||||
|
||||
interface TrackButtonProps {
|
||||
name: string;
|
||||
artist: string;
|
||||
active: boolean;
|
||||
on: Partial<QAbstractButtonSignals | WidgetEventListeners>;
|
||||
}
|
||||
|
||||
const TrackButton: FC<TrackButtonProps> = ({ name, artist, on, active }) => {
|
||||
return <Button id={`${active ? "active" : ""}`} on={on} text={`${name} -- ${artist}`} styleSheet={trackButtonStyle} />;
|
||||
};
|
||||
|
||||
const trackButtonStyle = `
|
||||
#active{
|
||||
background-color: orange;
|
||||
color: #333;
|
||||
}
|
||||
`;
|
||||
|
||||
export default PlaylistView;
|
41
src/components/shared/CachedImage.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Image, Text, View } from "@nodegui/react-nodegui";
|
||||
import { QLabel } from "@nodegui/nodegui";
|
||||
import { ImageProps } from "@nodegui/react-nodegui/dist/components/Image/RNImage";
|
||||
import { getCachedImageBuffer } from "../../helpers/getCachedImageBuffer";
|
||||
|
||||
interface CachedImageProps extends Omit<ImageProps, "buffer"> {
|
||||
src: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
function CachedImage({ src, alt, ...props }: CachedImageProps) {
|
||||
const imgRef = useRef<QLabel>();
|
||||
const [imageBuffer, setImageBuffer] = useState<Buffer>();
|
||||
const [imageProcessError, setImageProcessError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setImageBuffer(await getCachedImageBuffer(src, props.maxSize ?? props.size));
|
||||
} catch (error) {
|
||||
setImageProcessError(false);
|
||||
console.log("Cached Image Error:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
imgRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
return !imageProcessError && imageBuffer ? (
|
||||
<Image ref={imgRef} buffer={imageBuffer} {...props} />
|
||||
) : alt ? (
|
||||
<View style={`padding: ${((props.maxSize ?? props.size)?.height || 10) / 2.5}px ${((props.maxSize ?? props.size)?.width || 10) / 2.5}px;`}>
|
||||
<Text>{alt}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
export default CachedImage;
|
38
src/components/shared/IconButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import {QGraphicsDropShadowEffect, QPushButton} from "@nodegui/nodegui"
|
||||
import { Button } from "@nodegui/react-nodegui";
|
||||
import { ButtonProps } from "@nodegui/react-nodegui/dist/components/Button/RNButton";
|
||||
|
||||
interface IconButtonProps extends Omit<ButtonProps, "text"> {}
|
||||
|
||||
function IconButton({ style, ...props }: IconButtonProps) {
|
||||
const iconBtnRef = useRef<QPushButton>()
|
||||
const shadowGfx = new QGraphicsDropShadowEffect();
|
||||
useEffect(() => {
|
||||
shadowGfx.setBlurRadius(5);
|
||||
shadowGfx.setXOffset(0);
|
||||
shadowGfx.setYOffset(0);
|
||||
iconBtnRef.current?.setGraphicsEffect(shadowGfx);
|
||||
}, [])
|
||||
const iconButtonStyleSheet = `
|
||||
#icon-btn{
|
||||
background-color: rgba(255, 255, 255, 0.055);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-radius: ${((props.maxSize ?? props.size)?.width ?? 30) / 2}px;
|
||||
${style ?? ``}
|
||||
}
|
||||
#icon-btn:hover{
|
||||
border-color: green;
|
||||
}
|
||||
#icon-btn:pressed{
|
||||
border-style: groove;
|
||||
background-color: #1cca1c;
|
||||
}
|
||||
`;
|
||||
|
||||
return <Button ref={iconBtnRef} id="icon-btn" size={{ height: 30, width: 30, fixed: true }} styleSheet={iconButtonStyleSheet} {...props} />;
|
||||
}
|
||||
|
||||
export default IconButton;
|
16
src/context/authContext.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
|
||||
export interface AuthContext {
|
||||
isLoggedIn: boolean;
|
||||
setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
|
||||
access_token: string
|
||||
}
|
||||
|
||||
const authContext = React.createContext<AuthContext>({
|
||||
isLoggedIn: false,
|
||||
setIsLoggedIn() {},
|
||||
access_token: ""
|
||||
});
|
||||
|
||||
export default authContext;
|
18
src/context/playerContext.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
|
||||
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
||||
|
||||
export type CurrentPlaylist = { tracks: SpotifyApi.PlaylistTrackObject[]; id: string; name: string; thumbnail: string };
|
||||
|
||||
export interface PlayerContext {
|
||||
spotifyApi: SpotifyWebApi;
|
||||
currentPlaylist?: CurrentPlaylist;
|
||||
currentTrack?: CurrentTrack;
|
||||
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
|
||||
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
|
||||
}
|
||||
|
||||
const playerContext = React.createContext<PlayerContext>({ spotifyApi: new SpotifyWebApi(), setCurrentPlaylist() {}, setCurrentTrack() {} });
|
||||
|
||||
export default playerContext;
|
65
src/helpers/getCachedImageBuffer.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import path from "path";
|
||||
import isUrl from "is-url";
|
||||
import * as fs from "fs"
|
||||
import axios from "axios";
|
||||
import { Stream } from "stream";
|
||||
import { streamToBuffer } from "./streamToBuffer";
|
||||
import sharp from "sharp";
|
||||
import du from "du";
|
||||
|
||||
interface ImageDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const fsm = fs.promises;
|
||||
|
||||
export async function getCachedImageBuffer(name: string, dims?: ImageDimensions): Promise<Buffer> {
|
||||
try {
|
||||
const MB_5 = 5000000; //5 Megabytes
|
||||
const cacheFolder = path.join(process.cwd(), "cache", "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(cacheFolder, cacheName);
|
||||
// checking if the cached image already exists or not
|
||||
if (fs.existsSync(cachePath)) {
|
||||
// automatically removing cache after a certain 50 MB oversize
|
||||
if ((await du(cacheFolder)) > MB_5) {
|
||||
fs.rmSync(cacheFolder, { recursive: true, force: true });
|
||||
}
|
||||
const cachedImg = await fsm.readFile(cachePath);
|
||||
const cachedImgMeta = await sharp(cachedImg).metadata();
|
||||
|
||||
// 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);
|
||||
return await imageResizeAndWrite(cachedImg, { cacheFolder, cacheName, dims });
|
||||
}
|
||||
return cachedImg;
|
||||
} else {
|
||||
// finding no cache image fetching it through axios
|
||||
const { data: imgData } = await axios.get<Stream>(name, { responseType: "stream" });
|
||||
// converting axios stream to buffer
|
||||
const resImgBuf = await streamToBuffer(imgData);
|
||||
// creating cache_dir
|
||||
await fsm.mkdir(cacheFolder, { recursive: true });
|
||||
if (dims) {
|
||||
return await imageResizeAndWrite(resImgBuf, { cacheFolder, cacheName, dims });
|
||||
}
|
||||
await fsm.writeFile(path.join(cacheFolder, cacheName), resImgBuf);
|
||||
return resImgBuf;
|
||||
}
|
||||
} 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> {
|
||||
// caching the images by resizing if the max/fixed (Width/Height)
|
||||
// is available in the args
|
||||
const resizedImg = await sharp(img).resize(dims.width, dims.height).toBuffer();
|
||||
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImg);
|
||||
return resizedImg;
|
||||
}
|
49
src/helpers/getYoutubeTrack.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import scrapYt from "scrape-yt";
|
||||
import { CurrentTrack } from "../context/playerContext";
|
||||
|
||||
/**
|
||||
* returns the percentage of matched elements of a certain array(src)
|
||||
* which is determined by checking if a the element is available in the
|
||||
* array or not
|
||||
* @author KR Tirtho
|
||||
* @param {(string | Array<string | number>)} src
|
||||
* @param {(Array<string | number>)} matches
|
||||
* @return {*} {number}
|
||||
*/
|
||||
export function includePercentage(src: string | Array<string | number>, matches: Array<string | number>): number {
|
||||
let count = 0;
|
||||
matches.forEach((match) => {
|
||||
if (src.includes(match.toString())) count++;
|
||||
});
|
||||
return (count / matches.length) * 100;
|
||||
}
|
||||
|
||||
export interface YoutubeTrack extends CurrentTrack {
|
||||
youtube_uri: string;
|
||||
}
|
||||
|
||||
export async function getYoutubeTrack(track: SpotifyApi.TrackObjectFull): Promise<YoutubeTrack> {
|
||||
try {
|
||||
const artistsName = track.artists.map((ar) => ar.name);
|
||||
const queryString = `${artistsName[0]} - ${track.name}${artistsName.length > 1 ? ` feat. ${artistsName.slice(1).join(" ")}` : ``}`;
|
||||
console.log('Youtube Query String:', queryString);
|
||||
const result = await scrapYt.search(queryString, { limit: 7, type: "video" });
|
||||
const tracksWithRelevance = result
|
||||
.map((video) => {
|
||||
// percentage of matched track {name, artists} matched with
|
||||
// title of the youtube search results
|
||||
const matchPercentage = includePercentage(video.title, [track.name, ...artistsName]);
|
||||
// keeps only those tracks which are from the same artist channel
|
||||
const sameChannel = video.channel.name.includes(artistsName[0]) || 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 finalTrack = { ...track, youtube_uri: (sameChannelTracks.length > 0 ? sameChannelTracks : tracksWithRelevance)[0].url };
|
||||
return finalTrack;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve track's youtube url: ", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
9
src/helpers/shuffleArray.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function shuffleArray<T>(array:T[]):T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = array[i];
|
||||
array[i] = array[j];
|
||||
array[j] = temp;
|
||||
}
|
||||
return array;
|
||||
}
|
16
src/helpers/streamToBuffer.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Stream } from "stream";
|
||||
|
||||
export function streamToBuffer(stream: Stream): Promise<Buffer> {
|
||||
let buffArr: any[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("data", (data) => {
|
||||
buffArr.push(data);
|
||||
});
|
||||
stream.on("end", async () => {
|
||||
resolve(Buffer.concat(buffArr));
|
||||
});
|
||||
stream.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
19
src/icons.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import _play from "../assets/play-solid.svg";
|
||||
import _pause from "../assets/pause-solid.svg"
|
||||
import _angleLeft from "../assets/angle-left-solid.svg"
|
||||
import _backward from "../assets/backward-solid.svg"
|
||||
import _forward from "../assets/forward-solid.svg"
|
||||
import _heartRegular from "../assets/heart-regular.svg"
|
||||
import _heart from "../assets/heart-solid.svg"
|
||||
import _random from "../assets/random-solid.svg"
|
||||
import _stop from "../assets/stop-solid.svg"
|
||||
|
||||
export const play = _play;
|
||||
export const pause = _pause;
|
||||
export const angleLeft = _angleLeft;
|
||||
export const backward = _backward;
|
||||
export const forward = _forward;
|
||||
export const heartRegular = _heartRegular;
|
||||
export const heart = _heart;
|
||||
export const random = _random;
|
||||
export const stop = _stop;
|
12
src/index.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Renderer } from "@nodegui/react-nodegui";
|
||||
import React from "react";
|
||||
import App from "./app";
|
||||
|
||||
process.title = "My NodeGui App";
|
||||
Renderer.render(<App />);
|
||||
// This is for hot reloading (this will be stripped off in production by webpack)
|
||||
if (module.hot) {
|
||||
module.hot.accept(["./app"], function() {
|
||||
Renderer.forceUpdate();
|
||||
});
|
||||
}
|
24
src/routes.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { useContext } from "react";
|
||||
import { Route } from "react-router";
|
||||
import authContext from "./context/authContext";
|
||||
import Home from "./components/Home";
|
||||
import Login from "./components/Login";
|
||||
import PlaylistView from "./components/PlaylistView";
|
||||
import PlaylistGenreView from "./components/PlaylistGenreView";
|
||||
|
||||
function Routes() {
|
||||
const {
|
||||
isLoggedIn
|
||||
} = useContext(authContext);
|
||||
return (
|
||||
<>
|
||||
<Route exact path="/">
|
||||
{isLoggedIn ? <Home /> : <Login />}
|
||||
</Route>
|
||||
<Route path="/playlist/:id"><PlaylistView/></Route>
|
||||
<Route path="/genre/playlists/:id"><PlaylistGenreView/></Route>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Routes;
|
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["**/*"],
|
||||
"exclude": ["**/node_modules/*"]
|
||||
}
|
69
webpack.config.js
Normal file
@ -0,0 +1,69 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
|
||||
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const config = {
|
||||
mode: "production",
|
||||
entry: ["./src/index.tsx"],
|
||||
target: "node",
|
||||
externals: {
|
||||
'sharp': 'commonjs sharp',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: "index.js",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(j|t)sx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
options: { cacheDirectory: true, cacheCompression: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg|bmp|otf)$/i,
|
||||
use: [
|
||||
{
|
||||
loader: "file-loader",
|
||||
options: { publicPath: "dist" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.node/i,
|
||||
use: [
|
||||
{
|
||||
loader: "native-addon-loader",
|
||||
options: { name: "[name]-[hash].[ext]" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({"global.GENTLY": false}),
|
||||
new CleanWebpackPlugin(),
|
||||
],
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".js", ".jsx", ".json"],
|
||||
},
|
||||
};
|
||||
|
||||
if (argv.mode === "development") {
|
||||
config.mode = "development";
|
||||
config.plugins.push(new webpack.HotModuleReplacementPlugin());
|
||||
config.plugins.push(
|
||||
new ForkTsCheckerWebpackPlugin()
|
||||
);
|
||||
config.devtool = "source-map";
|
||||
config.watch = true;
|
||||
config.entry.unshift("webpack/hot/poll?100");
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|