Windows initial
8
.babelrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
["@babel/preset-env", { "targets": { "node": "12" } }],
|
||||||
|
"@babel/preset-typescript",
|
||||||
|
"@babel/preset-react"
|
||||||
|
],
|
||||||
|
"plugins": []
|
||||||
|
}
|
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
# user specific
|
||||||
|
cache/
|
||||||
|
local/
|
||||||
|
# debian build specific
|
||||||
|
deb-struct/usr
|
||||||
|
# deply build binaries
|
||||||
|
deploy/linux/build
|
||||||
|
deploy/win32/build
|
||||||
|
deploy/darwin/build
|
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
12
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "npm: start",
|
||||||
|
"detail": "qode ./dist/index.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
159
README.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Spotube
|
||||||
|
|
||||||
|
Spotube is a [qt](https://qt.io) based lightweight spotify client which uses [nodegui/react-nodegui](https://github.com/nodegui/react-nodegui) as frontend & nodejs as backend. It utilizes the power of Spotify & Youtube's public API & creates a hazardless, performant & resource friendly User Experience
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Following are the features that currently spotube offers:
|
||||||
|
|
||||||
|
- Open Source
|
||||||
|
- No telementry, diagnostics or user data collection
|
||||||
|
- Lightweight & resource friendly
|
||||||
|
- Near native performance & seemless with default desktop themes (Win10, Win7, OSX, QT-default)
|
||||||
|
- Playback control is on user's machine instead of server based
|
||||||
|
- Small size & less data hungry
|
||||||
|
- No spotify or youtube ads since it uses all public & free APIs (But it's recommended to support the creators by watching/liking/subscribing to the artists youtube channel or add as favourite track in spotify. Mostly buying spotify premium is the best way to support their valuable creations)
|
||||||
|
- Lyric Seek (WIP)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Don't worry **spotify premium isn't required**😱. But some extra packages required.
|
||||||
|
|
||||||
|
- [MPV](https://mpv.io/installation/) player for playing the actual audio
|
||||||
|
- [youtube-dl](https://github.com/ytdl-org/youtube-dl) for streaming the audio from youtube. It already comes pre bundled with mpv
|
||||||
|
|
||||||
|
**Important for [Ubuntu/Debian]():** If you're using any **ubuntu/debian** based linux distro then **youtube-dl** installed from the typical **apt-get** repositories will most likely not work as that version is older than current release. So remove it & install from the repository manually
|
||||||
|
|
||||||
|
Remove the **youtube-dl** installed with **mpv** player or from **apt package manger**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo apt-get remove youtube-dl
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, Install youtube-dl from
|
||||||
|
|
||||||
|
- official github repo: https://github.com/ytdl-org/youtube-dl#installation (recommended)
|
||||||
|
- snap installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ snap install youtube-dl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
I'm always releasing newer versions of binary of the software each 2-3 month with minor changes & each 6-8 month with major changes. Grab the binaries
|
||||||
|
|
||||||
|
Windows: [.exe]()
|
||||||
|
|
||||||
|
OSX: **I hate apple** (Just kidding, actually don't have a mac)😂😔
|
||||||
|
|
||||||
|
Linux: [.appimage]()
|
||||||
|
|
||||||
|
**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat stores or software centers or repositories**
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
There are some configurations that needs to be done to start using this software
|
||||||
|
|
||||||
|
You need a spotify account & a web app for
|
||||||
|
|
||||||
|
- clientId
|
||||||
|
- clientSecret
|
||||||
|
|
||||||
|
**Grab credentials:**
|
||||||
|
|
||||||
|
- Go to https://developer.spotify.com/dashboard/login & login with your spotify account (Skip if you're logged in)
|
||||||
|

|
||||||
|
|
||||||
|
- Create an web app for Spotify Public API
|
||||||
|

|
||||||
|
|
||||||
|
- Give the app a name & description. Then Edit settings & add **http://localhost:4304/auth/spotify/callback** as **Redirect URI** for the app. Its important for authenticating
|
||||||
|

|
||||||
|
|
||||||
|
- Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields
|
||||||
|

|
||||||
|
|
||||||
|
**[Important]!**: No personal data or any kind of sensitive information won't be collected from spotify. Don't believe? See the code for yourself
|
||||||
|
|
||||||
|
### Building from source
|
||||||
|
|
||||||
|
**nodegui/react-nodegui** requires following packages to run
|
||||||
|
|
||||||
|
- [CMake](https://cmake.org/install/) 3.1 & up
|
||||||
|
- GCC v7
|
||||||
|
- Nodejs 12.x & up
|
||||||
|
|
||||||
|
**Windows Specific:**
|
||||||
|
|
||||||
|
- Visual Studio 2017 & up
|
||||||
|
|
||||||
|
**MacOS & Linux specific:**
|
||||||
|
|
||||||
|
- Make
|
||||||
|
|
||||||
|
**Ubuntu/Debian based linux specific:**
|
||||||
|
Having `pkg-config build-essential mesa-common-dev libglu1-mesa-dev` is advisable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo apt-get install pkg-config build-essential mesa-common-dev libglu1-mesa-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After having this dependencies set up run following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git clone https://github.com/KRTirtho/spotube.git
|
||||||
|
$ cd spotube
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Now start building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm run build
|
||||||
|
$ npm run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
Go to built package directory replace `os-name` with `linux`|`win32`|`darwin`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cd deploy/<os-name>/build/spotube
|
||||||
|
```
|
||||||
|
|
||||||
|
If everything went smoothly then double clicking on the
|
||||||
|
|
||||||
|
- `AppRun` or Spotube-x86_64.AppImage for **linux**
|
||||||
|
- Spotube-x86_64.exe for **Windows**
|
||||||
|
- Spotube-x86_64.dmg for **MacOS**
|
||||||
|
|
||||||
|
should work just fine without any problem
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Follow the **Build from Source** guideline till `npm install`
|
||||||
|
Now, to start the dev server run the command in one terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To start the application in development environment run following command in another terminal keeping the dev server running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
There will be some glitches, lags & stuck motions because of the library Spotube is currently using under the hood. It has some issues with layouts thus sometime some contents aren't shown or overflows out of the window. But resizing the window would fix this issue. Soon there will be some updates fixing this sort of layout related problems
|
||||||
|
|
||||||
|
## TODO:
|
||||||
|
|
||||||
|
- Compile, Debug & Build for **Windows & MacOS**
|
||||||
|
- Add seek Lyric for currently playing track
|
||||||
|
- Support for playing/streaming podcasts/shows
|
||||||
|
|
||||||
|
#### Social handlers
|
||||||
|
|
||||||
|
Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about this application
|
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/loading-spinner.gif
Normal file
After Width: | Height: | Size: 19 KiB |
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 |
BIN
assets/rickroll.jpg
Normal file
After Width: | Height: | Size: 93 KiB |
1
assets/search-solid.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"></path></svg>
|
After Width: | Height: | Size: 577 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 |
10
control
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Package: Spotube
|
||||||
|
Version: 0.0.1
|
||||||
|
Section: sound
|
||||||
|
Priority: optional
|
||||||
|
Architecture: all
|
||||||
|
Essential: no
|
||||||
|
Installed-Size: 44000
|
||||||
|
Maintainer: KR Tirtho
|
||||||
|
Description: A music streaming app combining the power of Spotify & Youtube
|
||||||
|
Homepage: https://github.com/KRTirtho/spotube
|
4
deb-config.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"appName": "name",
|
||||||
|
"version": "0.1"
|
||||||
|
}
|
10
deb-struct/DEBIAN/control
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Package: Spotube
|
||||||
|
Version: 0.0.1
|
||||||
|
Section: sound
|
||||||
|
Priority: optional
|
||||||
|
Architecture: all
|
||||||
|
Essential: no
|
||||||
|
Installed-Size: 44000
|
||||||
|
Maintainer: KR Tirtho
|
||||||
|
Description: A music streaming app combining the power of Spotify & Youtube
|
||||||
|
Homepage: https://github.com/KRTirtho/spotube
|
1
deploy/config.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"appName":"spotube"}
|
3
deploy/linux/spotube/Spotube Icon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 1024 1024" width="1024" height="1024"><defs><path d="M864.5 488.49C864.5 680.95 708.5 837.21 516.36 837.21C324.22 837.21 168.22 680.95 168.22 488.49C168.22 296.03 324.22 139.78 516.36 139.78C708.5 139.78 864.5 296.03 864.5 488.49Z" id="amHaSVpu"></path><path d="" id="a2BJMy7Jb"></path><path d="M311.12 310.07C313.34 313.32 304.24 320.57 310.85 329.83C318.01 339.85 343.74 336.24 360.41 316.59C372.91 301.85 475.1 266.53 511.85 269.93C588.19 276.99 575.39 271.83 590.61 281.1C596.7 284.81 639.52 293.78 662.13 310.85C684.73 327.91 711.65 343.39 715.92 338.17C725.54 326.39 743.34 305.37 658.01 254.46C616.69 229.81 620.32 239.57 587.05 230.59C533.9 216.24 472.16 220.95 401.83 244.73C336.72 281.52 306.48 303.3 311.12 310.07Z" id="a1tDWZImgU"></path><path d="M411.75 413.54C413 415.03 367.9 428.18 360.73 442.12C349.52 463.92 375.04 487.76 385.62 472.66C392.39 463 485.79 428.29 508.37 428.23C555.27 428.11 596.35 438.36 606.16 443.1C614 446.9 672.19 473.83 676.79 467C681.97 459.31 702.29 441.03 647.4 415.1C607.69 396.35 600.7 394.71 570 390.75C547.31 387.83 524.26 387.05 500.84 388.41C438.23 401.33 408.53 409.7 411.75 413.54Z" id="d5X4Y3iXxi"></path><path d="M401.51 326.48C402.99 328.06 352.9 339.29 345.48 353.13C333.86 374.78 363.61 400.24 374.82 385.38C381.99 375.88 485.36 345.01 510.73 346.03C563.41 348.13 610.02 360.52 621.25 365.81C630.24 370.04 696.82 400.2 701.68 393.48C707.16 385.9 729.17 368.27 666.33 339.28C620.88 318.31 612.95 316.31 578.28 310.83C552.66 306.77 526.72 304.88 500.48 305.15C430.72 315.32 397.73 322.42 401.51 326.48Z" id="caQrsW984"></path><path d="M538.91 551.22C529.19 537.59 505.67 536.18 497.12 555.44C488.61 574.6 420.53 727.92 412.02 747.09C404.35 764.36 418.47 783.09 436.92 780.92C457.39 778.53 621.12 759.48 641.58 757.1C659.83 755 669.2 734.02 658.48 719.06C634.56 685.49 550.87 568 538.91 551.22Z" id="gcFKZeS95"></path><path d="M520.79 470.72C521.19 474.45 518.48 477.8 514.75 478.2C512.05 478.49 510.72 478.63 508.02 478.92C504.28 479.32 500.93 476.61 500.53 472.88C500.01 467.97 499.16 460.01 498.64 455.1C498.24 451.36 500.94 448.01 504.68 447.62C507.38 447.33 508.71 447.19 511.41 446.9C515.14 446.5 518.49 449.2 518.89 452.94C519.42 457.85 520.27 465.81 520.79 470.72Z" id="aFNm6T4Ou"></path><path d="M525.36 513.59C525.76 517.32 523.06 520.67 519.32 521.07C516.62 521.36 515.29 521.5 512.59 521.79C508.86 522.19 505.51 519.48 505.11 515.75C504.58 510.84 503.73 502.88 503.21 497.97C502.81 494.23 505.52 490.88 509.25 490.48C511.95 490.2 513.28 490.05 515.98 489.77C519.72 489.37 523.07 492.07 523.47 495.81C523.99 500.71 524.84 508.68 525.36 513.59Z" id="f44lxYrdu7"></path></defs><g><g><g><use xlink:href="#amHaSVpu" opacity="1" fill="#1db954" fill-opacity="1"></use></g><g><g><use xlink:href="#a2BJMy7Jb" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="1"></use></g></g><g><g><use xlink:href="#a1tDWZImgU" opacity="1" fill="#ffffff" fill-opacity="1"></use></g><g><use xlink:href="#d5X4Y3iXxi" opacity="1" fill="#ffffff" fill-opacity="1"></use></g><g><use xlink:href="#caQrsW984" opacity="1" fill="#ffffff" fill-opacity="1"></use></g></g><g><use xlink:href="#gcFKZeS95" opacity="1" fill="#ffffff" fill-opacity="1"></use><g><use xlink:href="#gcFKZeS95" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><g><use xlink:href="#aFNm6T4Ou" opacity="1" fill="#ffffff" fill-opacity="1"></use></g><g><use xlink:href="#f44lxYrdu7" opacity="1" fill="#ffffff" fill-opacity="1"></use></g></g></g></g></svg>
|
After Width: | Height: | Size: 3.7 KiB |
0
deploy/linux/spotube/default.png
Normal file
6
deploy/linux/spotube/index.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const path = require("path");
|
||||||
|
// Fix so that linux resources are found correctly
|
||||||
|
// since webpack will bundle them such that the expected path is /dist from cwd
|
||||||
|
process.chdir(path.resolve(path.dirname(process.execPath)));
|
||||||
|
// Now start loading the actual bundle
|
||||||
|
require("./dist");
|
3
deploy/linux/spotube/qode.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"distPath": "./index.js"
|
||||||
|
}
|
8
deploy/linux/spotube/spotube.desktop
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Spotube
|
||||||
|
Exec=AppRun
|
||||||
|
Icon=spotube
|
||||||
|
Comment=A music streaming app combining the power of Spotify & Youtube
|
||||||
|
Terminal=false
|
||||||
|
Categories=Music;
|
BIN
deploy/linux/spotube/spotube.png
Normal file
After Width: | Height: | Size: 63 KiB |
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>;
|
||||||
|
}
|
||||||
|
}
|
6538
package-lock.json
generated
Normal file
66
package.json
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "spotube",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "index.js",
|
||||||
|
"author": "KR Tirtho",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --mode=production",
|
||||||
|
"dev": "webpack --mode=development",
|
||||||
|
"start": "qode ./dist/index.js",
|
||||||
|
"start:trace": "qode ./dist/index.js --trace",
|
||||||
|
"debug": "qode --inspect ./dist/index.js",
|
||||||
|
"pack": "nodegui-packer -p ./dist",
|
||||||
|
"pack-deb": "node scripts/build-deb.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nodegui/nodegui": "^0.27.0",
|
||||||
|
"@nodegui/react-nodegui": "^0.10.0",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"base64url": "^3.0.1",
|
||||||
|
"chalk": "^4.1.0",
|
||||||
|
"color": "^3.1.3",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"du": "^1.0.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"jimp": "^0.16.1",
|
||||||
|
"node-localstorage": "^2.1.6",
|
||||||
|
"node-mpv": "^2.0.0-beta.1",
|
||||||
|
"open": "^7.4.1",
|
||||||
|
"react": "^16.14.0",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"react-query": "^3.12.0",
|
||||||
|
"react-router": "^5.2.0",
|
||||||
|
"scrape-yt": "^1.4.7",
|
||||||
|
"spotify-web-api-node": "^5.0.2",
|
||||||
|
"uuid": "^8.3.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/color": "^3.0.1",
|
||||||
|
"@types/du": "^1.0.0",
|
||||||
|
"@types/express": "^4.17.11",
|
||||||
|
"@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/spotify-web-api-node": "^5.0.0",
|
||||||
|
"@types/uuid": "^8.3.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"
|
||||||
|
}
|
||||||
|
}
|
86
scripts/build-deb.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const process = require("child_process");
|
||||||
|
// Get the foldername
|
||||||
|
const deployDir = path.join("deploy"); // ./deploy relative to where command is executed
|
||||||
|
const debStructDir = path.join(__dirname, "..", "deb-struct"); // ./deb-struct relative to where command is executed
|
||||||
|
const configFile = path.join(deployDir, "config.json");
|
||||||
|
const appName = JSON.parse(fs.readFileSync(configFile, { encoding: 'utf8' })).appName;
|
||||||
|
const appNameSanitized = appName.replace(' ', '').toLowerCase();
|
||||||
|
const buildFolder = path.join(deployDir, "linux", "build", appName);
|
||||||
|
function cleanDirectories() {
|
||||||
|
console.log("Cleaning DEBIAN:");
|
||||||
|
console.log(process.execSync('rm -rf ' + debStructDir + '/DEBIAN/*'));
|
||||||
|
console.log("Cleaning bin:");
|
||||||
|
console.log(process.execSync('rm -rf ' + debStructDir + '/usr/bin/*'));
|
||||||
|
console.log("Cleaning lib:");
|
||||||
|
console.log(process.execSync('rm -rf ' + debStructDir + '/usr/lib/*'));
|
||||||
|
console.log("Cleaning applications:");
|
||||||
|
console.log(process.execSync('rm -rf ' + debStructDir + '/usr/share/applications/*'));
|
||||||
|
}
|
||||||
|
function copyControlFile() {
|
||||||
|
console.log("Copying control:");
|
||||||
|
console.log(process.execSync('cp ./control ' + debStructDir + '/DEBIAN/control'));
|
||||||
|
}
|
||||||
|
function copyBuildFolderToLib() {
|
||||||
|
const folderPath = path.join(debStructDir, "usr", "lib");
|
||||||
|
console.log("Copying Build Folder:");
|
||||||
|
console.log(process.execSync('cp -R "' + buildFolder + '" "' + folderPath + '"'));
|
||||||
|
console.log(process.execSync('cp -R ./assets "' + path.join(folderPath, appName) + '"'));
|
||||||
|
if (appName !== appNameSanitized) {
|
||||||
|
console.log(process.execSync('mv "' + path.join(folderPath, appName) + '" "' + path.join(folderPath, appNameSanitized) + '"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createSymlinkToBin() {
|
||||||
|
const folderPath = '"' + path.join(debStructDir, "usr", "bin", appName) + '"';
|
||||||
|
console.log("Generating Symlink:");
|
||||||
|
console.log(process.execSync('ln -s /usr/lib/' + appNameSanitized + '/qode ' + folderPath));
|
||||||
|
}
|
||||||
|
function copyDesktopFileToApplications() {
|
||||||
|
console.log("Copying Desktop File:");
|
||||||
|
const desktopSrc = path.join(buildFolder, getFilesFromPath(buildFolder, '.desktop')[0]);
|
||||||
|
const desktopDest = path.join(debStructDir, 'usr', 'share', 'applications', appName.replace(' ', '').toLowerCase() + '.desktop');
|
||||||
|
console.log(process.execSync('cp "' + desktopSrc + '" "' + desktopDest + '"'));
|
||||||
|
// Copy icon and change relative Icon path to absolute path
|
||||||
|
const desktopContents = fs.readFileSync(desktopDest).toString();
|
||||||
|
let m;
|
||||||
|
const regex = /^Icon=(.*)$/m;
|
||||||
|
const matches = regex.exec(desktopContents);
|
||||||
|
if (matches && matches.length > 1) {
|
||||||
|
const iconFileName = matches[1];
|
||||||
|
if (!path.isAbsolute(iconFileName)) {
|
||||||
|
// check if file exists, look for extensions {.png,.svg,.svgz,.xpm} as @nodegui/packer does
|
||||||
|
let iconFileExt = '';
|
||||||
|
for (const fileExt of ['png', 'svg', 'svgz', 'xpm']) {
|
||||||
|
if (fs.existsSync(path.join(path.dirname(desktopSrc), iconFileName + '.' + fileExt))) {
|
||||||
|
iconFileExt = fileExt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!iconFileExt) {
|
||||||
|
throw new Error(iconFileName + '{.png,.svg,.svgz,.xpm} defined in desktop file but not found in ' + path.dirname(desktopSrc));
|
||||||
|
}
|
||||||
|
const absIconPath = '/' + path.join('usr', 'lib', appNameSanitized, iconFileName + '.' + iconFileExt);
|
||||||
|
fs.writeFileSync(desktopDest, desktopContents.replace(regex, 'Icon=' + absIconPath));
|
||||||
|
console.log('Adjusted relative icon path: ' + iconFileName + ' => ' + absIconPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createDeb() {
|
||||||
|
// Create DEBIAN File
|
||||||
|
console.log("Generating Debian:");
|
||||||
|
console.log(process.execSync('dpkg-deb --build "' + debStructDir + '" "' + appNameSanitized + '.deb"'));
|
||||||
|
}
|
||||||
|
function getFilesFromPath(path, extension) {
|
||||||
|
let files = fs.readdirSync(path);
|
||||||
|
return files.filter(file => file.match(new RegExp(`.*\.(${extension})`, 'ig')));
|
||||||
|
}
|
||||||
|
cleanDirectories();
|
||||||
|
copyControlFile();
|
||||||
|
copyBuildFolderToLib();
|
||||||
|
createSymlinkToBin();
|
||||||
|
copyDesktopFileToApplications();
|
||||||
|
createDeb();
|
||||||
|
//# sourceMappingURL=build-deb.js.map
|
164
src/app.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Window, hot, View, useEventHandler, BoxView } from "@nodegui/react-nodegui";
|
||||||
|
import { Direction, QIcon, QKeyEvent, QMainWindow, QMainWindowSignals, WidgetEventTypes, WindowState } from "@nodegui/nodegui";
|
||||||
|
import nodeguiIcon from "../assets/nodegui.jpg";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import Routes from "./routes";
|
||||||
|
import { LocalStorage } from "node-localstorage";
|
||||||
|
import authContext from "./context/authContext";
|
||||||
|
import playerContext, { CurrentPlaylist, CurrentTrack } from "./context/playerContext";
|
||||||
|
import Player, { audioPlayer } from "./components/Player";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
import express from "express";
|
||||||
|
import open from "open";
|
||||||
|
import spotifyApi from "./initializations/spotifyApi";
|
||||||
|
import showError from "./helpers/showError";
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path";
|
||||||
|
import { confDir } from "./conf";
|
||||||
|
|
||||||
|
export enum CredentialKeys {
|
||||||
|
credentials = "credentials",
|
||||||
|
refresh_token = "refresh_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Credentials {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSize = { width: 700, height: 750 };
|
||||||
|
const winIcon = new QIcon(nodeguiIcon);
|
||||||
|
const localStorageDir = path.join(confDir, "local");
|
||||||
|
fs.mkdirSync(localStorageDir, {recursive: true});
|
||||||
|
global.localStorage = new LocalStorage(localStorageDir);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
onError(error) {
|
||||||
|
showError(error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootApp() {
|
||||||
|
const windowRef = useRef<QMainWindow>();
|
||||||
|
const [currentTrack, setCurrentTrack] = useState<CurrentTrack>();
|
||||||
|
|
||||||
|
const windowEvents = useEventHandler<QMainWindowSignals>(
|
||||||
|
{
|
||||||
|
async KeyRelease(nativeEv) {
|
||||||
|
try {
|
||||||
|
if (nativeEv) {
|
||||||
|
const event = new QKeyEvent(nativeEv);
|
||||||
|
const eventKey = event.key();
|
||||||
|
if (audioPlayer.isRunning() && currentTrack)
|
||||||
|
switch (eventKey) {
|
||||||
|
case 32: //space
|
||||||
|
(await audioPlayer.isPaused()) ? await audioPlayer.play() : await audioPlayer.pause();
|
||||||
|
break;
|
||||||
|
case 16777236: //arrow-right
|
||||||
|
(await audioPlayer.isSeekable()) && (await audioPlayer.seek(+5));
|
||||||
|
break;
|
||||||
|
case 16777234: //arrow-left
|
||||||
|
(await audioPlayer.isSeekable()) && (await audioPlayer.seek(-5));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in window events: ", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[currentTrack]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
||||||
|
const [credentials, setCredentials] = useState<Credentials>({ clientId: "", clientSecret: "" });
|
||||||
|
const [access_token, setAccess_token] = useState<string>("");
|
||||||
|
const [currentPlaylist, setCurrentPlaylist] = useState<CurrentPlaylist>();
|
||||||
|
const cachedCredentials = localStorage.getItem(CredentialKeys.credentials);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoggedIn(!!cachedCredentials);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onWindowClose = () => {
|
||||||
|
if (audioPlayer.isRunning()) {
|
||||||
|
audioPlayer.stop().catch((e) => console.error("Failed to quit MPV player: ", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
windowRef.current?.addEventListener(WidgetEventTypes.Close, onWindowClose);
|
||||||
|
return () => {
|
||||||
|
windowRef.current?.removeEventListener(WidgetEventTypes.Close, onWindowClose);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// for user code login
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn && credentials && !localStorage.getItem(CredentialKeys.refresh_token)) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get<null, null, null, { code: string }>("/auth/spotify/callback", async (req, res) => {
|
||||||
|
try {
|
||||||
|
spotifyApi.setClientId(credentials.clientId);
|
||||||
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
|
const { body: authRes } = await spotifyApi.authorizationCodeGrant(req.query.code);
|
||||||
|
setAccess_token(authRes.access_token);
|
||||||
|
localStorage.setItem(CredentialKeys.refresh_token, authRes.refresh_token);
|
||||||
|
return res.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fullfil code grant flow: ", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(4304, () => {
|
||||||
|
console.log("Server is running");
|
||||||
|
spotifyApi.setClientId(credentials.clientId);
|
||||||
|
spotifyApi.setClientSecret(credentials.clientSecret);
|
||||||
|
open(spotifyApi.createAuthorizeURL(["user-library-read", "playlist-read-private", "user-library-modify","playlist-modify-private", "playlist-modify-public"], "xxxyyysssddd")).catch((e) =>
|
||||||
|
console.error("Opening IPC connection with browser failed: ", e)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
server.close(() => console.log("Closed server"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, credentials]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cachedCredentials) {
|
||||||
|
setCredentials(JSON.parse(cachedCredentials));
|
||||||
|
}
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Window ref={windowRef} on={windowEvents} windowState={WindowState.WindowMaximized} windowIcon={winIcon} windowTitle="Spotube" minSize={minSize}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<authContext.Provider value={{ isLoggedIn, setIsLoggedIn, access_token, setAccess_token, ...credentials }}>
|
||||||
|
<playerContext.Provider value={{ currentPlaylist, currentTrack, setCurrentPlaylist, setCurrentTrack }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<View style={`flex: 1; flex-direction: 'column'; align-items: 'stretch';`}>
|
||||||
|
<Routes />
|
||||||
|
{isLoggedIn && <Player />}
|
||||||
|
</View>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</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;
|
35
src/components/CurrentPlaylist.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import playerContext from "../context/playerContext";
|
||||||
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
|
function CurrentPlaylist() {
|
||||||
|
const { currentPlaylist, currentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
|
if (!currentPlaylist && !currentTrack) {
|
||||||
|
return <Text style="flex: 1;">{`<center>There is nothing being played now</center>`}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTrack && !currentPlaylist) {
|
||||||
|
<View style="flex: 1;">
|
||||||
|
<TrackButton track={currentTrack} index={0}/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text>{`<center><h2>${currentPlaylist?.name}</h2></center>`}</Text>
|
||||||
|
<TrackTableIndex />
|
||||||
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
|
{currentPlaylist?.tracks.map(({ track }, index) => {
|
||||||
|
return <TrackButton key={index + track.id} track={track} index={index} />;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrentPlaylist;
|
90
src/components/Home.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
|
||||||
|
import { useHistory } from "react-router";
|
||||||
|
import { CursorShape, QMouseEvent } from "@nodegui/nodegui";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const { data: categories, isError, refetch, isLoading } = useSpotifyQuery<SpotifyApi.CategoryObject[]>(
|
||||||
|
QueryCacheKeys.categories,
|
||||||
|
(spotifyApi) => spotifyApi.getCategories({ country: "US" }).then((categoriesReceived) => categoriesReceived.body.categories.items),
|
||||||
|
{ initialData: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea style={`flex-grow: 1; border: none; flex: 1;`}>
|
||||||
|
<View style={`flex-direction: 'column'; justify-content: '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} />;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
|
|
||||||
|
interface CategoryCardProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryStylesheet = `
|
||||||
|
#container{
|
||||||
|
flex: 1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
#child-view{
|
||||||
|
flex: 1;
|
||||||
|
justify-content: 'space-around';
|
||||||
|
align-items: 'center';
|
||||||
|
}
|
||||||
|
#anchor-heading:hover{
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const CategoryCard = ({ id, name }: CategoryCardProps) => {
|
||||||
|
const history = useHistory();
|
||||||
|
const { data: playlists, isError } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
||||||
|
[QueryCacheKeys.categoryPlaylists, id],
|
||||||
|
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id, { limit: 4 }).then((playlistsRes) => playlistsRes.body.playlists.items),
|
||||||
|
{ initialData: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
function goToGenre(native: any) {
|
||||||
|
const mouse = new QMouseEvent(native);
|
||||||
|
if (mouse.button() === 1) {
|
||||||
|
history.push(`/genre/playlists/${id}`, { name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View id="container" styleSheet={categoryStylesheet}>
|
||||||
|
<Button id="anchor-heading" cursor={CursorShape.PointingHandCursor} on={{ MouseButtonRelease: goToGenre }} text={name} />
|
||||||
|
<View id="child-view">
|
||||||
|
{playlists?.map((playlist, index) => {
|
||||||
|
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
146
src/components/Library.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { Button, ScrollArea, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
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';">
|
||||||
|
<Redirect from="/library" to="/library/saved-tracks" />
|
||||||
|
<View style="flex-direction: 'column'; flex: 1; max-width: 150px;">
|
||||||
|
<TabMenuItem title="Saved Tracks" url="/library/saved-tracks" />
|
||||||
|
<TabMenuItem title="Playlists" url="/library/playlists" />
|
||||||
|
</View>
|
||||||
|
<Route exact path="/library/saved-tracks">
|
||||||
|
<UserSavedTracks />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/library/playlists">
|
||||||
|
<UserPlaylists />
|
||||||
|
</Route>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Library;
|
||||||
|
|
||||||
|
function UserPlaylists() {
|
||||||
|
const { data: userPagedPlaylists, isError, isLoading, refetch, isFetchingNextPage, hasNextPage, fetchNextPage } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(
|
||||||
|
QueryCacheKeys.userPlaylists,
|
||||||
|
(spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => {
|
||||||
|
return userPlaylists.body;
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const userPlaylists = userPagedPlaylists?.pages
|
||||||
|
?.map((playlist) => playlist.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenreView
|
||||||
|
heading="User Playlists"
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading}
|
||||||
|
playlists={userPlaylists ?? []}
|
||||||
|
isLoadable={!isFetchingNextPage}
|
||||||
|
refetch={refetch}
|
||||||
|
loadMore={hasNextPage ? ()=>fetchNextPage() : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserSavedTracks() {
|
||||||
|
const userSavedPlaylistId = "user-saved-tracks";
|
||||||
|
const { data: userSavedTracks, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(
|
||||||
|
QueryCacheKeys.userSavedTracks,
|
||||||
|
(spotifyApi, { pageParam }) => spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam(lastPage) {
|
||||||
|
if (lastPage.next) {
|
||||||
|
return lastPage.offset + lastPage.limit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack } = useContext(playerContext);
|
||||||
|
|
||||||
|
const userTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
|
|
||||||
|
function handlePlaylistPlayPause(index?: number) {
|
||||||
|
if (currentPlaylist?.id !== userSavedPlaylistId && userTracks) {
|
||||||
|
setCurrentPlaylist({ id: userSavedPlaylistId, name: "Liked Tracks", thumbnail: "https://nerdist.com/wp-content/uploads/2020/07/maxresdefault.jpg", tracks: userTracks });
|
||||||
|
setCurrentTrack(userTracks[index ?? 0].track);
|
||||||
|
} else {
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist: TrackButtonPlaylistObject = {
|
||||||
|
collaborative: false,
|
||||||
|
description: "User Playlist",
|
||||||
|
tracks: {
|
||||||
|
items: userTracks ?? [],
|
||||||
|
limit: 20,
|
||||||
|
href: "",
|
||||||
|
next: "",
|
||||||
|
offset: 0,
|
||||||
|
previous: "",
|
||||||
|
total: 20,
|
||||||
|
},
|
||||||
|
external_urls: { spotify: "" },
|
||||||
|
href: "",
|
||||||
|
id: userSavedPlaylistId,
|
||||||
|
images: [{ url: "https://facebook.com/img.jpeg" }],
|
||||||
|
name: "User saved track",
|
||||||
|
owner: { external_urls: { spotify: "" }, href: "", id: "Me", type: "user", uri: "spotify:user:me", display_name: "User", followers: { href: null, total: 0 } },
|
||||||
|
public: false,
|
||||||
|
snapshot_id: userSavedPlaylistId + "snapshot",
|
||||||
|
type: "playlist",
|
||||||
|
uri: "spotify:user:me:saved-tracks",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<PlaylistSimpleControls handlePlaylistPlayPause={handlePlaylistPlayPause} isActive={currentPlaylist?.id === userSavedPlaylistId} />
|
||||||
|
<TrackTableIndex />
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column'; align-items: 'stretch';">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
||||||
|
{userTracks?.map(({ track }, index) => track && <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />)}
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
|
text="Load more"
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
73
src/components/Login.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React, { useContext, useState } from "react";
|
||||||
|
import { LineEdit, Text, Button, View } from "@nodegui/react-nodegui";
|
||||||
|
import authContext from "../context/authContext";
|
||||||
|
import { CredentialKeys, Credentials } from "../app";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
232
src/components/Player.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
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, heart } from "../icons";
|
||||||
|
import IconButton from "./shared/IconButton";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
import useTrackReaction from "../hooks/useTrackReaction";
|
||||||
|
|
||||||
|
export const audioPlayer = new NodeMpv(
|
||||||
|
{
|
||||||
|
audio_only: true,
|
||||||
|
auto_restart: true,
|
||||||
|
time_update: 1,
|
||||||
|
binary: process.env.MPV_EXECUTABLE,
|
||||||
|
// 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 { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
|
const [isPaused, setIsPaused] = useState(true);
|
||||||
|
const [volume, setVolume] = useState<number>(55);
|
||||||
|
const [totalDuration, setTotalDuration] = useState<number>(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);
|
||||||
|
},
|
||||||
|
sliderReleased: () => {
|
||||||
|
localStorage.setItem("volume", volume.toString());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const playerRunning = audioPlayer.isRunning();
|
||||||
|
const titleRef = useRef<QLabel>();
|
||||||
|
|
||||||
|
// initial Effect
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!playerRunning) {
|
||||||
|
await audioPlayer.start();
|
||||||
|
await audioPlayer.volume(volume);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed starting audio player]: ");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerRunning) {
|
||||||
|
audioPlayer.quit().catch((e: any) => console.log(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// track change effect
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (currentTrack && playerRunning) {
|
||||||
|
const youtubeTrack = await getYoutubeTrack(currentTrack);
|
||||||
|
await audioPlayer.load(youtubeTrack.youtube_uri, "replace");
|
||||||
|
await audioPlayer.play();
|
||||||
|
setIsPaused(false);
|
||||||
|
}
|
||||||
|
setIsStopped(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.errcode !== 5) {
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
showError(error, "[Failure at track change]: ");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [currentTrack]);
|
||||||
|
|
||||||
|
// changing shuffle to default
|
||||||
|
useEffect(() => {
|
||||||
|
setShuffle(false);
|
||||||
|
}, [currentPlaylist])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRunning) {
|
||||||
|
audioPlayer.volume(volume);
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// for monitoring shuffle playlist
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPlaylist) {
|
||||||
|
if (shuffle && realPlaylist.length === 0) {
|
||||||
|
const shuffledTracks = shuffleArray(currentPlaylist.tracks);
|
||||||
|
setRealPlaylist(currentPlaylist.tracks);
|
||||||
|
setCurrentPlaylist({ ...currentPlaylist, tracks: shuffledTracks });
|
||||||
|
} else if (!shuffle && realPlaylist.length > 0) {
|
||||||
|
setCurrentPlaylist({ ...currentPlaylist, tracks: realPlaylist });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shuffle]);
|
||||||
|
|
||||||
|
// live Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRunning) {
|
||||||
|
const statusListener = (status: { property: string; value: any }) => {
|
||||||
|
if (status?.property === "duration") {
|
||||||
|
setTotalDuration(status.value ?? 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stopListener = () => {
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
// go to next track
|
||||||
|
if (currentTrack && playlistTracksIds && currentPlaylist?.tracks.length !== 0) {
|
||||||
|
const index = playlistTracksIds?.indexOf(currentTrack.id) + 1;
|
||||||
|
setCurrentTrack(currentPlaylist?.tracks[index > playlistTracksIds.length - 1 ? 0 : index].track);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pauseListener = () => {
|
||||||
|
setIsPaused(true);
|
||||||
|
};
|
||||||
|
const resumeListener = () => {
|
||||||
|
setIsPaused(false);
|
||||||
|
};
|
||||||
|
audioPlayer.on("status", statusListener);
|
||||||
|
audioPlayer.on("stopped", stopListener);
|
||||||
|
audioPlayer.on("paused", pauseListener);
|
||||||
|
audioPlayer.on("resumed", resumeListener);
|
||||||
|
return () => {
|
||||||
|
audioPlayer.off("status", statusListener);
|
||||||
|
audioPlayer.off("stopped", stopListener);
|
||||||
|
audioPlayer.off("paused", pauseListener);
|
||||||
|
audioPlayer.off("resumed", resumeListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePlayPause = async () => {
|
||||||
|
try {
|
||||||
|
if ((await audioPlayer.isPaused()) && playerRunning) {
|
||||||
|
await audioPlayer.play();
|
||||||
|
setIsStopped(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
} else {
|
||||||
|
await audioPlayer.pause();
|
||||||
|
setIsStopped(true);
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Track control failed]: ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevOrNext = (constant: number) => {
|
||||||
|
if (currentTrack && playlistTracksIds && currentPlaylist) {
|
||||||
|
const index = playlistTracksIds.indexOf(currentTrack.id) + constant;
|
||||||
|
setCurrentTrack(currentPlaylist.tracks[index > playlistTracksIds?.length - 1 ? 0 : index < 0 ? playlistTracksIds.length - 1 : index].track);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function stopPlayback() {
|
||||||
|
try {
|
||||||
|
if (playerRunning) {
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
await audioPlayer.stop();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed at audio-player stop]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistsNames = currentTrack?.artists?.map((x) => x.name);
|
||||||
|
return (
|
||||||
|
<GridView enabled={!!currentTrack} style="flex: 1; max-height: 120px;">
|
||||||
|
<GridRow>
|
||||||
|
<GridColumn width={2}>
|
||||||
|
<Text ref={titleRef} wordWrap>
|
||||||
|
{artistsNames && currentTrack
|
||||||
|
? `
|
||||||
|
<p><b>${currentTrack.name}</b> - ${artistsNames[0]} ${artistsNames.length > 1 ? "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 || isPaused || !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
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
if (currentTrack) {
|
||||||
|
reactToTrack({ added_at: Date.now().toString(), track: currentTrack });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={new QIcon(isFavorite(currentTrack?.id ?? "") ? heart : 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;
|
62
src/components/PlayerProgressBar.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Direction, Orientation, QAbstractSliderSignals } from "@nodegui/nodegui";
|
||||||
|
import { BoxView, Slider, Text, useEventHandler } from "@nodegui/react-nodegui";
|
||||||
|
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<number>(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) => {
|
||||||
|
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;
|
||||||
|
const playbackTime = new Date(trackTime * 1000).toISOString().substr(14, 5) + "/" + new Date(totalDuration * 1000).toISOString().substr(14, 5)
|
||||||
|
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>{playbackTime}</Text>
|
||||||
|
</BoxView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayerProgressBar;
|
72
src/components/PlaylistGenreView.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { QAbstractButtonSignals } from "@nodegui/nodegui";
|
||||||
|
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React from "react";
|
||||||
|
import { useLocation, useParams } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
import BackButton from "./BackButton";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
|
||||||
|
function PlaylistGenreView() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const location = useLocation<{ name: string }>();
|
||||||
|
const { data: playlists, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistObjectSimplified[]>(
|
||||||
|
[QueryCacheKeys.genrePlaylists, id],
|
||||||
|
(spotifyApi) => spotifyApi.getPlaylistsForCategory(id).then((playlistsRes) => playlistsRes.body.playlists.items),
|
||||||
|
{ initialData: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
return <GenreView isError={isError} isLoading={isLoading} refetch={refetch} heading={location.state.name} playlists={playlists ?? []} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaylistGenreView;
|
||||||
|
|
||||||
|
interface GenreViewProps {
|
||||||
|
heading: string;
|
||||||
|
playlists: SpotifyApi.PlaylistObjectSimplified[];
|
||||||
|
loadMore?: QAbstractButtonSignals["clicked"];
|
||||||
|
isLoadable?: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenreView({ heading, playlists, loadMore, isLoadable, isError, isLoading, refetch }: GenreViewProps) {
|
||||||
|
const playlistGenreViewStylesheet = `
|
||||||
|
#genre-container{
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
#heading {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
#scroll-view{
|
||||||
|
flex: 1;
|
||||||
|
flex-grow: 1;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
#child-container {
|
||||||
|
flex-direction: "row";
|
||||||
|
justify-content: "space-evenly";
|
||||||
|
align-items: 'center';
|
||||||
|
flex-wrap: "wrap";
|
||||||
|
width: 330px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return (
|
||||||
|
<View id="genre-container" styleSheet={playlistGenreViewStylesheet}>
|
||||||
|
<BackButton />
|
||||||
|
<Text id="heading">{`<h2>${heading}</h2>`}</Text>
|
||||||
|
<ScrollArea id="scroll-view">
|
||||||
|
<View id="child-container">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed loading ${heading}'s playlists`} />
|
||||||
|
{playlists?.map((playlist, index) => {
|
||||||
|
return <PlaylistCard key={index + playlist.id} playlist={playlist} />;
|
||||||
|
})}
|
||||||
|
{loadMore && <Button text="Load more" on={{ clicked: loadMore }} enabled={isLoadable} />}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
107
src/components/PlaylistView.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React, { FC, useContext } from "react";
|
||||||
|
import { View, ScrollArea, Text } from "@nodegui/react-nodegui";
|
||||||
|
import BackButton from "./BackButton";
|
||||||
|
import { useLocation, useParams } from "react-router";
|
||||||
|
import { QIcon } from "@nodegui/nodegui";
|
||||||
|
import playerContext from "../context/playerContext";
|
||||||
|
import IconButton from "./shared/IconButton";
|
||||||
|
import { heart, heartRegular, play, stop } from "../icons";
|
||||||
|
import { audioPlayer } from "./Player";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
import usePlaylistReaction from "../hooks/usePlaylistReaction";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
|
||||||
|
export interface PlaylistTrackRes {
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistView: FC = () => {
|
||||||
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const location = useLocation<{ name: string; thumbnail: string }>();
|
||||||
|
const { isFavorite, reactToPlaylist } = usePlaylistReaction();
|
||||||
|
const { data: playlist } = useSpotifyQuery<SpotifyApi.PlaylistObjectFull>([QueryCacheKeys.categoryPlaylists, params.id], (spotifyApi) =>
|
||||||
|
spotifyApi.getPlaylist(params.id).then((playlistsRes) => playlistsRes.body)
|
||||||
|
);
|
||||||
|
const { data: tracks, isSuccess, isError, isLoading, refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>(
|
||||||
|
[QueryCacheKeys.playlistTracks, params.id],
|
||||||
|
(spotifyApi) => spotifyApi.getPlaylistTracks(params.id).then((track) => track.body.items),
|
||||||
|
{ initialData: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlaylistPlayPause = () => {
|
||||||
|
if (currentPlaylist?.id !== params.id && isSuccess && tracks) {
|
||||||
|
setCurrentPlaylist({ ...params, ...location.state, tracks });
|
||||||
|
setCurrentTrack(tracks[0].track);
|
||||||
|
} else {
|
||||||
|
audioPlayer.stop().catch((error) => console.error("Failed to stop audio player: ", error));
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={`flex: 1; flex-direction: 'column';`}>
|
||||||
|
<PlaylistSimpleControls
|
||||||
|
handlePlaylistReact={() => playlist && reactToPlaylist(playlist)}
|
||||||
|
handlePlaylistPlayPause={handlePlaylistPlayPause}
|
||||||
|
isActive={currentPlaylist?.id === params.id}
|
||||||
|
isFavorite={isFavorite(params.id)}
|
||||||
|
/>
|
||||||
|
<Text>{`<center><h2>${location.state.name[0].toUpperCase()}${location.state.name.slice(1)}</h2></center>`}</Text>
|
||||||
|
{<TrackTableIndex />}
|
||||||
|
<ScrollArea style={`flex:1; flex-grow: 1; border: none;`}>
|
||||||
|
<View style={`flex-direction:column; flex: 1;`}>
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading} reload={refetch} message={`Failed retrieving ${location.state.name} tracks`} />
|
||||||
|
{tracks?.map(({ track }, index) => {
|
||||||
|
if (track) {
|
||||||
|
return <TrackButton key={index + track.id} track={track} index={index} playlist={playlist} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaylistView;
|
||||||
|
|
||||||
|
export function TrackTableIndex() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style="padding: 5px;">{`<h3>#</h3>`}</Text>
|
||||||
|
<Text style="width: '35%';">{`<h3>Title</h3>`}</Text>
|
||||||
|
<Text style="width: '25%';">{`<h3>Album</h3>`}</Text>
|
||||||
|
<Text style="width: '15%';">{`<h3>Duration</h3>`}</Text>
|
||||||
|
<Text style="width: '15%';">{`<h3>Actions</h3>`}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
interface PlaylistSimpleControlsProps {
|
||||||
|
handlePlaylistPlayPause: (index?: number) => void;
|
||||||
|
handlePlaylistReact?: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistSimpleControls({ handlePlaylistPlayPause, isActive, handlePlaylistReact, isFavorite }: PlaylistSimpleControlsProps) {
|
||||||
|
return (
|
||||||
|
<View style={`justify-content: 'space-evenly'; max-width: 150px; padding: 10px;`}>
|
||||||
|
<BackButton />
|
||||||
|
{isFavorite !== undefined && <IconButton icon={new QIcon(isFavorite ? heart : heartRegular)} on={{ clicked: handlePlaylistReact }} />}
|
||||||
|
<IconButton
|
||||||
|
style={`background-color: #00be5f; color: white;`}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
handlePlaylistPlayPause();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={new QIcon(isActive ? stop : play)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
95
src/components/Search.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { CursorShape, QIcon, QKeyEvent, QMouseEvent } from "@nodegui/nodegui";
|
||||||
|
import { LineEdit, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useHistory } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
import useSpotifyQuery from "../hooks/useSpotifyQuery";
|
||||||
|
import { search } from "../icons";
|
||||||
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
|
import IconButton from "./shared/IconButton";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import PlaylistCard from "./shared/PlaylistCard";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
|
function Search() {
|
||||||
|
const history = useHistory<{ searchQuery: string }>();
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const { data: searchResults, refetch, isError, isLoading } = useSpotifyQuery<SpotifyApi.SearchResponse>(
|
||||||
|
QueryCacheKeys.search,
|
||||||
|
(spotifyApi) => spotifyApi.search(searchQuery, ["playlist", "track"], { limit: 4 }).then((res) => res.body),
|
||||||
|
{ enabled: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
try {
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed to search through spotify]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = <PlaceholderApplet error={isError} loading={isLoading} message="Failed querying spotify" reload={refetch} />;
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column'; padding: 5px;">
|
||||||
|
<View>
|
||||||
|
<LineEdit
|
||||||
|
style="width: '65%'; margin: 5px;"
|
||||||
|
placeholderText="Search spotify"
|
||||||
|
on={{
|
||||||
|
textChanged(t) {
|
||||||
|
setSearchQuery(t);
|
||||||
|
},
|
||||||
|
KeyRelease(native: any) {
|
||||||
|
const key = new QKeyEvent(native);
|
||||||
|
const isEnter = key.key() === 16777220;
|
||||||
|
if (isEnter) {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton enabled={searchQuery.length > 0} icon={new QIcon(search)} on={{ clicked: handleSearch }} />
|
||||||
|
</View>
|
||||||
|
<ScrollArea style="flex: 1;">
|
||||||
|
<View style="flex-direction: 'column'; flex: 1;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (new QMouseEvent(native).button() === 1 && searchResults?.tracks) {
|
||||||
|
history.push("/search/songs", { searchQuery });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}>{`<h2>Songs</h2>`}</Text>
|
||||||
|
<TrackTableIndex />
|
||||||
|
{placeholder}
|
||||||
|
{searchResults?.tracks?.items.map((track, index) => (
|
||||||
|
<TrackButton key={index + track.id} index={index} track={track} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text
|
||||||
|
cursor={CursorShape.PointingHandCursor}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (new QMouseEvent(native).button() === 1 && searchResults?.playlists) {
|
||||||
|
history.push("/search/playlists", { searchQuery });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}>{`<h2>Playlists</h2>`}</Text>
|
||||||
|
<View style="flex: 1; justify-content: 'space-around'; align-items: 'center';">
|
||||||
|
{placeholder}
|
||||||
|
{searchResults?.playlists?.items.map((playlist, index) => (
|
||||||
|
<PlaylistCard key={index + playlist.id} playlist={playlist} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Search;
|
38
src/components/SearchResultPlaylistCollection.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
|
import { GenreView } from "./PlaylistGenreView";
|
||||||
|
|
||||||
|
function SearchResultPlaylistCollection() {
|
||||||
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
|
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
|
QueryCacheKeys.searchPlaylist,
|
||||||
|
(spotifyApi, { pageParam }) => spotifyApi.searchPlaylists(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.playlists?.next) {
|
||||||
|
return (lastPage.playlists?.offset ?? 0) + (lastPage.playlists?.limit ?? 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<GenreView
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading || isFetchingNextPage}
|
||||||
|
refetch={refetch}
|
||||||
|
heading={"Search: " + location.state.searchQuery}
|
||||||
|
playlists={
|
||||||
|
(searchResults?.pages
|
||||||
|
?.map((page) => page.playlists?.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[]) ?? []
|
||||||
|
}
|
||||||
|
loadMore={hasNextPage ? () => fetchNextPage() : undefined}
|
||||||
|
isLoadable={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchResultPlaylistCollection;
|
58
src/components/SearchResultSongsCollection.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Button, ScrollArea, Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "../hooks/useSpotifyInfiniteQuery";
|
||||||
|
import { TrackTableIndex } from "./PlaylistView";
|
||||||
|
import PlaceholderApplet from "./shared/PlaceholderApplet";
|
||||||
|
import { TrackButton } from "./shared/TrackButton";
|
||||||
|
|
||||||
|
function SearchResultSongsCollection() {
|
||||||
|
const location = useLocation<{ searchQuery: string }>();
|
||||||
|
const { data: searchResults, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, refetch } = useSpotifyInfiniteQuery<SpotifyApi.SearchResponse>(
|
||||||
|
QueryCacheKeys.searchSongs,
|
||||||
|
(spotifyApi, { pageParam }) => spotifyApi.searchTracks(location.state.searchQuery, { limit: 20, offset: pageParam }).then((res) => res.body),
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.tracks?.next) {
|
||||||
|
return (lastPage.tracks?.offset ?? 0) + (lastPage.tracks?.limit ?? 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<Text>{`
|
||||||
|
<center>
|
||||||
|
<h2>Search: ${location.state.searchQuery}</h2>
|
||||||
|
</center>
|
||||||
|
`}</Text>
|
||||||
|
<TrackTableIndex />
|
||||||
|
<ScrollArea style="flex: 1; border: none;">
|
||||||
|
<View style="flex: 1; flex-direction: 'column';">
|
||||||
|
<PlaceholderApplet error={isError} loading={isLoading || isFetchingNextPage} message="Failed querying spotify" reload={refetch} />
|
||||||
|
{searchResults?.pages
|
||||||
|
.map((searchResult) => searchResult.tracks?.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1)
|
||||||
|
.map((track, index) => track && <TrackButton key={index + track.id} index={index} track={track} />)}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
style="flex-grow: 0; align-self: 'center';"
|
||||||
|
text="Load more"
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
fetchNextPage();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
enabled={!isFetchingNextPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollArea>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchResultSongsCollection;
|
58
src/components/TabMenu.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Button, Text } from "@nodegui/react-nodegui";
|
||||||
|
import { useHistory, useLocation } from "react-router";
|
||||||
|
|
||||||
|
function TabMenu() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View id="tabmenu" styleSheet={tabBarStylesheet}>
|
||||||
|
<View>
|
||||||
|
<Text>{`<h1>Spotube</h1>`}</Text>
|
||||||
|
</View>
|
||||||
|
<TabMenuItem url="/home" title="Browse" />
|
||||||
|
<TabMenuItem url="/library" title="Library" />
|
||||||
|
<TabMenuItem url="/currently" title="Currently Playing" />
|
||||||
|
<TabMenuItem url="/search" title="Search"/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tabBarStylesheet = `
|
||||||
|
#tabmenu{
|
||||||
|
padding: 10px;
|
||||||
|
flex-direction: 'row';
|
||||||
|
justify-content: 'space-around';
|
||||||
|
}
|
||||||
|
#tabmenu-item:hover{
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
#tabmenu-item:active{
|
||||||
|
color: #59ff88;
|
||||||
|
}
|
||||||
|
#tabmenu-active-item{
|
||||||
|
background-color: green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default TabMenu;
|
||||||
|
|
||||||
|
export interface TabMenuItemProps {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* path to the icon in string
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabMenuItem({ icon, title, url }: TabMenuItemProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
function clicked() {
|
||||||
|
history.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Button on={{ clicked }} id={location.pathname.replace("/", " ").startsWith(url.replace("/", " ")) ? "tabmenu-active-item" : `tabmenu-item`} text={title} />;
|
||||||
|
}
|
43
src/components/shared/CachedImage.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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";
|
||||||
|
import showError from "../../helpers/showError";
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (imageBuffer===undefined) {
|
||||||
|
getCachedImageBuffer(src, props.maxSize ?? props.size)
|
||||||
|
.then((buffer) => setImageBuffer(buffer))
|
||||||
|
.catch((error) => {
|
||||||
|
setImageProcessError(false);
|
||||||
|
showError(error, "[Cached Image 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;
|
56
src/components/shared/PlaceholderApplet.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { View, Button, Text } from "@nodegui/react-nodegui";
|
||||||
|
import { QLabel, QMovie, } from "@nodegui/nodegui";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { loadingSpinner } from "../../icons";
|
||||||
|
|
||||||
|
interface ErrorAppletProps {
|
||||||
|
error: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
message?: string;
|
||||||
|
reload: Function;
|
||||||
|
helps?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderApplet({ message, reload, helps, loading, error }: ErrorAppletProps) {
|
||||||
|
const textRef = useRef<QLabel>();
|
||||||
|
const movie = new QMovie();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movie.setFileName(loadingSpinner);
|
||||||
|
textRef.current?.setMovie(movie);
|
||||||
|
textRef.current?.show();
|
||||||
|
movie.start();
|
||||||
|
}, []);
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style="flex: 1; justify-content: 'center'; align-items: 'center';">
|
||||||
|
<Text ref={textRef} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
return (
|
||||||
|
<View style="flex-direction: 'column'; align-items: 'center';">
|
||||||
|
<Text openExternalLinks>{`
|
||||||
|
<h3>${message ? message : "An error occured"}</h3>
|
||||||
|
${
|
||||||
|
helps
|
||||||
|
? `<p>Check if you're connected to internet & then try again. If the issue still persists ask for help or create a <a href="https://github.com/krtirtho/spotube/issues">issue</a>
|
||||||
|
</p>`
|
||||||
|
: ``
|
||||||
|
}
|
||||||
|
`}</Text>
|
||||||
|
<Button
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reload();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
text="Reload"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaceholderApplet;
|
123
src/components/shared/PlaylistCard.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { CursorShape, QIcon, QMouseEvent } from '@nodegui/nodegui';
|
||||||
|
import { Text, View } from '@nodegui/react-nodegui';
|
||||||
|
import React, { useContext, useMemo, useState } from 'react'
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
import { QueryCacheKeys } from '../../conf';
|
||||||
|
import playerContext from '../../context/playerContext';
|
||||||
|
import { generateRandomColor, getDarkenForeground } from '../../helpers/RandomColor';
|
||||||
|
import showError from '../../helpers/showError';
|
||||||
|
import usePlaylistReaction from '../../hooks/usePlaylistReaction';
|
||||||
|
import useSpotifyQuery from '../../hooks/useSpotifyQuery';
|
||||||
|
import { heart, heartRegular, pause, play } from '../../icons';
|
||||||
|
import { audioPlayer } from '../Player';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
|
||||||
|
interface PlaylistCardProps {
|
||||||
|
playlist: SpotifyApi.PlaylistObjectSimplified;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistCard = ({ playlist }: PlaylistCardProps) => {
|
||||||
|
const { id, description, name, images } = playlist;
|
||||||
|
const history = useHistory();
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const { setCurrentTrack, currentPlaylist, setCurrentPlaylist } = useContext(playerContext);
|
||||||
|
const { refetch } = useSpotifyQuery<SpotifyApi.PlaylistTrackObject[]>([QueryCacheKeys.playlistTracks, id], (spotifyApi) => spotifyApi.getPlaylistTracks(id).then((track) => track.body.items), {
|
||||||
|
initialData: [],
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const { reactToPlaylist, isFavorite } = usePlaylistReaction();
|
||||||
|
|
||||||
|
const handlePlaylistPlayPause = async () => {
|
||||||
|
try {
|
||||||
|
const { data: tracks, isSuccess } = await refetch();
|
||||||
|
if (currentPlaylist?.id !== id && isSuccess && tracks) {
|
||||||
|
setCurrentPlaylist({ tracks, id, name, thumbnail: images[0].url });
|
||||||
|
setCurrentTrack(tracks[0].track);
|
||||||
|
} else {
|
||||||
|
await audioPlayer.stop();
|
||||||
|
setCurrentTrack(undefined);
|
||||||
|
setCurrentPlaylist(undefined);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Failed adding playlist to queue]: ");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function gotoPlaylist(native?: any) {
|
||||||
|
const key = new QMouseEvent(native);
|
||||||
|
if (key.button() === 1) {
|
||||||
|
history.push(`/playlist/${id}`, { name, thumbnail: images[0].url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgColor1 = useMemo(() => generateRandomColor(), []);
|
||||||
|
const color = useMemo(() => getDarkenForeground(bgColor1), [bgColor1]);
|
||||||
|
|
||||||
|
const playlistStyleSheet = `
|
||||||
|
#playlist-container{
|
||||||
|
width: 150px;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 150px;
|
||||||
|
background-color: ${bgColor1};
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
#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,
|
||||||
|
HoverEnter() {
|
||||||
|
setHovered(true);
|
||||||
|
},
|
||||||
|
HoverLeave() {
|
||||||
|
setHovered(false);
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
{/* <CachedImage src={thumbnail} maxSize={{ height: 150, width: 150 }} scaledContents alt={name} /> */}
|
||||||
|
<Text style={`color: ${color};`} wordWrap on={{ MouseButtonRelease: gotoPlaylist }}>
|
||||||
|
{`
|
||||||
|
<center>
|
||||||
|
<h3>${name}</h3>
|
||||||
|
<p>${description}</p>
|
||||||
|
</center>
|
||||||
|
`}
|
||||||
|
</Text>
|
||||||
|
{(hovered || currentPlaylist?.id === id) && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
style={`position: absolute; bottom: 30px; left: '55%'; background-color: ${color};`}
|
||||||
|
icon={new QIcon(isFavorite(id) ? heart : heartRegular)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reactToPlaylist(playlist);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(currentPlaylist?.id === id ? pause : play)}
|
||||||
|
style={`position: absolute; bottom: 30px; left: '80%'; background-color: ${color};`}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
handlePlaylistPlayPause();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaylistCard;
|
86
src/components/shared/TrackButton.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { QIcon, QMouseEvent } from "@nodegui/nodegui";
|
||||||
|
import { Text, View } from "@nodegui/react-nodegui";
|
||||||
|
import React, { FC, useContext, useMemo } from "react";
|
||||||
|
import playerContext from "../../context/playerContext";
|
||||||
|
import { msToMinAndSec } from "../../helpers/msToMin_sec";
|
||||||
|
import useTrackReaction from "../../hooks/useTrackReaction";
|
||||||
|
import { heart, heartRegular, pause, play } from "../../icons";
|
||||||
|
import IconButton from "./IconButton";
|
||||||
|
|
||||||
|
export interface TrackButtonPlaylistObject extends SpotifyApi.PlaylistBaseObject {
|
||||||
|
follower?: SpotifyApi.FollowersObject;
|
||||||
|
tracks: SpotifyApi.PagingObject<SpotifyApi.SavedTrackObject | SpotifyApi.PlaylistTrackObject>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackButtonProps {
|
||||||
|
track: SpotifyApi.TrackObjectFull;
|
||||||
|
playlist?: TrackButtonPlaylistObject;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackButton: FC<TrackButtonProps> = ({ track, index, playlist }) => {
|
||||||
|
const { reactToTrack, isFavorite } = useTrackReaction();
|
||||||
|
const { currentPlaylist, setCurrentPlaylist, setCurrentTrack, currentTrack } = useContext(playerContext);
|
||||||
|
const handlePlaylistPlayPause = (index: number) => {
|
||||||
|
if (playlist && currentPlaylist?.id !== playlist.id) {
|
||||||
|
const globalPlaylistObj = { id: playlist.id, name: playlist.name, thumbnail: playlist.images[0].url, tracks: playlist.tracks.items };
|
||||||
|
setCurrentPlaylist(globalPlaylistObj);
|
||||||
|
setCurrentTrack(playlist.tracks.items[index].track);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackClickHandler = (track: SpotifyApi.TrackObjectFull) => {
|
||||||
|
setCurrentTrack(track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = useMemo(() => msToMinAndSec(track.duration_ms), []);
|
||||||
|
const active = (currentPlaylist?.id === playlist?.id && currentTrack?.id === track.id) || currentTrack?.id === track.id;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
id={active ? "active" : "track-button"}
|
||||||
|
styleSheet={trackButtonStyle}
|
||||||
|
on={{
|
||||||
|
MouseButtonRelease(native: any) {
|
||||||
|
if (new QMouseEvent(native).button() === 1 && playlist) {
|
||||||
|
handlePlaylistPlayPause(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Text style="padding: 5px;">{index + 1}</Text>
|
||||||
|
<View style="flex-direction: 'column'; width: '35%';">
|
||||||
|
<Text>{`<h3>${track.name}</h3>`}</Text>
|
||||||
|
<Text>{track.artists.map((artist) => artist.name).join(", ")}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style="width: '25%';">{track.album.name}</Text>
|
||||||
|
<Text style="width: '15%';">{duration}</Text>
|
||||||
|
<View style="width: '15%'; padding: 5px; justify-content: 'space-around';">
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(isFavorite(track.id) ? heart : heartRegular)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
reactToTrack({ track, added_at: "" });
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={new QIcon(active ? pause : play)}
|
||||||
|
on={{
|
||||||
|
clicked() {
|
||||||
|
trackClickHandler(track);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackButtonStyle = `
|
||||||
|
#active{
|
||||||
|
background-color: #34eb71;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
#track-button:hover{
|
||||||
|
background-color: rgba(229, 224, 224, 0.48);
|
||||||
|
}
|
||||||
|
`;
|
22
src/conf.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import dotenv from "dotenv"
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const env = dotenv.config({path: join(process.cwd(), ".env")}).parsed as any
|
||||||
|
export const clientId = "";
|
||||||
|
export const trace = process.argv.find(arg => arg === "--trace") ?? false;
|
||||||
|
export const redirectURI = "http://localhost:4304/auth/spotify/callback"
|
||||||
|
export const confDir = join(homedir(), ".config", "spotube")
|
||||||
|
export const cacheDir = join(homedir(), ".cache", "spotube")
|
||||||
|
|
||||||
|
export enum QueryCacheKeys{
|
||||||
|
categories="categories",
|
||||||
|
categoryPlaylists = "categoryPlaylists",
|
||||||
|
genrePlaylists="genrePlaylists",
|
||||||
|
playlistTracks="playlistTracks",
|
||||||
|
userPlaylists = "user-palylists",
|
||||||
|
userSavedTracks = "user-saved-tracks",
|
||||||
|
search = "search",
|
||||||
|
searchPlaylist = "searchPlaylist",
|
||||||
|
searchSongs = "searchSongs"
|
||||||
|
}
|
21
src/context/authContext.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React, { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
setIsLoggedIn: Dispatch<SetStateAction<boolean>>;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
access_token: string;
|
||||||
|
setAccess_token: Dispatch<SetStateAction<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authContext = React.createContext<AuthContext>({
|
||||||
|
isLoggedIn: false,
|
||||||
|
setIsLoggedIn() {},
|
||||||
|
access_token: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
setAccess_token() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default authContext;
|
16
src/context/playerContext.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React, { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export type CurrentTrack = SpotifyApi.TrackObjectFull;
|
||||||
|
|
||||||
|
export type CurrentPlaylist = { tracks: (SpotifyApi.PlaylistTrackObject | SpotifyApi.SavedTrackObject)[]; id: string; name: string; thumbnail: string };
|
||||||
|
|
||||||
|
export interface PlayerContext {
|
||||||
|
currentPlaylist?: CurrentPlaylist;
|
||||||
|
currentTrack?: CurrentTrack;
|
||||||
|
setCurrentPlaylist: Dispatch<SetStateAction<CurrentPlaylist | undefined>>;
|
||||||
|
setCurrentTrack: Dispatch<SetStateAction<CurrentTrack | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerContext = React.createContext<PlayerContext>({ setCurrentPlaylist() {}, setCurrentTrack() {} });
|
||||||
|
|
||||||
|
export default playerContext;
|
10
src/helpers/RandomColor.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import color from "color";
|
||||||
|
|
||||||
|
export function generateRandomColor(lightness: number=70): string {
|
||||||
|
return "hsl(" + 360 * Math.random() + "," + (25 + 70 * Math.random()) + "%," + (lightness + 10 * Math.random()) + "%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDarkenForeground(hslcolor: string): string {
|
||||||
|
const adjustedColor = color(hslcolor);
|
||||||
|
return adjustedColor.darken(.5).hex();
|
||||||
|
}
|
68
src/helpers/getCachedImageBuffer.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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 Jimp from "jimp";
|
||||||
|
import du from "du";
|
||||||
|
import { cacheDir } from "../conf";
|
||||||
|
|
||||||
|
|
||||||
|
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 cacheImgFolder = path.join(cacheDir, "images");
|
||||||
|
// for clearing up the cache if it reaches out of the size
|
||||||
|
const cacheName = `${isUrl(name) ? name.split("/").slice(-1)[0] : name}.cnim`;
|
||||||
|
const cachePath = path.join(cacheImgFolder, cacheName);
|
||||||
|
// 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(cacheImgFolder)) > MB_5) {
|
||||||
|
fs.rmSync(cacheImgFolder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
const cachedImg = await fsm.readFile(cachePath);
|
||||||
|
const cachedImgMeta = (await Jimp.read(cachedImg)).bitmap;
|
||||||
|
|
||||||
|
// if the dimensions are changed then the previously cached
|
||||||
|
// images are removed and replaced with a new one
|
||||||
|
if (dims && (cachedImgMeta.height !== dims.height || cachedImgMeta.width !== dims?.width)) {
|
||||||
|
fs.rmSync(cachePath);
|
||||||
|
return await imageResizeAndWrite(cachedImg, { cacheFolder: cacheImgFolder, 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(cacheImgFolder, { recursive: true });
|
||||||
|
if (dims) {
|
||||||
|
return await imageResizeAndWrite(resImgBuf, { cacheFolder: cacheImgFolder, cacheName, dims });
|
||||||
|
}
|
||||||
|
await fsm.writeFile(path.join(cacheImgFolder, cacheName), resImgBuf);
|
||||||
|
return resImgBuf;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Error in Image Cache]: ", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageResizeAndWrite(img: Buffer, { cacheFolder, cacheName, dims }: { dims: ImageDimensions; cacheFolder: string; cacheName: string }): Promise<Buffer> {
|
||||||
|
// caching the images by resizing if the max/fixed (Width/Height)
|
||||||
|
// is available in the args
|
||||||
|
const resizedImg = (await Jimp.read(img)).resize(dims.width, dims.height)
|
||||||
|
const resizedImgBuffer = await resizedImg.getBufferAsync(resizedImg._originalMime);
|
||||||
|
await fsm.writeFile(path.join(cacheFolder, cacheName), resizedImgBuffer);
|
||||||
|
return resizedImgBuffer;
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
5
src/helpers/msToMin_sec.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function msToMinAndSec(ms: number) {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds:number = parseInt(((ms % 60000) / 1000).toFixed(0));
|
||||||
|
return minutes + ":" + (seconds < 10 ? '0' : '') + seconds;
|
||||||
|
}
|
8
src/helpers/showError.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { trace } from "../conf";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
function showError(error: any, message: any="[Error]: ") {
|
||||||
|
console.error(chalk.red(message), trace ? error : error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default showError;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
54
src/hooks/usePlaylistReaction.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { InfiniteData, useQueryClient } from "react-query";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
|
||||||
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
|
|
||||||
|
function usePlaylistReaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: favoritePagedPlaylists } = useSpotifyInfiniteQuery<SpotifyApi.ListOfUsersPlaylistsResponse>(QueryCacheKeys.userPlaylists, (spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi.getUserPlaylists({ limit: 20, offset: pageParam }).then((userPlaylists) => {
|
||||||
|
return userPlaylists.body;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const favoritePlaylists = favoritePagedPlaylists?.pages
|
||||||
|
.map((playlist) => playlist.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.PlaylistObjectSimplified[];
|
||||||
|
|
||||||
|
function updateFunction(playlist: SpotifyApi.PlaylistObjectSimplified, old?: InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>): InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse> {
|
||||||
|
const obj: typeof old = {
|
||||||
|
pageParams: old?.pageParams ?? [],
|
||||||
|
pages:
|
||||||
|
old?.pages.map(
|
||||||
|
(oldPage, index): SpotifyApi.ListOfUsersPlaylistsResponse => {
|
||||||
|
const isPlaylistFavorite = isFavorite(playlist.id);
|
||||||
|
if (index === 0 && !isPlaylistFavorite) {
|
||||||
|
return { ...oldPage, items: [...oldPage.items, playlist] };
|
||||||
|
} else if (isPlaylistFavorite) {
|
||||||
|
return { ...oldPage, items: oldPage.items.filter((oldPlaylist) => oldPlaylist.id !== playlist.id) };
|
||||||
|
}
|
||||||
|
return oldPage;
|
||||||
|
}
|
||||||
|
) ?? [],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutate: reactToPlaylist } = useSpotifyMutation<{}, SpotifyApi.PlaylistObjectSimplified>(
|
||||||
|
(spotifyApi, { id }) => spotifyApi[isFavorite(id) ? "unfollowPlaylist" : "followPlaylist"](id).then((res) => res.body),
|
||||||
|
{
|
||||||
|
onSuccess(_, playlist) {
|
||||||
|
queryClient.setQueryData<InfiniteData<SpotifyApi.ListOfUsersPlaylistsResponse>>(QueryCacheKeys.userPlaylists, (old) => updateFunction(playlist, old));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const favoritePlaylistIds = favoritePlaylists?.map((playlist) => playlist.id);
|
||||||
|
|
||||||
|
function isFavorite(playlistId: string) {
|
||||||
|
return favoritePlaylistIds?.includes(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactToPlaylist, isFavorite, favoritePlaylists };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePlaylistReaction;
|
40
src/hooks/useSpotifyApi.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
import { CredentialKeys } from "../app";
|
||||||
|
import authContext from "../context/authContext";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
import spotifyApi from "../initializations/spotifyApi";
|
||||||
|
|
||||||
|
function useSpotifyApi() {
|
||||||
|
const {
|
||||||
|
access_token,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
isLoggedIn,
|
||||||
|
setAccess_token,
|
||||||
|
} = useContext(authContext);
|
||||||
|
const refreshToken = localStorage.getItem(CredentialKeys.refresh_token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn && clientId && clientSecret && refreshToken) {
|
||||||
|
spotifyApi.setClientId(clientId);
|
||||||
|
spotifyApi.setClientSecret(clientSecret);
|
||||||
|
spotifyApi.setRefreshToken(refreshToken);
|
||||||
|
if (!access_token) {
|
||||||
|
spotifyApi
|
||||||
|
.refreshAccessToken()
|
||||||
|
.then((token) => {
|
||||||
|
setAccess_token(token.body.access_token);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
showError(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
spotifyApi.setAccessToken(access_token);
|
||||||
|
}
|
||||||
|
}, [access_token, clientId, clientSecret, isLoggedIn]);
|
||||||
|
|
||||||
|
return spotifyApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyApi;
|
29
src/hooks/useSpotifyApiError.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import authContext from "../context/authContext";
|
||||||
|
import showError from "../helpers/showError";
|
||||||
|
|
||||||
|
function useSpotifyApiError(spotifyApi: SpotifyWebApi) {
|
||||||
|
const { setAccess_token, isLoggedIn } = useContext(authContext);
|
||||||
|
return async (error: any | Error | TypeError) => {
|
||||||
|
const isUnauthorized = error.message === "Unauthorized";
|
||||||
|
const status401 = error.status === 401;
|
||||||
|
const bodyStatus401 = error.body.error.status === 401;
|
||||||
|
const noToken = error.body.error.message === "No token provided";
|
||||||
|
const expiredToken = error.body.error.message === "The access token expired";
|
||||||
|
if ((isUnauthorized && isLoggedIn && status401) || ((noToken || expiredToken) && bodyStatus401)) {
|
||||||
|
try {
|
||||||
|
console.log(chalk.bgYellow.blackBright("Refreshing Access token"));
|
||||||
|
const {
|
||||||
|
body: { access_token: refreshedAccessToken },
|
||||||
|
} = await spotifyApi.refreshAccessToken();
|
||||||
|
setAccess_token(refreshedAccessToken);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, "[Authorization Failure]: ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyApiError;
|
28
src/hooks/useSpotifyInfiniteQuery.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { QueryFunctionContext, QueryKey, useInfiniteQuery, UseInfiniteQueryOptions, UseInfiniteQueryResult } from "react-query";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
|
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi, pageArgs: QueryFunctionContext) => Promise<TQueryData>;
|
||||||
|
|
||||||
|
function useSpotifyInfiniteQuery<TQueryData = unknown>(
|
||||||
|
queryKey: QueryKey,
|
||||||
|
queryHandler: SpotifyQueryFn<TQueryData>,
|
||||||
|
options: UseInfiniteQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
|
||||||
|
): UseInfiniteQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
||||||
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const query = useInfiniteQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, (pageArgs) => queryHandler(spotifyApi, pageArgs), options);
|
||||||
|
const { isError, error } = query;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyInfiniteQuery;
|
24
src/hooks/useSpotifyMutation.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMutation, UseMutationOptions } from "react-query";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
|
type SpotifyMutationFn<TData = unknown, TVariables = unknown> = (spotifyApi: SpotifyWebApi, variables: TVariables) => Promise<TData>;
|
||||||
|
|
||||||
|
function useSpotifyMutation<TData = unknown, TVariable = unknown>(mutationFn: SpotifyMutationFn<TData, TVariable>, options?: UseMutationOptions<TData, SpotifyApi.ErrorObject, TVariable>) {
|
||||||
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const mutation = useMutation<TData, SpotifyApi.ErrorObject, TVariable>((arg) => mutationFn(spotifyApi, arg), options);
|
||||||
|
const { isError, error } = mutation;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyMutation;
|
29
src/hooks/useSpotifyQuery.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from "react-query";
|
||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import useSpotifyApi from "./useSpotifyApi";
|
||||||
|
import useSpotifyApiError from "./useSpotifyApiError";
|
||||||
|
|
||||||
|
type SpotifyQueryFn<TQueryData> = (spotifyApi: SpotifyWebApi) => Promise<TQueryData>;
|
||||||
|
|
||||||
|
function useSpotifyQuery<TQueryData = unknown>(
|
||||||
|
queryKey: QueryKey,
|
||||||
|
queryHandler: SpotifyQueryFn<TQueryData>,
|
||||||
|
options: UseQueryOptions<TQueryData, SpotifyApi.ErrorObject> = {}
|
||||||
|
): UseQueryResult<TQueryData, SpotifyApi.ErrorObject> {
|
||||||
|
const spotifyApi = useSpotifyApi();
|
||||||
|
const handleSpotifyError = useSpotifyApiError(spotifyApi);
|
||||||
|
const query = useQuery<TQueryData, SpotifyApi.ErrorObject>(queryKey, ()=>queryHandler(spotifyApi), options);
|
||||||
|
const { isError, error } = query;
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError && error) {
|
||||||
|
handleSpotifyError(error);
|
||||||
|
}
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSpotifyQuery;
|
52
src/hooks/useTrackReaction.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { InfiniteData, useQueryClient } from "react-query";
|
||||||
|
import { QueryCacheKeys } from "../conf";
|
||||||
|
import useSpotifyInfiniteQuery from "./useSpotifyInfiniteQuery";
|
||||||
|
import useSpotifyMutation from "./useSpotifyMutation";
|
||||||
|
|
||||||
|
function useTrackReaction() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: userSavedTracks } = useSpotifyInfiniteQuery<SpotifyApi.UsersSavedTracksResponse>(QueryCacheKeys.userSavedTracks, (spotifyApi, { pageParam }) =>
|
||||||
|
spotifyApi.getMySavedTracks({ limit: 50, offset: pageParam }).then((res) => res.body)
|
||||||
|
);
|
||||||
|
const favoriteTracks = userSavedTracks?.pages
|
||||||
|
?.map((page) => page.items)
|
||||||
|
.filter(Boolean)
|
||||||
|
.flat(1) as SpotifyApi.SavedTrackObject[] | undefined;
|
||||||
|
|
||||||
|
function updateFunction(track: SpotifyApi.SavedTrackObject, old?: InfiniteData<SpotifyApi.UsersSavedTracksResponse>): InfiniteData<SpotifyApi.UsersSavedTracksResponse> {
|
||||||
|
const obj: typeof old = {
|
||||||
|
pageParams: old?.pageParams ?? [],
|
||||||
|
pages:
|
||||||
|
old?.pages.map(
|
||||||
|
(oldPage, index): SpotifyApi.UsersSavedTracksResponse => {
|
||||||
|
const isTrackFavorite = isFavorite(track.track.id);
|
||||||
|
if (index === 0 && !isTrackFavorite) {
|
||||||
|
return { ...oldPage, items: [...oldPage.items, track] };
|
||||||
|
} else if (isTrackFavorite) {
|
||||||
|
return { ...oldPage, items: oldPage.items.filter((oldTrack) => oldTrack.track.id !== track.track.id) };
|
||||||
|
}
|
||||||
|
return oldPage;
|
||||||
|
}
|
||||||
|
) ?? [],
|
||||||
|
};
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutate: reactToTrack } = useSpotifyMutation<{}, SpotifyApi.SavedTrackObject>(
|
||||||
|
(spotifyApi, { track }) => spotifyApi[isFavorite(track.id) ? "removeFromMySavedTracks" : "addToMySavedTracks"]([track.id]).then((res) => res.body),
|
||||||
|
{
|
||||||
|
onSuccess(_, track) {
|
||||||
|
queryClient.setQueryData<InfiniteData<SpotifyApi.UsersSavedTracksResponse>>(QueryCacheKeys.userSavedTracks, (old) => updateFunction(track, old));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const favoriteTrackIds = favoriteTracks?.map((track) => track.track.id);
|
||||||
|
|
||||||
|
function isFavorite(trackId: string) {
|
||||||
|
return favoriteTrackIds?.includes(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactToTrack, isFavorite, favoriteTracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTrackReaction;
|
23
src/icons.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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"
|
||||||
|
import _search from "../assets/search-solid.svg";
|
||||||
|
import _loadingSpinner from "../assets/loading-spinner.gif";
|
||||||
|
|
||||||
|
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;
|
||||||
|
export const search = _search;
|
||||||
|
export const loadingSpinner = _loadingSpinner;
|
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();
|
||||||
|
});
|
||||||
|
}
|
6
src/initializations/spotifyApi.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import SpotifyWebApi from "spotify-web-api-node";
|
||||||
|
import { redirectURI } from "../conf";
|
||||||
|
|
||||||
|
const spotifyApi = new SpotifyWebApi({ redirectUri: redirectURI });
|
||||||
|
|
||||||
|
export default spotifyApi;
|
57
src/routes.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { Redirect, 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";
|
||||||
|
import TabMenu from "./components/TabMenu";
|
||||||
|
import CurrentPlaylist from "./components/CurrentPlaylist";
|
||||||
|
import Library from "./components/Library";
|
||||||
|
import Search from "./components/Search";
|
||||||
|
import SearchResultPlaylistCollection from "./components/SearchResultPlaylistCollection";
|
||||||
|
import SearchResultSongsCollection from "./components/SearchResultSongsCollection";
|
||||||
|
|
||||||
|
function Routes() {
|
||||||
|
const { isLoggedIn } = useContext(authContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Route path="/">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<Redirect from="/" to="/home" />
|
||||||
|
<TabMenu />
|
||||||
|
<Route exact path="/home">
|
||||||
|
<Home />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/playlist/:id">
|
||||||
|
<PlaylistView />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/genre/playlists/:id">
|
||||||
|
<PlaylistGenreView />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Login />
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/currently">
|
||||||
|
<CurrentPlaylist />
|
||||||
|
</Route>
|
||||||
|
<Route path="/library">
|
||||||
|
<Library />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/search">
|
||||||
|
<Search />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/search/playlists">
|
||||||
|
<SearchResultPlaylistCollection />
|
||||||
|
</Route>
|
||||||
|
<Route exact path="/search/songs">
|
||||||
|
<SearchResultSongsCollection />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Routes;
|
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2016",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": [
|
||||||
|
"ES2019.Array",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"jsx": "react",
|
||||||
|
"strict": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["**/*"],
|
||||||
|
"exclude": ["**/node_modules/*"]
|
||||||
|
}
|
66
webpack.config.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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",
|
||||||
|
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;
|
||||||
|
};
|