Merge branch 'release/5.2.0' of github.com:Mastermindzh/tidal-hifi into fix-album-not-updating

This commit is contained in:
Rick van Lieshout 2023-06-18 15:45:01 +02:00
commit 8b56c28d75
72 changed files with 5903 additions and 7809 deletions

View File

@ -10,6 +10,10 @@ insert_final_newline = true
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[**.ts]
indent_style = space
indent_size = 2
[**.json] [**.json]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
@ -50,4 +54,4 @@ trim_trailing_whitespace = ignore
charset = ignore charset = ignore
[{test/fixtures,deps,tools/eslint,tools/gyp,tools/icu,tools/msvs}/**] [{test/fixtures,deps,tools/eslint,tools/gyp,tools/icu,tools/msvs}/**]
insert_final_newline = false insert_final_newline = false

12
.eslintrc Normal file
View File

@ -0,0 +1,12 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
]
}

View File

@ -5,6 +5,10 @@ on:
branches-ignore: branches-ignore:
- master - master
- develop - develop
pull_request:
branches-ignore:
- master
- develop
jobs: jobs:
build_on_linux: build_on_linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -16,7 +20,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
@ -26,7 +30,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
@ -36,6 +40,6 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build

View File

@ -5,6 +5,10 @@ on:
branches: branches:
- master - master
- develop - develop
pull_request:
branches:
- master
jobs: jobs:
build_on_linux: build_on_linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -16,7 +20,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- uses: actions/upload-artifact@master - uses: actions/upload-artifact@master
@ -25,12 +29,12 @@ jobs:
path: dist/ path: dist/
build_on_mac: build_on_mac:
runs-on: macOS-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- uses: actions/upload-artifact@master - uses: actions/upload-artifact@master
@ -44,7 +48,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 16 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- uses: actions/upload-artifact@master - uses: actions/upload-artifact@master

5
.gitignore vendored
View File

@ -12,3 +12,8 @@ build/linux/arch/*
# JetBrains IDE configuration # JetBrains IDE configuration
.idea .idea
ts-dist/**
ts-dist
themes
!src/themes
.sass-cache

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
19.8.1

13
.stylelintrc.json Normal file
View File

@ -0,0 +1,13 @@
{
"plugins": [
"stylelint-prettier"
],
"extends": [
"stylelint-config-standard-scss"
],
"rules": {
"prettier/prettier": true,
"scss/at-extend-no-missing-placeholder": null,
"no-descending-specificity": null
}
}

12
.vscode/settings.json vendored
View File

@ -1,3 +1,13 @@
{ {
"cSpell.words": ["hifi", "rescrobbler", "widevine"] "cSpell.words": [
"flac",
"geqnfr",
"hifi",
"playpause",
"rescrobbler",
"trackid",
"tracklist",
"widevine",
"xesam"
]
} }

View File

@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 5.2.0
- moved from Javascript to Typescript for all files
- use `npm run watch` to watch for changes & recompile typescript and sass files
- Added support for theming the application
## 5.1.0
### New features
- Added proper updates through the MediaSession API
- You can now add custom CSS in the "advanced" settings tab
- You can now configure the updateFrequency in the settings window
- Default value is set to 500 and will overwrite the hardcoded value of 100
### Fixes
- Any songs **including** an artist listed in the `skipped artists` setting will now be skipped even if the song is a collaboration.
- Linux desktop icons have been fixed. See [#222](https://github.com/Mastermindzh/tidal-hifi/pull/222) for details.
## 5.0.0
- Replaced "muting artists" with a full implementation of an Adblock mechanism
> Disabled audio & visual ads, unlocked lyrics, suggested track, track info, unlimited skips thanks to uBlockOrigin custom filters ([source](https://github.com/uBlockOrigin/uAssets/issues/17495))
- @thanasistrisp updated Electron to 24.1.2 and fixed the tray bug :)
## 4.4.0 ## 4.4.0
- Updated shortcut hint on the menubar to reflect the new `ctrl+=` shortcut. - Updated shortcut hint on the menubar to reflect the new `ctrl+=` shortcut.

141
README.md
View File

@ -1,39 +1,81 @@
<h1> # Tidal-hifi<img src = "./build/icon.png" height="40" align="right"/>
Tidal-hifi
<img src = "./build/icon.png" height="40" align="right" /> ![GitHub release](https://img.shields.io/github/release/Mastermindzh/tidal-hifi.svg) [![Discord logo](./docs/images/discord.png)](https://discord.gg/yhNwf4v4He)
</h1>
The web version of [listen.tidal.com](https://listen.tidal.com) running in electron with hifi support thanks to widevine. The web version of [listen.tidal.com](https://listen.tidal.com) running in electron with hifi support thanks to widevine.
![tidal-hifi preview](./docs/preview.png) ![tidal-hifi preview](./docs/images/preview.png)
## Table of contents ## Table of Contents
<!-- toc --> <!-- toc -->
- [Installation](#installation) - [Tidal-hifi](#tidal-hifi)
* [Using releases](#using-releases) - [Table of Contents](#table-of-contents)
* [Snap](#snap) - [Features](#features)
* [Arch Linux](#arch-linux) - [Contributions](#contributions)
* [Flatpak](#flatpak) - [Why did I create tidal-hifi?](#why-did-i-create-tidal-hifi)
* [Nix](#nix) - [Why not extend existing projects?](#why-not-extend-existing-projects)
* [Using source](#using-source) - [Installation](#installation)
- [Features](#features) - [Dependencies](#dependencies)
- [Integrations](#integrations) - [Using releases](#using-releases)
* [Known bugs](#known-bugs) - [Snap](#snap)
+ [last.fm doesn't work out of the box. Use rescrobbler as a workaround](#lastfm-doesnt-work-out-of-the-box-use-rescrobbler-as-a-workaround) - [Arch Linux](#arch-linux)
- [Why](#why) - [Flatpak](#flatpak)
- [Why not extend existing projects?](#why-not-extend-existing-projects) - [Nix](#nix)
- [Special thanks to...](#special-thanks-to) - [Using source](#using-source)
- [Buy me a coffee? Please don't](#buy-me-a-coffee-please-dont) - [Integrations](#integrations)
- [Images](#images) - [Known bugs](#known-bugs)
* [Settings window](#settings-window) - [last.fm doesn't work out of the box. Use rescrobbler as a workaround](#lastfm-doesnt-work-out-of-the-box-use-rescrobbler-as-a-workaround)
* [User setups](#user-setups) - [Special thanks to](#special-thanks-to)
- [Donations](#donations)
- [Images](#images)
- [Settings window](#settings-window)
- [User setups](#user-setups)
<!-- tocstop --> <!-- tocstop -->
## Features
- HiFi playback
- Notifications
- Custom [theming](./docs/theming.md)
- Custom hotkeys ([source](https://defkey.com/tidal-desktop-shortcuts))
- API for status and playback
- Disabled audio & visual ads, unlocked lyrics, suggested track, track info, and unlimited skips thanks to uBlockOrigin custom filters ([source](https://github.com/uBlockOrigin/uAssets/issues/17495))
- Custom [integrations](#integrations)
- [Settings feature](./docs/images/settings.png) to disable certain functionality. (`ctrl+=` or `ctrl+0`)
- AlbumArt in integrations ([best-effort](https://github.com/Mastermindzh/tidal-hifi/pull/88#pullrequestreview-840814847))
## Contributions
To contribute you can use the standard GitHub features (issues, prs, etc) or join the discord server to talk with like-minded individuals.
- ![Discord logo](./docs/images/discord.png) [Join the Discord server](https://discord.gg/yhNwf4v4He)
## Why did I create tidal-hifi?
I moved from Spotify over to Tidal and found Linux support to be lacking.
When I started this project there weren't any Linux apps that offered Tidal's "hifi" options nor any scripts to control it.
### Why not extend existing projects?
Whilst there are a handful of projects attempting to run Tidal on Electron they are all unappealing to me because of various reasons:
- Lack of maintainers/developers. (no hotfixes, no issues being handled etc)
- Most are simple web wrappers, not my cup of tea.
- Some are DE oriented. I want this to work on WM's too.
- None have widevine working at the moment
Sometimes it's just easier to start over, cover my own needs and then making it available to the public :)
## Installation ## Installation
### Dependencies
Note that you **need** a notification library such as [libnotify](https://github.com/GNOME/libnotify) or [dunst](https://github.com/dunst-project/dunst) for the software to work properly.
### Using releases ### Using releases
Various packaged versions of the software are available on the [releases](https://github.com/Mastermindzh/tidal-hifi/releases) tab. Various packaged versions of the software are available on the [releases](https://github.com/Mastermindzh/tidal-hifi/releases) tab.
@ -44,15 +86,15 @@ To install with `snap` you need to download the pre-packaged snap-package from t
1. Download 1. Download
```sh ```sh
wget <URI> #for instance: https://github.com/Mastermindzh/tidal-hifi/releases/download/1.0/tidal-hifi_1.0.0_amd64.snap wget <URI> #for instance: https://github.com/Mastermindzh/tidal-hifi/releases/download/1.0/tidal-hifi_1.0.0_amd64.snap
``` ```
2. Install 2. Install
```sh ```sh
snap install --dangerous <path> #for instance: tidal-hifi_1.0.0_amd64.snap snap install --dangerous <path> #for instance: tidal-hifi_1.0.0_amd64.snap
``` ```
### Arch Linux ### Arch Linux
@ -87,23 +129,12 @@ To install and work with the code on this project follow these steps:
- npm install - npm install
- npm start - npm start
## Features
- HiFi playback
- Notifications
- Custom hotkeys ([source](https://defkey.com/tidal-desktop-shortcuts))
- API for status and playback
- [Mute artists automatically (defaults to "Tidal")]("./docs/muting-artists.md")
- Custom [integrations](#integrations)
- [Settings feature](./docs/settings.png) to disable certain functionality. (`ctrl+=` or `ctrl+0`)
- AlbumArt in integrations ([best-effort](https://github.com/Mastermindzh/tidal-hifi/pull/88#pullrequestreview-840814847))
## Integrations ## Integrations
Tidal-hifi comes with several integrations out of the box. Tidal-hifi comes with several integrations out of the box.
You can find these in the settings menu (`ctrl + =` by default) under the "integrations" tab. You can find these in the settings menu (`ctrl + =` by default) under the "integrations" tab.
![integrations menu, showing a list of integrations](./docs/integrations.png) ![integrations menu, showing a list of integrations](./docs/images/integrations.png)
It currently includes: It currently includes:
@ -122,38 +153,20 @@ The last.fm login doesn't work, as is evident from the following issue: [Last.fm
However, in that same issue you can read about a workaround using [rescrobbler](https://github.com/InputUsername/rescrobbled). However, in that same issue you can read about a workaround using [rescrobbler](https://github.com/InputUsername/rescrobbled).
For now that will be the default workaround. For now that will be the default workaround.
## Why ## Special thanks to
I moved from Spotify over to Tidal and found Linux support to be lacking.
When I started this project there weren't any Linux apps that offered Tidal's "hifi" options nor any scripts to control it.
## Why not extend existing projects?
Whilst there are a handful of projects attempting to run Tidal on Electron they are all unappealing to me because of various reasons:
- Lack of a maintainers/developers. (no hotfixes, no issues being handled etc)
- Most are simple web wrappers, not my cup of tea.
- Some are DE oriented. I want this to work on WM's too.
- None have widevine working at the moment
Sometimes it's just easier to start over, cover my own needs and then making it available to the public :)
## Special thanks to...
- [Castlabs](https://castlabs.com/) - [Castlabs](https://castlabs.com/)
For maintaining Electron with Widevine CDM installation, Verified Media Path (VMP), and persistent licenses (StorageID) For maintaining Electron with Widevine CDM installation, Verified Media Path (VMP), and persistent licenses (StorageID)
## Buy me a coffee? Please don't ## Donations
Instead spend some money on a charity I care for: [kwf.nl](https://www.kwf.nl/donatie/donation). You can find my Github sponsorship page at: [https://github.com/sponsors/Mastermindzh](https://github.com/sponsors/Mastermindzh)
Inspired by [haydenjames' issue](https://github.com/Mastermindzh/tidal-hifi/issues/27#issuecomment-704198429)
## Images ## Images
### Settings window ### Settings window
![settings window](./docs/settings-preview.png) ![settings window](./docs/images/settings-preview.png)
### User setups ### User setups

BIN
assets/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
assets/icons/22x22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
assets/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
assets/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
assets/icons/384x384.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,15 +1,17 @@
appId: com.rickvanlieshout.tidal-hifi appId: com.rickvanlieshout.tidal-hifi
electronVersion: 19.0.5 electronVersion: 24.1.2
electronDownload: electronDownload:
version: 19.0.5+wvcus version: 24.1.2+wvcus
mirror: https://github.com/castlabs/electron-releases/releases/download/v mirror: https://github.com/castlabs/electron-releases/releases/download/v
snap: snap:
plugs: plugs:
- default - default
- screen-inhibit-control - screen-inhibit-control
extraResources:
- "themes/**"
linux: linux:
category: AudioVideo category: AudioVideo
icon: icon.png icon: assets/icons
target: target:
- dir - dir
executableName: tidal-hifi executableName: tidal-hifi
@ -18,7 +20,7 @@ linux:
Name: TIDAL Hi-Fi Name: TIDAL Hi-Fi
GenericName: TIDAL Hi-Fi GenericName: TIDAL Hi-Fi
Comment: The web version of listen.tidal.com running in electron with hifi support thanks to widevine. Comment: The web version of listen.tidal.com running in electron with hifi support thanks to widevine.
Icon: icon.png Icon: tidal-hifi
StartupNotify: true StartupNotify: true
Terminal: false Terminal: false
Type: Application Type: Application

View File

@ -1,5 +1,4 @@
extends: ./build/electron-builder.base.yml extends: ./build/electron-builder.base.yml
linux: linux:
icon: icon.png
target: target:
- pacman - pacman

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
docs/images/customcss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/images/discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 800 KiB

View File

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 726 KiB

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

BIN
docs/images/theming.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -1,11 +0,0 @@
# Muting artists
If you feel that some of your music is embarrassing for others you can mute specific artists in the settings window.
This functionality is inspired by the [adblock ticket](https://github.com/Mastermindzh/tidal-hifi/issues/112), and whilst I personally feel you should simply buy Tidal, I also believe in muting sound that you don't want to hear.
Anyway, to block an artist, open the settings window (see image below) and enter a list of artists in the textarea as seen below.
Don't forget to turn the feature on and Tidal-hifi will automatically mute the player whenever that artist is playing.
This will allow you to skip the song without anyone noticing. (you can always say "no idea, it seems to have no audio").
![muted artists settings window](./settings-muted-artists.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

38
docs/theming.md Normal file
View File

@ -0,0 +1,38 @@
# Theming tidal-hifi
## Table of contents
<!-- toc -->
- [Theming tidal-hifi](#theming-tidal-hifi)
- [Table of contents](#table-of-contents)
- [Custom CSS](#custom-css)
- [config](#config)
- [Warning! Themes might break](#warning-themes-might-break)
<!-- tocstop -->
By default tidal-hifi comes with a few themes.
You can select these in the settings window under the theming tab as shown below.
![Settings window with the theming tab opened](./images/theming.png)
## Custom CSS
The custom CSS will be added to the HTML document last.
This means that it will overwrite any existing CSS, even that of themes, unless the original has an access modifier such as `$important`.
![settings window on the theming tab with a custom CSS override](./images/customcss.png)
## config
The theme selector and customCSS are stored in the config file.
The custom CSS is stored as a list of lines.
![settings window on the theming tab next to the config file](./images/customcss-config.png)
## Warning! Themes might break
Themes might break at any point. Tidal changes their webpage structure a ton (they probably generate classNames and don't provide roles/ids/attributes.)
If one breaks you can create an Issue on GitHub or ask for assistance in the [Discord channel](https://discord.gg/yhNwf4v4He).

11422
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,15 @@
{ {
"name": "tidal-hifi", "name": "tidal-hifi",
"version": "4.4.0", "version": "5.2.0",
"description": "Tidal on Electron with widevine(hifi) support", "description": "Tidal on Electron with widevine(hifi) support",
"main": "src/main.js", "main": "ts-dist/main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"compile": "tsc && npm run sass-and-copy",
"watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist",
"copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev",
"build": "npm run builder -- -c ./build/electron-builder.yml", "build": "npm run builder -- -c ./build/electron-builder.yml",
"build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml", "build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml",
"build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml", "build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml",
@ -14,12 +19,11 @@
"build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl", "build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl",
"build-mac": "npm run builder -- -c ./build/electron-builder.yml -m", "build-mac": "npm run builder -- -c ./build/electron-builder.yml -m",
"build-base": "npm run builder -- -c ./build/electron-builder.base.yml", "build-base": "npm run builder -- -c ./build/electron-builder.base.yml",
"prestart": "npm run sass", "prebuilder": "npm run compile",
"prebuilder": "npm run sass",
"builder": "electron-builder --publish=never", "builder": "electron-builder --publish=never",
"sass": "sass ./src/pages/settings/settings.scss ./src/pages/settings/settings.css", "sass": "sass ./src/pages/settings/settings.scss ./src/pages/settings/settings.css && sass --no-source-map src/themes:themes",
"sass-lint": "sass-lint -vc ./sass-lint.yml ./src/pages/settings/settings.scss", "style-lint": "npx stylelint **/*.scss",
"sass-lint-fix": "sass-lint-auto-fix ./src/pages/settings/settings.scss --config-sass-lint ./sass-lint.yml" "style-lint-fix": "npx stylelint --fix **/*.scss"
}, },
"keywords": [ "keywords": [
"electron", "electron",
@ -31,23 +35,35 @@
"homepage": "https://github.com/Mastermindzh/tidal-hifi", "homepage": "https://github.com/Mastermindzh/tidal-hifi",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron/remote": "^2.0.8", "@electron/remote": "^2.0.9",
"discord-rpc": "^4.0.1", "discord-rpc": "^4.0.1",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"express": "^4.18.1", "express": "^4.18.2",
"hotkeys-js": "^3.9.4", "hotkeys-js": "^3.10.2",
"mpris-service": "^2.1.2", "mpris-service": "^2.1.2",
"request": "^2.88.2", "request": "^2.88.2",
"sass": "^1.54.9" "sass": "^1.62.0"
}, },
"devDependencies": { "devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0", "@mastermindzh/prettier-config": "^1.0.0",
"electron": "git+https://github.com/castlabs/electron-releases.git#v19.0.5+wvcus", "@types/discord-rpc": "^4.0.4",
"electron-builder": "^23.3.3", "@types/express": "^4.17.17",
"js-yaml": "^3.14.1", "@types/request": "^2.48.8",
"prettier": "^2.7.1", "@typescript-eslint/eslint-plugin": "^5.59.1",
"sass-lint": "^1.13.1", "@typescript-eslint/parser": "^5.59.1",
"sass-lint-auto-fix": "^0.21.2" "copyfiles": "^2.4.1",
"electron": "git+https://github.com/castlabs/electron-releases.git#v24.1.2+wvcus",
"electron-builder": "^24.2.1",
"eslint": "^8.39.0",
"js-yaml": "^4.1.0",
"markdown-toc": "^1.2.0",
"prettier": "^2.8.8",
"stylelint": "^15.6.0",
"stylelint-config-standard": "^33.0.0",
"stylelint-config-standard-scss": "^9.0.0",
"stylelint-prettier": "^3.0.0",
"tsc-watch": "^6.0.4",
"typescript": "^5.0.4"
}, },
"prettier": "@mastermindzh/prettier-config" "prettier": "@mastermindzh/prettier-config"
} }

View File

@ -1,21 +0,0 @@
rules:
property-sort-order:
- 1
- order: "smacss"
class-name-format:
- 1
- convention: "hyphenatedbem"
quotes:
- 1
- style: "double"
nesting-depth:
- 1
- max-depth: 3
placeholder-in-extend:
- 0
no-vendor-prefixes:
- 0
empty-line-between-blocks:
- 0
force-pseudo-nesting:
- 0

View File

@ -1,6 +1,4 @@
const flags = { export const flags: { [key: string]: { flag: string; value?: string }[] } = {
gpuRasterization: [{ flag: "enable-gpu-rasterization", value: undefined }], gpuRasterization: [{ flag: "enable-gpu-rasterization", value: undefined }],
disableHardwareMediaKeys: [{ flag: "disable-features", value: "HardwareMediaKeyHandling" }], disableHardwareMediaKeys: [{ flag: "disable-features", value: "HardwareMediaKeyHandling" }],
}; };
module.exports = flags;

View File

@ -1,4 +1,4 @@
const globalEvents = { export const globalEvents = {
play: "play", play: "play",
pause: "pause", pause: "pause",
playPause: "playPause", playPause: "playPause",
@ -11,5 +11,3 @@ const globalEvents = {
storeChanged: "storeChanged", storeChanged: "storeChanged",
error: "error", error: "error",
}; };
module.exports = globalEvents;

View File

@ -1,9 +1,7 @@
const globalEvents = require("./globalEvents"); import { globalEvents } from "./globalEvents";
const mediaKeys = { export const mediaKeys = {
MediaPlayPause: globalEvents.playPause, MediaPlayPause: globalEvents.playPause,
MediaNextTrack: globalEvents.next, MediaNextTrack: globalEvents.next,
MediaPreviousTrack: globalEvents.previous, MediaPreviousTrack: globalEvents.previous,
}; };
module.exports = mediaKeys;

View File

@ -8,36 +8,37 @@
* }, * },
* windowBounds: { width: 800, height: 600 }, * windowBounds: { width: 800, height: 600 },
*/ */
const settings = { export const settings = {
notifications: "notifications", adBlock: "adBlock",
api: "api", api: "api",
menuBar: "menuBar",
playBackControl: "playBackControl",
muteArtists: "muteArtists",
mutedArtists: "mutedArtists",
skipArtists: "skipArtists",
skippedArtists: "skippedArtists",
disableBackgroundThrottle: "disableBackgroundThrottle",
apiSettings: { apiSettings: {
root: "apiSettings", root: "apiSettings",
port: "apiSettings.port", port: "apiSettings.port",
}, },
singleInstance: "singleInstance", customCSS: "customCSS",
disableBackgroundThrottle: "disableBackgroundThrottle",
disableHardwareMediaKeys: "disableHardwareMediaKeys", disableHardwareMediaKeys: "disableHardwareMediaKeys",
enableCustomHotkeys: "enableCustomHotkeys",
enableDiscord: "enableDiscord",
flags: { flags: {
root: "flags",
disableHardwareMediaKeys: "flags.disableHardwareMediaKeys", disableHardwareMediaKeys: "flags.disableHardwareMediaKeys",
gpuRasterization: "flags.gpuRasterization", gpuRasterization: "flags.gpuRasterization",
}, },
menuBar: "menuBar",
minimizeOnClose: "minimizeOnClose",
mpris: "mpris", mpris: "mpris",
enableCustomHotkeys: "enableCustomHotkeys", notifications: "notifications",
playBackControl: "playBackControl",
singleInstance: "singleInstance",
skipArtists: "skipArtists",
skippedArtists: "skippedArtists",
theme: "theme",
trayIcon: "trayIcon", trayIcon: "trayIcon",
enableDiscord: "enableDiscord", updateFrequency: "updateFrequency",
windowBounds: { windowBounds: {
root: "windowBounds", root: "windowBounds",
width: "windowBounds.width", width: "windowBounds.width",
height: "windowBounds.height", height: "windowBounds.height",
}, },
minimizeOnClose: "minimizeOnClose",
}; };
module.exports = settings;

View File

@ -1,4 +1,4 @@
module.exports = { export const statuses = {
playing: "playing", playing: "playing",
paused: "paused", paused: "paused",
}; };

View File

@ -1,3 +1,3 @@
module.exports = { export default {
name: "tidal-hifi", name: "tidal-hifi",
}; };

1
src/declarations.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "mpris-service";

View File

@ -1,36 +1,47 @@
require("@electron/remote/main").initialize(); import { enable, initialize } from "@electron/remote/main";
const { app, BrowserWindow, components, globalShortcut, ipcMain, protocol } = require("electron"); import {
const { BrowserWindow,
settings, app,
store, components,
createSettingsWindow, globalShortcut,
showSettingsWindow, ipcMain,
protocol,
session,
} from "electron";
import path from "path";
import { flags } from "./constants/flags";
import { globalEvents } from "./constants/globalEvents";
import { mediaKeys } from "./constants/mediaKeys";
import { initRPC, rpc, unRPC } from "./scripts/discord";
import { startExpress } from "./scripts/express";
import { updateMediaInfo } from "./scripts/mediaInfo";
import { addMenu } from "./scripts/menu";
import {
closeSettingsWindow, closeSettingsWindow,
createSettingsWindow,
hideSettingsWindow, hideSettingsWindow,
} = require("./scripts/settings"); showSettingsWindow,
const { addTray, refreshTray } = require("./scripts/tray"); settingsStore,
const { addMenu } = require("./scripts/menu"); } from "./scripts/settings";
const path = require("path"); import { settings } from "./constants/settings";
import { addTray, refreshTray } from "./scripts/tray";
import { MediaInfo } from "./models/mediaInfo";
const tidalUrl = "https://listen.tidal.com"; const tidalUrl = "https://listen.tidal.com";
const expressModule = require("./scripts/express");
const mediaKeys = require("./constants/mediaKeys");
const mediaInfoModule = require("./scripts/mediaInfo");
const discordModule = require("./scripts/discord");
const globalEvents = require("./constants/globalEvents");
const flagValues = require("./constants/flags");
let mainWindow; initialize();
let icon = path.join(__dirname, "../assets/icon.png");
let mainWindow: BrowserWindow;
const icon = path.join(__dirname, "../assets/icon.png");
const PROTOCOL_PREFIX = "tidal"; const PROTOCOL_PREFIX = "tidal";
setFlags(); setFlags();
function setFlags() { function setFlags() {
const flags = store.get().flags; const flagsFromSettings = settingsStore.get(settings.flags.root);
if (flags) { if (flagsFromSettings) {
for (const [key, value] of Object.entries(flags)) { for (const [key, value] of Object.entries(flags)) {
if (value) { if (value) {
flagValues[key].forEach((flag) => { flags[key].forEach((flag) => {
console.log(`enabling command line switch ${flag.flag} with value ${flag.value}`); console.log(`enabling command line switch ${flag.flag} with value ${flag.value}`);
app.commandLine.appendSwitch(flag.flag, flag.value); app.commandLine.appendSwitch(flag.flag, flag.value);
}); });
@ -49,7 +60,7 @@ function setFlags() {
* *
*/ */
function syncMenuBarWithStore() { function syncMenuBarWithStore() {
const fixedMenuBar = store.get(settings.menuBar); const fixedMenuBar = !!settingsStore.get(settings.menuBar);
mainWindow.autoHideMenuBar = !fixedMenuBar; mainWindow.autoHideMenuBar = !fixedMenuBar;
mainWindow.setMenuBarVisibility(fixedMenuBar); mainWindow.setMenuBarVisibility(fixedMenuBar);
@ -62,7 +73,7 @@ function syncMenuBarWithStore() {
* @returns true if singInstance is not requested, otherwise true/false based on whether the current window is the main window * @returns true if singInstance is not requested, otherwise true/false based on whether the current window is the main window
*/ */
function isMainInstanceOrMultipleInstancesAllowed() { function isMainInstanceOrMultipleInstancesAllowed() {
if (store.get(settings.singleInstance)) { if (settingsStore.get(settings.singleInstance)) {
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
@ -72,39 +83,37 @@ function isMainInstanceOrMultipleInstancesAllowed() {
return true; return true;
} }
function createWindow(options = {}) { function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
// Create the browser window. // Create the browser window.
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
x: options.x, x: options.x,
y: options.y, y: options.y,
width: store && store.get(settings.windowBounds.width), width: settingsStore && settingsStore.get(settings.windowBounds.width),
height: store && store.get(settings.windowBounds.height), height: settingsStore && settingsStore.get(settings.windowBounds.height),
icon, icon,
backgroundColor: options.backgroundColor, backgroundColor: options.backgroundColor,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
sandbox: false,
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
plugins: true, plugins: true,
devTools: true, // I like tinkering, others might too devTools: true, // I like tinkering, others might too
}, },
}); });
require("@electron/remote/main").enable(mainWindow.webContents); enable(mainWindow.webContents);
registerHttpProtocols(); registerHttpProtocols();
syncMenuBarWithStore(); syncMenuBarWithStore();
// load the Tidal website // load the Tidal website
mainWindow.loadURL(tidalUrl); mainWindow.loadURL(tidalUrl);
if (store.get(settings.disableBackgroundThrottle)) { if (settingsStore.get(settings.disableBackgroundThrottle)) {
// prevent setInterval lag // prevent setInterval lag
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
} }
// run stuff after first load mainWindow.on("close", function (event: CloseEvent) {
mainWindow.webContents.once("did-finish-load", () => {}); if (settingsStore.get(settings.minimizeOnClose)) {
mainWindow.on("close", function (event) {
if (!app.isQuiting && store.get(settings.minimizeOnClose)) {
event.preventDefault(); event.preventDefault();
mainWindow.hide(); mainWindow.hide();
refreshTray(mainWindow); refreshTray(mainWindow);
@ -117,14 +126,13 @@ function createWindow(options = {}) {
app.quit(); app.quit();
}); });
mainWindow.on("resize", () => { mainWindow.on("resize", () => {
let { width, height } = mainWindow.getBounds(); const { width, height } = mainWindow.getBounds();
settingsStore.set(settings.windowBounds.root, { width, height });
store.set(settings.windowBounds.root, { width, height });
}); });
} }
function registerHttpProtocols() { function registerHttpProtocols() {
protocol.registerHttpProtocol(PROTOCOL_PREFIX, (request, _callback) => { protocol.registerHttpProtocol(PROTOCOL_PREFIX, (request) => {
mainWindow.loadURL(`${tidalUrl}/${request.url.substring(PROTOCOL_PREFIX.length + 3)}`); mainWindow.loadURL(`${tidalUrl}/${request.url.substring(PROTOCOL_PREFIX.length + 3)}`);
}); });
if (!app.isDefaultProtocolClient(PROTOCOL_PREFIX)) { if (!app.isDefaultProtocolClient(PROTOCOL_PREFIX)) {
@ -135,7 +143,7 @@ function registerHttpProtocols() {
function addGlobalShortcuts() { function addGlobalShortcuts() {
Object.keys(mediaKeys).forEach((key) => { Object.keys(mediaKeys).forEach((key) => {
globalShortcut.register(`${key}`, () => { globalShortcut.register(`${key}`, () => {
mainWindow.webContents.send("globalEvent", `${mediaKeys[key]}`); mainWindow.webContents.send("globalEvent", `${(mediaKeys as any)[key]}`);
}); });
}); });
} }
@ -146,14 +154,26 @@ function addGlobalShortcuts() {
app.on("ready", async () => { app.on("ready", async () => {
if (isMainInstanceOrMultipleInstancesAllowed()) { if (isMainInstanceOrMultipleInstancesAllowed()) {
await components.whenReady(); await components.whenReady();
// Adblock
if (settingsStore.get(settings.adBlock)) {
const filter = { urls: ["https://listen.tidal.com/*"] };
session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => {
if (details.url.match(/\/users\/.*\d\?country/)) callback({ cancel: true });
else callback({ cancel: false });
});
}
createWindow(); createWindow();
addMenu(mainWindow); addMenu(mainWindow);
createSettingsWindow(); createSettingsWindow();
addGlobalShortcuts(); addGlobalShortcuts();
store.get(settings.trayIcon) && addTray(mainWindow, { icon }) && refreshTray(); if (settingsStore.get(settings.trayIcon)) {
store.get(settings.api) && expressModule.run(mainWindow); addTray(mainWindow, { icon });
store.get(settings.enableDiscord) && discordModule.initRPC(); refreshTray(mainWindow);
// mainWindow.webContents.openDevTools(); }
settingsStore.get(settings.api) && startExpress(mainWindow);
settingsStore.get(settings.enableDiscord) && initRPC();
} else { } else {
app.quit(); app.quit();
} }
@ -168,35 +188,35 @@ app.on("activate", function () {
}); });
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
require("@electron/remote/main").enable(window.webContents); enable(window.webContents);
}); });
// IPC // IPC
ipcMain.on(globalEvents.updateInfo, (_event, arg) => { ipcMain.on(globalEvents.updateInfo, (_event, arg: MediaInfo) => {
mediaInfoModule.update(arg); updateMediaInfo(arg);
}); });
ipcMain.on(globalEvents.hideSettings, (_event, _arg) => { ipcMain.on(globalEvents.hideSettings, () => {
hideSettingsWindow(); hideSettingsWindow();
}); });
ipcMain.on(globalEvents.showSettings, (_event, _arg) => { ipcMain.on(globalEvents.showSettings, () => {
showSettingsWindow(); showSettingsWindow();
}); });
ipcMain.on(globalEvents.refreshMenuBar, (_event, _arg) => { ipcMain.on(globalEvents.refreshMenuBar, () => {
syncMenuBarWithStore(); syncMenuBarWithStore();
}); });
ipcMain.on(globalEvents.storeChanged, (_event, _arg) => { ipcMain.on(globalEvents.storeChanged, () => {
syncMenuBarWithStore(); syncMenuBarWithStore();
if (store.get(settings.enableDiscord) && !discordModule.rpc) { if (settingsStore.get(settings.enableDiscord) && !rpc) {
discordModule.initRPC(); initRPC();
} else if (!store.get(settings.enableDiscord) && discordModule.rpc) { } else if (!settingsStore.get(settings.enableDiscord) && rpc) {
discordModule.unRPC(); unRPC();
} }
}); });
ipcMain.on(globalEvents.error, (event, _arg) => { ipcMain.on(globalEvents.error, (event) => {
console.log(event); console.log(event);
}); });

13
src/models/mediaInfo.ts Normal file
View File

@ -0,0 +1,13 @@
import { MediaStatus } from "./mediaStatus";
export interface MediaInfo {
title: string;
artists: string;
album: string;
icon: string;
status: MediaStatus;
url: string;
current: string;
duration: string;
image: string;
}

View File

@ -0,0 +1,4 @@
export enum MediaStatus {
playing = "playing",
paused = "paused",
}

12
src/models/options.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Options {
title: string;
artists: string;
album: string;
status: string;
url: string;
current: string;
duration: string;
"app-name": string;
image: string;
icon: string;
}

View File

@ -1,153 +0,0 @@
let trayIcon,
minimizeOnClose,
mpris,
enableCustomHotkeys,
enableDiscord,
muteArtists,
skipArtists,
notifications,
playBackControl,
api,
port,
menuBar,
mutedArtists,
skippedArtists,
disableBackgroundThrottle,
singleInstance,
disableHardwareMediaKeys,
gpuRasterization;
const { store, settings } = require("./../../scripts/settings");
const { ipcRenderer } = require("electron");
const globalEvents = require("./../../constants/globalEvents");
const remote = require("@electron/remote");
const { app } = remote;
/**
* Sync the UI forms with the current settings
*/
function refreshSettings() {
notifications.checked = store.get(settings.notifications);
playBackControl.checked = store.get(settings.playBackControl);
api.checked = store.get(settings.api);
port.value = store.get(settings.apiSettings.port);
menuBar.checked = store.get(settings.menuBar);
trayIcon.checked = store.get(settings.trayIcon);
mpris.checked = store.get(settings.mpris);
enableCustomHotkeys.checked = store.get(settings.enableCustomHotkeys);
enableDiscord.checked = store.get(settings.enableDiscord);
minimizeOnClose.checked = store.get(settings.minimizeOnClose);
muteArtists.checked = store.get(settings.muteArtists);
mutedArtists.value = store.get(settings.mutedArtists).join("\n");
skipArtists.checked = store.get(settings.skipArtists);
skippedArtists.value = store.get(settings.skippedArtists).join("\n");
singleInstance.checked = store.get(settings.singleInstance);
disableHardwareMediaKeys.checked = store.get(settings.flags.disableHardwareMediaKeys);
gpuRasterization.checked = store.get(settings.flags.gpuRasterization);
disableBackgroundThrottle.checked = store.get("disableBackgroundThrottle");
}
/**
* Open an url in the default browsers
*/
function openExternal(url) {
const { shell } = require("electron");
shell.openExternal(url);
}
/**
* hide the settings window
*/
function hide() {
ipcRenderer.send(globalEvents.hideSettings);
}
/**
* Restart tidal-hifi after changes
*/
function restart() {
app.relaunch();
app.quit();
}
/**
* Bind UI components to functions after DOMContentLoaded
*/
window.addEventListener("DOMContentLoaded", () => {
function get(id) {
return document.getElementById(id);
}
document.getElementById("close").addEventListener("click", hide);
document.getElementById("restart").addEventListener("click", restart);
document.querySelectorAll(".external-link").forEach((elem) =>
elem.addEventListener("click", function (event) {
openExternal(event.target.getAttribute("data-url"));
})
);
function addInputListener(source, key) {
source.addEventListener("input", function (_event, _data) {
if (this.value === "on") {
store.set(key, source.checked);
} else {
store.set(key, this.value);
}
ipcRenderer.send(globalEvents.storeChanged);
});
}
function addTextAreaListener(source, key) {
source.addEventListener("input", function (_event, _data) {
store.set(key, source.value.split("\n"));
ipcRenderer.send(globalEvents.storeChanged);
});
}
ipcRenderer.on("refreshData", () => {
refreshSettings();
});
ipcRenderer.on("goToTab", (_, tab) => {
document.getElementById(tab).click();
});
notifications = get("notifications");
playBackControl = get("playBackControl");
api = get("apiCheckbox");
port = get("port");
menuBar = get("menuBar");
trayIcon = get("trayIcon");
minimizeOnClose = get("minimizeOnClose");
mpris = get("mprisCheckbox");
enableCustomHotkeys = get("enableCustomHotkeys");
enableDiscord = get("enableDiscord");
muteArtists = get("muteArtists");
mutedArtists = get("mutedArtists");
skipArtists = get("skipArtists");
skippedArtists = get("skippedArtists");
disableBackgroundThrottle = get("disableBackgroundThrottle");
singleInstance = get("singleInstance");
disableHardwareMediaKeys = get("disableHardwareMediaKeys");
gpuRasterization = get("gpuRasterization");
refreshSettings();
addInputListener(notifications, settings.notifications);
addInputListener(playBackControl, settings.playBackControl);
addInputListener(api, settings.api);
addInputListener(port, settings.apiSettings.port);
addInputListener(menuBar, settings.menuBar);
addInputListener(trayIcon, settings.trayIcon);
addInputListener(mpris, settings.mpris);
addInputListener(enableCustomHotkeys, settings.enableCustomHotkeys);
addInputListener(enableDiscord, settings.enableDiscord);
addInputListener(minimizeOnClose, settings.minimizeOnClose);
addInputListener(muteArtists, settings.muteArtists);
addTextAreaListener(mutedArtists, settings.mutedArtists);
addInputListener(skipArtists, settings.skipArtists);
addTextAreaListener(skippedArtists, settings.skippedArtists);
addInputListener(disableBackgroundThrottle, settings.disableBackgroundThrottle);
addInputListener(singleInstance, settings.singleInstance);
addInputListener(disableHardwareMediaKeys, settings.flags.disableHardwareMediaKeys);
addInputListener(gpuRasterization, settings.flags.gpuRasterization);
});

View File

@ -0,0 +1,209 @@
import remote, { app } from "@electron/remote";
import { ipcRenderer, shell } from "electron";
import fs from "fs";
import { globalEvents } from "../../constants/globalEvents";
import { settings } from "../../constants/settings";
import { settingsStore } from "./../../scripts/settings";
import { getOptions, getOptionsHeader, getThemeListFromDirectory } from "./theming";
let adBlock: HTMLInputElement,
api: HTMLInputElement,
customCSS: HTMLInputElement,
disableBackgroundThrottle: HTMLInputElement,
disableHardwareMediaKeys: HTMLInputElement,
enableCustomHotkeys: HTMLInputElement,
enableDiscord: HTMLInputElement,
gpuRasterization: HTMLInputElement,
menuBar: HTMLInputElement,
minimizeOnClose: HTMLInputElement,
mpris: HTMLInputElement,
notifications: HTMLInputElement,
playBackControl: HTMLInputElement,
port: HTMLInputElement,
singleInstance: HTMLInputElement,
skipArtists: HTMLInputElement,
skippedArtists: HTMLInputElement,
theme: HTMLSelectElement,
trayIcon: HTMLInputElement,
updateFrequency: HTMLInputElement;
function getThemeFiles() {
const selectElement = document.getElementById("themesList") as HTMLSelectElement;
const builtInThemes = getThemeListFromDirectory(process.resourcesPath);
const userThemes = getThemeListFromDirectory(`${app.getPath("userData")}/themes`);
let allThemes = [
getOptionsHeader("Built-in Themes"),
new Option("Tidal - Default", "none"),
].concat(getOptions(builtInThemes));
if (userThemes.length >= 1) {
allThemes = allThemes.concat([getOptionsHeader("User Themes")]).concat(getOptions(userThemes));
}
// empty old options
const oldOptions = document.querySelectorAll("#themesList option");
oldOptions.forEach((o) => o.remove());
allThemes.forEach((option) => {
selectElement.add(option, null);
});
}
function handleFileUploads() {
const fileMessage = document.getElementById("file-message");
fileMessage.innerText = "or drag and drop files here";
document.getElementById("theme-files").addEventListener("change", function (e: any) {
Array.from(e.target.files).forEach((file: File) => {
const destination = `${app.getPath("userData")}/themes/${file.name}`;
fs.copyFileSync(file.path, destination, null);
});
fileMessage.innerText = `${e.target.files.length} files successfully uploaded`;
getThemeFiles();
});
}
/**
* Sync the UI forms with the current settings
*/
function refreshSettings() {
adBlock.checked = settingsStore.get(settings.adBlock);
api.checked = settingsStore.get(settings.api);
customCSS.value = settingsStore.get<string, string[]>(settings.customCSS).join("\n");
disableBackgroundThrottle.checked = settingsStore.get(settings.disableBackgroundThrottle);
disableHardwareMediaKeys.checked = settingsStore.get(settings.flags.disableHardwareMediaKeys);
enableCustomHotkeys.checked = settingsStore.get(settings.enableCustomHotkeys);
enableDiscord.checked = settingsStore.get(settings.enableDiscord);
gpuRasterization.checked = settingsStore.get(settings.flags.gpuRasterization);
menuBar.checked = settingsStore.get(settings.menuBar);
minimizeOnClose.checked = settingsStore.get(settings.minimizeOnClose);
mpris.checked = settingsStore.get(settings.mpris);
notifications.checked = settingsStore.get(settings.notifications);
playBackControl.checked = settingsStore.get(settings.playBackControl);
port.value = settingsStore.get(settings.apiSettings.port);
singleInstance.checked = settingsStore.get(settings.singleInstance);
skipArtists.checked = settingsStore.get(settings.skipArtists);
theme.value = settingsStore.get(settings.theme);
skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
trayIcon.checked = settingsStore.get(settings.trayIcon);
updateFrequency.value = settingsStore.get(settings.updateFrequency);
}
/**
* Open an url in the default browsers
*/
function openExternal(url: string) {
shell.openExternal(url);
}
/**
* hide the settings window
*/
function hide() {
ipcRenderer.send(globalEvents.hideSettings);
}
/**
* Restart tidal-hifi after changes
*/
function restart() {
remote.app.relaunch();
remote.app.exit();
}
/**
* Bind UI components to functions after DOMContentLoaded
*/
window.addEventListener("DOMContentLoaded", () => {
function get<T = HTMLInputElement>(id: string): T {
return document.getElementById(id) as T;
}
getThemeFiles();
handleFileUploads();
document.getElementById("close").addEventListener("click", hide);
document.getElementById("restart").addEventListener("click", restart);
document.querySelectorAll(".external-link").forEach((elem) =>
elem.addEventListener("click", function (event) {
openExternal((event.target as HTMLElement).getAttribute("data-url"));
})
);
function addInputListener(source: HTMLInputElement, key: string) {
source.addEventListener("input", () => {
if (source.value === "on") {
settingsStore.set(key, source.checked);
} else {
settingsStore.set(key, source.value);
}
ipcRenderer.send(globalEvents.storeChanged);
});
}
function addTextAreaListener(source: HTMLInputElement, key: string) {
source.addEventListener("input", () => {
settingsStore.set(key, source.value.split("\n"));
ipcRenderer.send(globalEvents.storeChanged);
});
}
function addSelectListener(source: HTMLSelectElement, key: string) {
source.addEventListener("change", () => {
settingsStore.set(key, source.value);
ipcRenderer.send(globalEvents.storeChanged);
});
}
ipcRenderer.on("refreshData", () => {
refreshSettings();
});
ipcRenderer.on("goToTab", (_, tab) => {
document.getElementById(tab).click();
});
adBlock = get("adBlock");
api = get("apiCheckbox");
customCSS = get("customCSS");
disableBackgroundThrottle = get("disableBackgroundThrottle");
disableHardwareMediaKeys = get("disableHardwareMediaKeys");
enableCustomHotkeys = get("enableCustomHotkeys");
enableDiscord = get("enableDiscord");
gpuRasterization = get("gpuRasterization");
menuBar = get("menuBar");
minimizeOnClose = get("minimizeOnClose");
mpris = get("mprisCheckbox");
notifications = get("notifications");
playBackControl = get("playBackControl");
port = get("port");
theme = get<HTMLSelectElement>("themesList");
trayIcon = get("trayIcon");
skipArtists = get("skipArtists");
skippedArtists = get("skippedArtists");
singleInstance = get("singleInstance");
updateFrequency = get("updateFrequency");
refreshSettings();
addInputListener(adBlock, settings.adBlock);
addInputListener(api, settings.api);
addTextAreaListener(customCSS, settings.customCSS);
addInputListener(disableBackgroundThrottle, settings.disableBackgroundThrottle);
addInputListener(disableHardwareMediaKeys, settings.flags.disableHardwareMediaKeys);
addInputListener(enableCustomHotkeys, settings.enableCustomHotkeys);
addInputListener(enableDiscord, settings.enableDiscord);
addInputListener(gpuRasterization, settings.flags.gpuRasterization);
addInputListener(menuBar, settings.menuBar);
addInputListener(minimizeOnClose, settings.minimizeOnClose);
addInputListener(mpris, settings.mpris);
addInputListener(notifications, settings.notifications);
addInputListener(playBackControl, settings.playBackControl);
addInputListener(port, settings.apiSettings.port);
addInputListener(skipArtists, settings.skipArtists);
addTextAreaListener(skippedArtists, settings.skippedArtists);
addInputListener(singleInstance, settings.singleInstance);
addSelectListener(theme, settings.theme);
addInputListener(trayIcon, settings.trayIcon);
addInputListener(updateFrequency, settings.updateFrequency);
});

View File

@ -35,6 +35,9 @@
<input type="radio" name="tab" id="advanced" /> <input type="radio" name="tab" id="advanced" />
<label for="advanced">Advanced</label> <label for="advanced">Advanced</label>
<input type="radio" name="tab" id="theming" />
<label for="theming">Theming</label>
<input type="radio" name="tab" id="about" /> <input type="radio" name="tab" id="about" />
<label for="about">About</label> <label for="about">About</label>
@ -48,33 +51,34 @@
<p>Show a notification when a new song starts.</p> <p>Show a notification when a new song starts.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="notifications" type="checkbox"> <input id="notifications" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
<div class="group__option">
<div class="group__description">
<h4>Mute Artists automatically</h4>
<p>The following list of artists (1 per line) will be muted automatically.</p>
</div>
<label class="switch">
<input id="muteArtists" type="checkbox">
<span class="switch__slider"></span>
</label>
</div>
<textarea id="mutedArtists" class="textarea" cols="40" rows="5" spellcheck="false"></textarea>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Skip Artists automatically</h4> <h4>Skip Artists automatically</h4>
<p>The following list of artists (1 per line) will be skipped automatically.</p> <p>The following list of artists (1 per line) will be skipped automatically.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="skipArtists" type="checkbox"> <input id="skipArtists" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
<textarea id="skippedArtists" class="textarea" cols="40" rows="5" spellcheck="false"></textarea> <textarea id="skippedArtists" class="textarea" cols="40" rows="5" spellcheck="false"></textarea>
<div class="group__option">
<div class="group__description">
<h4>Block ads</h4>
<p>
Disabled audio & visual ads, unlocked lyrics, suggested track, track info,
unlimited skips
</p>
</div>
<label class="switch">
<input id="adBlock" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
</div> </div>
<div class="group"> <div class="group">
<p class="group__title">UI</p> <p class="group__title">UI</p>
@ -84,7 +88,7 @@
<p>Always show TIDAL Hi-Fi's menu bar.</p> <p>Always show TIDAL Hi-Fi's menu bar.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="menuBar" type="checkbox"> <input id="menuBar" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -97,7 +101,7 @@
<p>Show TIDAL Hi-Fi's tray icon.</p> <p>Show TIDAL Hi-Fi's tray icon.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="trayIcon" type="checkbox"> <input id="trayIcon" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -107,7 +111,7 @@
<p>Minimize window on close instead.</p> <p>Minimize window on close instead.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="minimizeOnClose" type="checkbox"> <input id="minimizeOnClose" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -120,7 +124,7 @@
</p> </p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="enableCustomHotkeys" type="checkbox"> <input id="enableCustomHotkeys" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -130,7 +134,7 @@
<p>Prevent opening multiple TIDAL Hi-Fi's instances.</p> <p>Prevent opening multiple TIDAL Hi-Fi's instances.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="singleInstance" type="checkbox"> <input id="singleInstance" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -142,8 +146,8 @@
<p class="group__title">API</p> <p class="group__title">API</p>
<div class="group__description"> <div class="group__description">
<p> <p>
TIDAL Hi-Fi has a built-in web API to allow users to get current song information. You can optionally TIDAL Hi-Fi has a built-in web API to allow users to get current song information.
enable playback control as well. You can optionally enable playback control as well.
</p> </p>
</div> </div>
<div class="group__option"> <div class="group__option">
@ -152,14 +156,14 @@
<p>Enable the TIDAL Hi-Fi web API.</p> <p>Enable the TIDAL Hi-Fi web API.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="apiCheckbox" type="checkbox"> <input id="apiCheckbox" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<label for="port">API port</label> <label for="port">API port</label>
<input id="port" type="text" class="text-input" name="port"> <input id="port" type="number" class="text-input" name="port" />
</div> </div>
</div> </div>
<div class="group__option"> <div class="group__option">
@ -168,7 +172,7 @@
<p>Enable playback control from the web API.</p> <p>Enable playback control from the web API.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="playBackControl" type="checkbox"> <input id="playBackControl" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -180,18 +184,20 @@
<p class="group__title">Integrations</p> <p class="group__title">Integrations</p>
<div class="group__description"> <div class="group__description">
<p> <p>
TIDAL Hi-Fi is extensible through the use of integrations. TIDAL Hi-Fi is extensible through the use of integrations. You can enable or
You can enable or disable them here. disable them here.
</p> </p>
</div> </div>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>MPRIS</h4> <h4>MPRIS</h4>
<p>Enable MPRIS interface which provides a mechanism for discovery, querying and basic playback control <p>
on Linux systems.</p> Enable MPRIS interface which provides a mechanism for discovery, querying and
basic playback control on Linux systems.
</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="mprisCheckbox" type="checkbox"> <input id="mprisCheckbox" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -201,7 +207,7 @@
<p>Show what you're listening to on Discord.</p> <p>Show what you're listening to on Discord.</p>
</div> </div>
<label class="switch"> <label class="switch">
<input id="enableDiscord" type="checkbox"> <input id="enableDiscord" type="checkbox" />
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
@ -210,55 +216,121 @@
<section id="advanced-section" class="tabs__section"> <section id="advanced-section" class="tabs__section">
<div class="group"> <div class="group">
<p class="group__title">Flags</p> <p class="group__title">Settings</p>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Disable hardware built-in media keys</h4> <h4>Update frequency</h4>
<p> <p>
Also prevents certain desktop environments from recognizing the chrome The amount of time, in milliseconds, that tidal-hifi will refresh its playback info by scraping the
MPRIS client separately from the custom MPRIS client. website.
The default of 500 seems to work in more cases but if you are fine with a bit more resource usage you
can decrease it as well.
</p>
<input id="updateFrequency" type="number" class="text-input" name="updateFrequency" />
</div>
</div>
<div class="group">
<p class="group__title">Flags</p>
<div class="group__option">
<div class="group__description">
<h4>Disable hardware built-in media keys</h4>
<p>
Also prevents certain desktop environments from recognizing the chrome MPRIS
client separately from the custom MPRIS client.
</p>
</div>
<label class="switch">
<input id="disableHardwareMediaKeys" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div class="group__option">
<div class="group__description">
<h4>Enable GPU rasterization</h4>
<p>Move a part of the rendering to the GPU for increased performance.</p>
</div>
<label class="switch">
<input id="gpuRasterization" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div class="group__option">
<div class="group__description">
<h4>Disable Background Throttling</h4>
<p>
Makes app more responsive while in the background, at the cost of performance.
</p>
</div>
<label class="switch">
<input id="disableBackgroundThrottle" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
</div>
</section>
<section id="theming-section" class="tabs__section">
<div class="group">
<p class="group__title">Theming</p>
<div class="group__option">
<div class="group__description">
<h4>Custom CSS</h4>
<p>
The css that you put in here will be injected into a style tag in the head of the document.
</p> </p>
</div> </div>
<label class="switch">
<input id="disableHardwareMediaKeys" type="checkbox">
<span class="switch__slider"></span>
</label>
</div> </div>
</div>
<textarea id="customCSS" class="textarea" cols="40" rows="8" spellcheck="false"></textarea>
<div class="group">
<p class="group__title">Theme files</p>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Enable GPU rasterization</h4> <h4>Current theme</h4>
<p>Move a part of the rendering to the GPU for increased performance.</p> <p>
Select a theme below or "Tidal - Default" to return to the original Tidal look.
</p>
<select class="select-input" id="themesList" name="themesList">
</select>
</div> </div>
<label class="switch">
<input id="gpuRasterization" type="checkbox">
<span class="switch__slider"></span>
</label>
</div> </div>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Disable Background Throttling</h4> <h4>Upload new themes</h4>
<p>Makes app more responsive while in the background, at the cost of performance.</p> <p>
Click the button and select the css files to import. They will be added to the theme list
automatically.
</p>
<div class="file-drop-area">
<div>
<span class="file-btn">Choose files</span>
<span id="file-message" class="file-msg">or drag and drop files here</span>
<input id="theme-files" class="file-input" type="file" accept=".css" multiple>
</div>
</div>
</div> </div>
<label class="switch">
<input id="disableBackgroundThrottle" type="checkbox">
<span class="switch__slider"></span>
</label>
</div> </div>
</div> </div>
</section> </section>
<section id="about-section" class="tabs__section about-section"> <section id="about-section" class="tabs__section about-section">
<img alt="tidal icon" class="about-section__icon" src="./icon.png"> <img alt="tidal icon" class="about-section__icon" src="./icon.png" />
<p class="about-section__text"> <p class="about-section__text">
<a class="external-link" data-url="https://github.com/Mastermindzh/tidal-hifi">TIDAL Hi-Fi</a> <a class="external-link" data-url="https://github.com/Mastermindzh/tidal-hifi">TIDAL Hi-Fi</a>
is made by <a class="external-link" data-url="https://www.rickvanlieshout.com"> is made by
Rick van Lieshout</a>. <br>It uses <a class="external-link" data-url="https://castlabs.com/">Castlabs'</a> <a class="external-link" data-url="https://www.rickvanlieshout.com">
Rick van Lieshout</a>. <br />It uses
<a class="external-link" data-url="https://castlabs.com/">Castlabs'</a>
version of Electron for widevine support. version of Electron for widevine support.
</p> </p>
</section> </section>
<footer class="footer"> <footer class="footer">
<p class="footer__note">Some settings may require a restart of TIDAL Hi-Fi. To do so, click the button below: <p class="footer__note">
Some settings may require a restart of TIDAL Hi-Fi. To do so, click the button below:
</p> </p>
<button class="footer__button" id="restart">Restart TIDAL Hi-Fi</button> <button class="footer__button" id="restart">Restart TIDAL Hi-Fi</button>
</footer> </footer>
@ -267,4 +339,4 @@
</div> </div>
</body> </body>
</html> </html>

View File

@ -3,7 +3,6 @@
$black: #17171a; $black: #17171a;
$grey-333: #333; $grey-333: #333;
$white: #f9f9f9; $white: #f9f9f9;
$tidal-blue: #0ff; $tidal-blue: #0ff;
$tidal-grey: #72777f; $tidal-grey: #72777f;
$tidal-grey-darker: #404248; $tidal-grey-darker: #404248;
@ -36,16 +35,14 @@ $tidal-grey-darkest: #242528;
src: url("fonts/NotoSans-Bold.ttf") format("truetype"); src: url("fonts/NotoSans-Bold.ttf") format("truetype");
} }
$font1: "Noto Sans", Helvetica, sans-serif; $font1: "Noto Sans", helvetica, sans-serif;
// --- Mixins --- // --- Mixins ---
@mixin drag($enabled: true) { @mixin drag($enabled: true) {
@if $enabled { @if $enabled {
-webkit-app-region: drag; -webkit-app-region: drag;
} } @else {
@else {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
} }
@ -62,6 +59,7 @@ html {
.external-link { .external-link {
@extend button; @extend button;
text-decoration: underline; text-decoration: underline;
} }
@ -80,6 +78,7 @@ html {
&__drag-area { &__drag-area {
@include drag; @include drag;
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 50px; height: 50px;
@ -90,6 +89,7 @@ html {
&__close-button { &__close-button {
@extend button; @extend button;
@include drag(false); @include drag(false);
position: absolute; position: absolute;
top: 12px; top: 12px;
right: 10px; right: 10px;
@ -106,7 +106,7 @@ html {
display: block; display: block;
width: 18px; width: 18px;
height: 18px; height: 18px;
opacity: .7; opacity: 0.7;
} }
// --- Settings tabs --- // --- Settings tabs ---
@ -125,8 +125,9 @@ html {
outline: none; outline: none;
} }
&+label { & + label {
@include drag(false); @include drag(false);
display: inline-block; display: inline-block;
position: relative; position: relative;
margin-right: 35px; margin-right: 35px;
@ -138,7 +139,7 @@ html {
user-select: none; user-select: none;
} }
&:checked+label { &:checked + label {
border-bottom: 2px solid $tidal-blue; border-bottom: 2px solid $tidal-blue;
color: $tidal-blue; color: $tidal-blue;
} }
@ -155,8 +156,8 @@ html {
display: none; display: none;
} }
@for $i from 1 to 6 { @for $i from 1 to 7 {
.settings>input:nth-child(#{$i*2-1}):checked~&>.tabs__section:nth-child(#{$i}) { .settings > input:nth-child(#{$i * 2 - 1}):checked ~ & > .tabs__section:nth-child(#{$i}) {
display: block; display: block;
} }
} }
@ -217,7 +218,7 @@ html {
width: 100%; width: 100%;
margin-bottom: 10px; margin-bottom: 10px;
padding: 5px 0; padding: 5px 0;
transition: .2s; transition: 0.2s;
border: 0; border: 0;
border-bottom: solid 1px $grey-333; border-bottom: solid 1px $grey-333;
outline: none; outline: none;
@ -229,46 +230,42 @@ html {
border-color: $tidal-blue; border-color: $tidal-blue;
color: $white; color: $white;
} }
// --- Switch slider component ---
} }
} }
} }
.switch { .switch {
$this: &; $this: &;
position: relative; position: relative;
min-width: 50px; min-width: 50px;
height: 28px; height: 28px;
margin-left: 10px; margin-left: 10px;
input { input {
transform: scale(0); transform: scale(0);
outline: none; outline: none;
&:checked+#{$this}__slider { &:checked + #{$this}__slider {
background-color: $tidal-blue; background-color: $tidal-blue;
&::before { &::before {
transform: translateX(22px); transform: translateX(22px);
background-color: white; background-color: $white;
} }
} }
&:focus+#{$this}__slider { &:focus + #{$this}__slider {
box-shadow: inset 0 0 0 1px $tidal-blue; box-shadow: inset 0 0 0 1px $tidal-blue;
} }
} }
&__slider { &__slider {
@extend button; @extend button;
position: absolute; position: absolute;
top: 0; inset: 0;
right: 0; transition: 0.4s;
bottom: 0;
left: 0;
transition: .4s;
border-radius: 40px; border-radius: 40px;
background-color: $tidal-grey-darkest; background-color: $tidal-grey-darkest;
@ -278,7 +275,7 @@ html {
left: 2px; left: 2px;
width: 24px; width: 24px;
height: 24px; height: 24px;
transition: .4s; transition: 0.4s;
border-radius: 50%; border-radius: 50%;
background-color: $white; background-color: $white;
content: ""; content: "";
@ -294,7 +291,7 @@ html {
min-height: 50px; min-height: 50px;
max-height: 100px; max-height: 100px;
padding: 8px; padding: 8px;
transition: .2s; transition: 0.2s;
border: 0; border: 0;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
background: $tidal-grey-darkest; background: $tidal-grey-darkest;
@ -345,11 +342,12 @@ html {
&__button { &__button {
@extend button; @extend button;
display: block; display: block;
height: 48px; height: 48px;
margin: auto; margin: auto;
padding: 0 24px; padding: 0 24px;
transition: .2s; transition: 0.2s;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
background: $tidal-grey-darker; background: $tidal-grey-darker;
@ -361,3 +359,87 @@ html {
} }
} }
} }
// file upload
.file-drop-area {
position: relative;
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
padding: 25px 0 25px 0px;
border: 1px dashed $tidal-grey;
border-radius: 3px;
transition: 0.2s;
&.is-active {
background-color: $black;
}
div {
padding-left: 25px;
}
}
.file-btn {
flex-shrink: 0;
background-color: $black;
border: 1px solid $tidal-grey;
border-radius: 3px;
padding: 8px 15px;
margin-right: 10px;
font-size: 12px;
text-transform: uppercase;
}
.file-msg {
font-size: small;
font-weight: 300;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-input {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
cursor: pointer;
opacity: 0;
&:focus {
outline: none;
}
}
.select-input {
display: block;
width: 100%;
margin-bottom: 10px;
padding: 5px 0;
transition: 0.2s;
border: 0;
border-bottom: solid 1px $grey-333;
outline: none;
background: transparent;
color: $tidal-grey;
font-size: 14px;
&:focus {
border-color: $tidal-blue;
color: $white;
}
option {
background-color: $tidal-grey-darkest;
&:disabled {
font-size: 1.2em;
line-height: 1.5em;
text-align: center;
color: $white;
}
}
}

View File

@ -0,0 +1,54 @@
import fs from "fs";
const cssFilter = (file: string) => file.endsWith(".css");
const sort = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase());
/**
* Create an "options header" (disabled option) based on a bit of text
* @param text of the header
* @returns
*/
export const getOptionsHeader = (text: string): HTMLOptionElement => {
const opt = new Option(text, undefined, false, false);
opt.disabled = true;
return opt;
};
/**
* Maps a list of filenames to a list of HTMLOptionElements
* Will strip ".css" from the name but keeps it in the value
* @param array array of filenames
* @returns
*/
export const getOptions = (array: string[]) => {
return array.map((name) => {
return new Option(name.replace(".css", ""), name);
});
};
/**
* Read .css files from a directory and return them in a sorted array.
* @param directory to read from. Will be created if it doesn't exist
* @returns
*/
export const getThemeListFromDirectory = (directory: string): string[] => {
try {
makeUserThemesDirectory(directory);
return fs.readdirSync(directory).filter(cssFilter).sort(sort);
} catch (err) {
console.error(err);
return [];
}
};
/**
* Create the directory to store user themes in
* @param directory directory to create
*/
export const makeUserThemesDirectory = (directory: string) => {
try {
fs.mkdirSync(directory, { recursive: true });
} catch (err) {
console.error(err);
}
};

View File

@ -1,19 +1,21 @@
const { setTitle } = require("./scripts/window-functions"); import { Notification, app, dialog } from "@electron/remote";
const { dialog, process, Notification } = require("@electron/remote"); import { ipcRenderer } from "electron";
const { store, settings } = require("./scripts/settings"); import fs from "fs";
const { ipcRenderer } = require("electron"); import Player from "mpris-service";
const { app } = require("@electron/remote"); import { globalEvents } from "./constants/globalEvents";
const { downloadFile } = require("./scripts/download"); import { settings } from "./constants/settings";
const statuses = require("./constants/statuses"); import { statuses } from "./constants/statuses";
const hotkeys = require("./scripts/hotkeys"); import { Options } from "./models/options";
const globalEvents = require("./constants/globalEvents"); import { downloadFile } from "./scripts/download";
const { skipArtists } = require("./constants/settings"); import { addHotkey } from "./scripts/hotkeys";
import { settingsStore } from "./scripts/settings";
import { setTitle } from "./scripts/window-functions";
const notificationPath = `${app.getPath("userData")}/notification.jpg`; const notificationPath = `${app.getPath("userData")}/notification.jpg`;
const appName = "Tidal Hifi"; const appName = "Tidal Hifi";
let currentSong = ""; let currentSong = "";
let player; let player: Player;
let currentPlayStatus = statuses.paused; let currentPlayStatus = statuses.paused;
let isMutedArtist = true;
const elements = { const elements = {
play: '*[data-test="play"]', play: '*[data-test="play"]',
@ -46,7 +48,7 @@ const elements = {
* Get an element from the dom * Get an element from the dom
* @param {*} key key in elements object to fetch * @param {*} key key in elements object to fetch
*/ */
get: function (key) { get: function (key: string) {
return window.document.querySelector(this[key.toLowerCase()]); return window.document.querySelector(this[key.toLowerCase()]);
}, },
@ -66,16 +68,27 @@ const elements = {
return ""; return "";
}, },
getArtists: function () { /**
* returns an array of all artists in the current song
* @returns {Array} artists
*/
getArtistsArray: function () {
const footer = this.get("footer"); const footer = this.get("footer");
if (footer) { if (footer) {
const artists = footer.querySelector(this["artists"]); const artists = footer.querySelectorAll(this.artists);
if (artists) { if (artists) return Array.from(artists).map((artist) => (artist as HTMLElement).textContent);
return artists.innerText;
}
} }
return [];
},
/**
* unify the artists array into a string separated by commas
* @param {Array} artistsArray
* @returns {String} artists
*/
getArtistsString: function (artistsArray: string[]) {
if (artistsArray.length > 0) return artistsArray.join(", ");
return "unknown artist(s)"; return "unknown artist(s)";
}, },
@ -110,7 +123,7 @@ const elements = {
* Shorthand function to get the text of a dom element * Shorthand function to get the text of a dom element
* @param {*} key key in elements object to fetch * @param {*} key key in elements object to fetch
*/ */
getText: function (key) { getText: function (key: string) {
const element = this.get(key); const element = this.get(key);
return element ? element.textContent : ""; return element ? element.textContent : "";
}, },
@ -119,7 +132,7 @@ const elements = {
* Shorthand function to click a dom element * Shorthand function to click a dom element
* @param {*} key key in elements object to fetch * @param {*} key key in elements object to fetch
*/ */
click: function (key) { click: function (key: string) {
this.get(key).click(); this.get(key).click();
return this; return this;
}, },
@ -128,11 +141,50 @@ const elements = {
* Shorthand function to focus a dom element * Shorthand function to focus a dom element
* @param {*} key key in elements object to fetch * @param {*} key key in elements object to fetch
*/ */
focus: function (key) { focus: function (key: string) {
return this.get(key).focus(); return this.get(key).focus();
}, },
}; };
function addCustomCss() {
window.addEventListener("DOMContentLoaded", () => {
const selectedTheme = settingsStore.get(settings.theme);
if (selectedTheme !== "none") {
const themeFile = `${process.resourcesPath}/${selectedTheme}`;
fs.readFile(themeFile, "utf-8", (err, data) => {
if (err) {
alert("An error ocurred reading the theme file.");
return;
}
const themeStyle = document.createElement("style");
themeStyle.innerHTML = data;
document.head.appendChild(themeStyle);
});
}
// read customCSS (it will override the theme)
const style = document.createElement("style");
style.innerHTML = settingsStore.get<string, string[]>(settings.customCSS).join("\n");
document.head.appendChild(style);
});
}
/**
* Get the update frequency from the store
* make sure it returns a number, if not use the default
*/
function getUpdateFrequency() {
const storeValue = settingsStore.get(settings.updateFrequency) as number;
const defaultValue = 500;
if (!isNaN(storeValue)) {
return storeValue;
} else {
return defaultValue;
}
}
/** /**
* Play or pause the current song * Play or pause the current song
*/ */
@ -152,41 +204,41 @@ function playPause() {
* https://defkey.com/tidal-desktop-shortcuts * https://defkey.com/tidal-desktop-shortcuts
*/ */
function addHotKeys() { function addHotKeys() {
if (store.get(settings.enableCustomHotkeys)) { if (settingsStore.get(settings.enableCustomHotkeys)) {
hotkeys.add("Control+p", function () { addHotkey("Control+p", function () {
elements.click("account").click("settings"); elements.click("account").click("settings");
}); });
hotkeys.add("Control+l", function () { addHotkey("Control+l", function () {
handleLogout(); handleLogout();
}); });
hotkeys.add("Control+h", function () { addHotkey("Control+h", function () {
elements.click("home"); elements.click("home");
}); });
hotkeys.add("backspace", function () { addHotkey("backspace", function () {
elements.click("back"); elements.click("back");
}); });
hotkeys.add("shift+backspace", function () { addHotkey("shift+backspace", function () {
elements.click("forward"); elements.click("forward");
}); });
hotkeys.add("control+u", function () { addHotkey("control+u", function () {
// reloading window without cache should show the update bar if applicable // reloading window without cache should show the update bar if applicable
window.location.reload(true); window.location.reload();
}); });
hotkeys.add("control+r", function () { addHotkey("control+r", function () {
elements.click("repeat"); elements.click("repeat");
}); });
} }
// always add the hotkey for the settings window // always add the hotkey for the settings window
hotkeys.add("control+=", function () { addHotkey("control+=", function () {
ipcRenderer.send(globalEvents.showSettings); ipcRenderer.send(globalEvents.showSettings);
}); });
hotkeys.add("control+0", function () { addHotkey("control+0", function () {
ipcRenderer.send(globalEvents.showSettings); ipcRenderer.send(globalEvents.showSettings);
}); });
} }
@ -198,28 +250,26 @@ function addHotKeys() {
function handleLogout() { function handleLogout() {
const logoutOptions = ["Cancel", "Yes, please", "No, thanks"]; const logoutOptions = ["Cancel", "Yes, please", "No, thanks"];
dialog.showMessageBox( dialog
null, .showMessageBox(null, {
{
type: "question", type: "question",
title: "Logging out", title: "Logging out",
message: "Are you sure you want to log out?", message: "Are you sure you want to log out?",
buttons: logoutOptions, buttons: logoutOptions,
defaultId: 2, defaultId: 2,
}, })
function (response) { .then((result: { response: number }) => {
if (logoutOptions.indexOf("Yes, please") == response) { if (logoutOptions.indexOf("Yes, please") == result.response) {
for (let i = 0; i < window.localStorage.length; i++) { for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i); const key = window.localStorage.key(i);
if (key.startsWith("_TIDAL_activeSession")) { if (key.startsWith("_TIDAL_activeSession")) {
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
i = window.localStorage.length + 1; break;
} }
} }
window.location.reload(); window.location.reload();
} }
} });
);
} }
function addFullScreenListeners() { function addFullScreenListeners() {
@ -260,7 +310,7 @@ function addIPCEventListeners() {
* Update the current status of tidal (e.g playing or paused) * Update the current status of tidal (e.g playing or paused)
*/ */
function getCurrentlyPlayingStatus() { function getCurrentlyPlayingStatus() {
let pause = elements.get("pause"); const pause = elements.get("pause");
let status = undefined; let status = undefined;
// if pause button is visible tidal is playing // if pause button is visible tidal is playing
@ -276,7 +326,7 @@ function getCurrentlyPlayingStatus() {
* Convert the duration from MM:SS to seconds * Convert the duration from MM:SS to seconds
* @param {*} duration * @param {*} duration
*/ */
function convertDuration(duration) { function convertDuration(duration: string) {
const parts = duration.split(":"); const parts = duration.split(":");
return parseInt(parts[1]) + 60 * parseInt(parts[0]); return parseInt(parts[1]) + 60 * parseInt(parts[0]);
} }
@ -286,21 +336,22 @@ function convertDuration(duration) {
* *
* @param {*} options * @param {*} options
*/ */
function updateMediaInfo(options, notify) { function updateMediaInfo(options: Options, notify: boolean) {
if (options) { if (options) {
ipcRenderer.send(globalEvents.updateInfo, options); ipcRenderer.send(globalEvents.updateInfo, options);
if (store.get(settings.notifications) && notify) { if (settingsStore.get(settings.notifications) && notify) {
new Notification({ title: options.title, body: options.message, icon: options.icon }).show(); new Notification({ title: options.title, body: options.artists, icon: options.icon }).show();
} }
if (player) { if (player) {
player.metadata = { player.metadata = {
...player.metadata, ...player.metadata,
...{ ...{
"xesam:title": options.title, "xesam:title": options.title,
"xesam:artist": [options.message], "xesam:artist": [options.artists],
"xesam:album": options.album, "xesam:album": options.album,
"mpris:artUrl": options.image, "mpris:artUrl": options.image,
"mpris:length": convertDuration(options.duration) * 1000 * 1000, "mpris:length": convertDuration(options.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
}, },
}; };
player.playbackStatus = options.status == statuses.paused ? "Paused" : "Playing"; player.playbackStatus = options.status == statuses.paused ? "Paused" : "Playing";
@ -313,41 +364,65 @@ function updateMediaInfo(options, notify) {
* If it's a song it returns the track URL, if not it will return undefined * If it's a song it returns the track URL, if not it will return undefined
*/ */
function getTrackURL() { function getTrackURL() {
const id = getTrackID();
return `https://tidal.com/browse/track/${id}`;
}
function getTrackID() {
const URLelement = elements.get("title").querySelector("a"); const URLelement = elements.get("title").querySelector("a");
if (URLelement !== null) { if (URLelement !== null) {
const id = URLelement.href.replace(/[^0-9]/g, ""); const id = URLelement.href.replace(/\D/g, "");
return `https://tidal.com/browse/track/${id}`; return id;
} }
return window.location; return window.location;
} }
function updateMediaSession(options: Options) {
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: options.title,
artist: options.artists,
album: options.album,
artwork: [
{
src: options.icon,
sizes: "640x640",
type: "image/png",
},
],
});
}
}
/** /**
* Watch for song changes and update title + notify * Watch for song changes and update title + notify
*/ */
setInterval(function () { setInterval(function () {
const title = elements.getText("title"); const title = elements.getText("title");
const artists = elements.getArtists(); const artistsArray = elements.getArtistsArray();
skipArtistsIfFoundInSkippedArtistsList(artists); const artistsString = elements.getArtistsString(artistsArray);
muteArtistIfFoundInMutedArtistsList(artists); // doing this here so that nothing can possibly fail before we call this function skipArtistsIfFoundInSkippedArtistsList(artistsArray);
const album = elements.getAlbumName(); const album = elements.getAlbumName();
const current = elements.getText("current"); const current = elements.getText("current");
const duration = elements.getText("duration"); const duration = elements.getText("duration");
const songDashArtistTitle = `${title} - ${artists}`; const songDashArtistTitle = `${title} - ${artistsString}`;
const currentStatus = getCurrentlyPlayingStatus(); const currentStatus = getCurrentlyPlayingStatus();
const options = { const options = {
title, title,
message: artists, artists: artistsString,
album: album, album: album,
status: currentStatus, status: currentStatus,
url: getTrackURL(), url: getTrackURL(),
current, current,
duration, duration,
"app-name": appName, "app-name": appName,
image: "",
icon: "",
}; };
const titleOrArtistChanged = currentSong !== songDashArtistTitle; const titleOrArtistsChanged = currentSong !== songDashArtistTitle;
// update title, url and play info with new info // update title, url and play info with new info
setTitle(songDashArtistTitle); setTitle(songDashArtistTitle);
@ -357,7 +432,7 @@ setInterval(function () {
const image = elements.getSongIcon(); const image = elements.getSongIcon();
new Promise((resolve) => { new Promise<void>((resolve) => {
if (image.startsWith("http")) { if (image.startsWith("http")) {
options.image = image; options.image = image;
downloadFile(image, notificationPath).then( downloadFile(image, notificationPath).then(
@ -374,48 +449,34 @@ setInterval(function () {
// if the image can't be found on the page continue without it // if the image can't be found on the page continue without it
resolve(); resolve();
} }
}).then( }).then(() => {
() => { updateMediaInfo(options, titleOrArtistsChanged);
updateMediaInfo(options, titleOrArtistChanged); if (titleOrArtistsChanged) {
}, updateMediaSession(options);
() => {}
);
/**
* Checks whether the current artist is included in the "muted artists" list and if so it will automatically mute the player
*/
function muteArtistIfFoundInMutedArtistsList(artists) {
if (store.get(settings.muteArtists)) {
const mutedArtists = store.get(settings.mutedArtists);
if (mutedArtists.find((artist) => artist === artists) !== undefined) {
if (!elements.isMuted()) {
isMutedArtist = true;
elements.click("volume");
}
} else if (isMutedArtist && elements.isMuted()) {
elements.click("volume");
isMutedArtist = false;
}
} }
} });
/** /**
* automatically skip a song if the artists are found in the list of artists to skip * automatically skip a song if the artists are found in the list of artists to skip
* @param {*} artists list of artists to skip * @param {*} artists array of artists
*/ */
function skipArtistsIfFoundInSkippedArtistsList(artists) { function skipArtistsIfFoundInSkippedArtistsList(artists: string[]) {
if (store.get(skipArtists)) { if (settingsStore.get(settings.skipArtists)) {
const skippedArtists = store.get(settings.skippedArtists); const skippedArtists = settingsStore.get<string, string[]>(settings.skippedArtists);
if (skippedArtists.find((artist) => artist === artists) !== undefined) { if (skippedArtists.length > 0) {
elements.click("next"); const artistsToSkip = skippedArtists.map((artist) => artist);
const artistNames = Object.values(artists).map((artist) => artist);
const foundArtist = artistNames.some((artist) => artistsToSkip.includes(artist));
if (foundArtist) {
elements.click("next");
}
} }
} }
} }
}, 1000); }, getUpdateFrequency());
if (process.platform === "linux" && store.get(settings.mpris)) { if (process.platform === "linux" && settingsStore.get(settings.mpris)) {
try { try {
const Player = require("mpris-service");
player = Player({ player = Player({
name: "tidal-hifi", name: "tidal-hifi",
identity: "tidal-hifi", identity: "tidal-hifi",
@ -432,7 +493,7 @@ if (process.platform === "linux" && store.get(settings.mpris)) {
}); });
// Events // Events
var events = { const events = {
next: "next", next: "next",
previous: "previous", previous: "previous",
pause: "pause", pause: "pause",
@ -442,7 +503,7 @@ if (process.platform === "linux" && store.get(settings.mpris)) {
loopStatus: "repeat", loopStatus: "repeat",
shuffle: "shuffle", shuffle: "shuffle",
seek: "seek", seek: "seek",
}; } as { [key: string]: string };
Object.keys(events).forEach(function (eventName) { Object.keys(events).forEach(function (eventName) {
player.on(eventName, function () { player.on(eventName, function () {
const eventValue = events[eventName]; const eventValue = events[eventName];
@ -468,7 +529,7 @@ if (process.platform === "linux" && store.get(settings.mpris)) {
console.log("player api not working"); console.log("player api not working");
} }
} }
addCustomCss();
addHotKeys(); addHotKeys();
addIPCEventListeners(); addIPCEventListeners();
addFullScreenListeners(); addFullScreenListeners();

View File

@ -1,95 +0,0 @@
const discordrpc = require("discord-rpc");
const { app, ipcMain } = require("electron");
const globalEvents = require("../constants/globalEvents");
const clientId = "833617820704440341";
const mediaInfoModule = require("./mediaInfo");
const discordModule = [];
function timeToSeconds(timeArray) {
let minutes = timeArray[0] * 1;
let seconds = minutes * 60 + timeArray[1] * 1;
return seconds;
}
let rpc;
const observer = (event, arg) => {
if (mediaInfoModule.mediaInfo.status == "paused" && rpc) {
rpc.setActivity(idleStatus);
} else if (rpc) {
const currentSeconds = timeToSeconds(mediaInfoModule.mediaInfo.current.split(":"));
const durationSeconds = timeToSeconds(mediaInfoModule.mediaInfo.duration.split(":"));
const date = new Date();
const now = (date.getTime() / 1000) | 0;
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
if (mediaInfoModule.mediaInfo.url) {
rpc.setActivity({
...idleStatus,
...{
details: `Listening to ${mediaInfoModule.mediaInfo.title}`,
state: mediaInfoModule.mediaInfo.artist
? mediaInfoModule.mediaInfo.artist
: "unknown artist(s)",
startTimestamp: parseInt(now),
endTimestamp: parseInt(remaining),
largeImageKey: mediaInfoModule.mediaInfo.image,
largeImageText: mediaInfoModule.mediaInfo.album
? mediaInfoModule.mediaInfo.album
: `${idleStatus.largeImageText}`,
buttons: [{ label: "Play on Tidal", url: mediaInfoModule.mediaInfo.url }],
},
});
} else {
rpc.setActivity({
...idleStatus,
...{
details: `Watching ${mediaInfoModule.mediaInfo.title}`,
state: mediaInfoModule.mediaInfo.artist,
startTimestamp: parseInt(now),
endTimestamp: parseInt(remaining),
},
});
}
}
};
const idleStatus = {
details: `Browsing Tidal`,
largeImageKey: "tidal-hifi-icon",
largeImageText: `Tidal HiFi ${app.getVersion()}`,
instance: false,
};
/**
* Set up the discord rpc and listen on globalEvents.updateInfo
*/
discordModule.initRPC = function () {
rpc = new discordrpc.Client({ transport: "ipc" });
rpc.login({ clientId }).then(
() => {
discordModule.rpc = rpc;
rpc.on("ready", () => {
rpc.setActivity(idleStatus);
});
ipcMain.on(globalEvents.updateInfo, observer);
},
() => {
console.error("Can't connect to Discord, is it running?");
}
);
};
/**
* Remove any RPC connection with discord and remove the event listener on globalEvents.updateInfo
*/
discordModule.unRPC = function () {
if (rpc) {
rpc.clearActivity();
rpc.destroy();
rpc = false;
discordModule.rpc = undefined;
ipcMain.removeListener(globalEvents.updateInfo, observer);
}
};
module.exports = discordModule;

88
src/scripts/discord.ts Normal file
View File

@ -0,0 +1,88 @@
import { Client } from "discord-rpc";
import { app, ipcMain } from "electron";
import { globalEvents } from "../constants/globalEvents";
import { MediaStatus } from "../models/mediaStatus";
import { mediaInfo } from "./mediaInfo";
const clientId = "833617820704440341";
function timeToSeconds(timeArray: string[]) {
const minutes = parseInt(timeArray[0]) * 1;
const seconds = minutes * 60 + parseInt(timeArray[1]) * 1;
return seconds;
}
export let rpc: Client;
const observer = () => {
if (mediaInfo.status == MediaStatus.paused && rpc) {
rpc.setActivity(idleStatus);
} else if (rpc) {
const currentSeconds = timeToSeconds(mediaInfo.current.split(":"));
const durationSeconds = timeToSeconds(mediaInfo.duration.split(":"));
const date = new Date();
const now = (date.getTime() / 1000) | 0;
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
if (mediaInfo.url) {
rpc.setActivity({
...idleStatus,
...{
details: `Listening to ${mediaInfo.title}`,
state: mediaInfo.artists ? mediaInfo.artists : "unknown artist(s)",
startTimestamp: now,
endTimestamp: remaining,
largeImageKey: mediaInfo.image,
largeImageText: mediaInfo.album ? mediaInfo.album : `${idleStatus.largeImageText}`,
buttons: [{ label: "Play on Tidal", url: mediaInfo.url }],
},
});
} else {
rpc.setActivity({
...idleStatus,
...{
details: `Watching ${mediaInfo.title}`,
state: mediaInfo.artists,
startTimestamp: now,
endTimestamp: remaining,
},
});
}
}
};
const idleStatus = {
details: `Browsing Tidal`,
largeImageKey: "tidal-hifi-icon",
largeImageText: `Tidal HiFi ${app.getVersion()}`,
instance: false,
};
/**
* Set up the discord rpc and listen on globalEvents.updateInfo
*/
export const initRPC = () => {
rpc = new Client({ transport: "ipc" });
rpc.login({ clientId }).then(
() => {
rpc.on("ready", () => {
rpc.setActivity(idleStatus);
});
ipcMain.on(globalEvents.updateInfo, observer);
},
() => {
console.error("Can't connect to Discord, is it running?");
}
);
};
/**
* Remove any RPC connection with discord and remove the event listener on globalEvents.updateInfo
*/
export const unRPC = () => {
if (rpc) {
rpc.clearActivity();
rpc.destroy();
rpc = null;
ipcMain.removeListener(globalEvents.updateInfo, observer);
}
};

View File

@ -1,26 +0,0 @@
const download = {};
const request = require("request");
const fs = require("fs");
/**
* download and save a file
* @param {*} fileUrl url to download
* @param {*} targetPath path to save it at
*/
download.downloadFile = function(fileUrl, targetPath) {
return new Promise((resolve, reject) => {
var req = request({
method: "GET",
uri: fileUrl,
});
var out = fs.createWriteStream(targetPath);
req.pipe(out);
req.on("end", resolve);
req.on("error", reject);
});
};
module.exports = download;

23
src/scripts/download.ts Normal file
View File

@ -0,0 +1,23 @@
import fs from "fs";
import request from "request";
/**
* download and save a file
* @param {string} fileUrl url to download
* @param {string} targetPath path to save it at
*/
export const downloadFile = function (fileUrl: string, targetPath: string) {
return new Promise((resolve, reject) => {
const req = request({
method: "GET",
uri: fileUrl,
});
const out = fs.createWriteStream(targetPath);
req.pipe(out);
req.on("end", resolve);
req.on("error", reject);
});
};

View File

@ -1,70 +0,0 @@
const express = require("express");
const { mediaInfo } = require("./mediaInfo");
const { store, settings } = require("./settings");
const globalEvents = require("./../constants/globalEvents");
const statuses = require("./../constants/statuses");
const expressModule = {};
const fs = require("fs");
let expressInstance;
/**
* Function to enable tidal-hifi's express api
*/
expressModule.run = function(mainWindow) {
/**
* Shorthand to handle a fire and forget global event
* @param {*} res
* @param {*} action
*/
function handleGlobalEvent(res, action) {
mainWindow.webContents.send("globalEvent", action);
res.sendStatus(200);
}
const expressApp = express();
expressApp.get("/", (req, res) => res.send("Hello World!"));
expressApp.get("/current", (req, res) => res.json(mediaInfo));
expressApp.get("/image", (req, res) => {
var stream = fs.createReadStream(mediaInfo.icon);
stream.on("open", function() {
res.set("Content-Type", "image/png");
stream.pipe(res);
});
stream.on("error", function() {
res.set("Content-Type", "text/plain");
res.status(404).end("Not found");
});
});
if (store.get(settings.playBackControl)) {
expressApp.get("/play", (req, res) => handleGlobalEvent(res, globalEvents.play));
expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause));
expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next));
expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous));
expressApp.get("/playpause", (req, res) => {
if (mediaInfo.status == statuses.playing) {
handleGlobalEvent(res, globalEvents.pause);
} else {
handleGlobalEvent(res, globalEvents.play);
}
});
}
if (store.get(settings.api)) {
let port = store.get(settings.apiSettings.port);
expressInstance = expressApp.listen(port, "127.0.0.1", () => {});
expressInstance.on("error", function(e) {
let message = e.code;
if (e.code === "EADDRINUSE") {
message = `Port ${port} in use.`;
}
const { dialog } = require("electron");
dialog.showErrorBox("Api failed to start.", message);
});
} else {
expressInstance = undefined;
}
};
module.exports = expressModule;

66
src/scripts/express.ts Normal file
View File

@ -0,0 +1,66 @@
import { BrowserWindow, dialog } from "electron";
import express, { Response } from "express";
import fs from "fs";
import { globalEvents } from "./../constants/globalEvents";
import { statuses } from "./../constants/statuses";
import { mediaInfo } from "./mediaInfo";
import { settingsStore } from "./settings";
import { settings } from "../constants/settings";
/**
* Function to enable tidal-hifi's express api
*/
// expressModule.run = function (mainWindow)
export const startExpress = (mainWindow: BrowserWindow) => {
/**
* Shorthand to handle a fire and forget global event
* @param {*} res
* @param {*} action
*/
function handleGlobalEvent(res: Response, action: string) {
mainWindow.webContents.send("globalEvent", action);
res.sendStatus(200);
}
const expressApp = express();
expressApp.get("/", (req, res) => res.send("Hello World!"));
expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists }));
expressApp.get("/image", (req, res) => {
const stream = fs.createReadStream(mediaInfo.icon);
stream.on("open", function () {
res.set("Content-Type", "image/png");
stream.pipe(res);
});
stream.on("error", function () {
res.set("Content-Type", "text/plain");
res.status(404).end("Not found");
});
});
if (settingsStore.get(settings.playBackControl)) {
expressApp.get("/play", (req, res) => handleGlobalEvent(res, globalEvents.play));
expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause));
expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next));
expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous));
expressApp.get("/playpause", (req, res) => {
if (mediaInfo.status == statuses.playing) {
handleGlobalEvent(res, globalEvents.pause);
} else {
handleGlobalEvent(res, globalEvents.play);
}
});
}
const port = settingsStore.get<string, number>(settings.apiSettings.port);
const expressInstance = expressApp.listen(port, "127.0.0.1");
expressInstance.on("error", function (e: { code: string }) {
let message = e.code;
if (e.code === "EADDRINUSE") {
message = `Port ${port} in use.`;
}
dialog.showErrorBox("Api failed to start.", message);
});
};

View File

@ -1,11 +0,0 @@
const hotkeyjs = require("hotkeys-js");
const hotkeys = {};
hotkeys.add = function(keys, func) {
hotkeyjs(keys, function(event, args) {
event.preventDefault();
func(event, args);
});
};
module.exports = hotkeys;

11
src/scripts/hotkeys.ts Normal file
View File

@ -0,0 +1,11 @@
import hotkeyjs, { HotkeysEvent } from "hotkeys-js";
export const addHotkey = function (
keys: string,
func: (event?: KeyboardEvent, args?: HotkeysEvent) => void
) {
hotkeyjs(keys, function (event, args) {
event.preventDefault();
func(event, args);
});
};

View File

@ -1,26 +1,21 @@
const statuses = require("./../constants/statuses"); import { MediaInfo } from "../models/mediaInfo";
import { statuses } from "./../constants/statuses";
const mediaInfo = { export const mediaInfo = {
title: "", title: "",
artist: "", artists: "",
album: "", album: "",
icon: "", icon: "",
status: statuses.paused, status: statuses.paused,
url: "", url: "",
current: "", current: "",
duration: "", duration: "",
image: "tidal-hifi-icon" image: "tidal-hifi-icon",
};
const mediaInfoModule = {
mediaInfo,
}; };
/** export const updateMediaInfo = (arg: MediaInfo) => {
* Update artist and song info in the mediaInfo constant
*/
mediaInfoModule.update = function (arg) {
mediaInfo.title = propOrDefault(arg.title); mediaInfo.title = propOrDefault(arg.title);
mediaInfo.artist = propOrDefault(arg.message); mediaInfo.artists = propOrDefault(arg.artists);
mediaInfo.album = propOrDefault(arg.album); mediaInfo.album = propOrDefault(arg.album);
mediaInfo.icon = propOrDefault(arg.icon); mediaInfo.icon = propOrDefault(arg.icon);
mediaInfo.url = propOrDefault(arg.url); mediaInfo.url = propOrDefault(arg.url);
@ -35,8 +30,6 @@ mediaInfoModule.update = function (arg) {
* @param {*} prop property to check * @param {*} prop property to check
* @param {*} defaultValue defaults to "" * @param {*} defaultValue defaults to ""
*/ */
function propOrDefault(prop, defaultValue = "") { function propOrDefault(prop: string, defaultValue = "") {
return prop ? prop : defaultValue; return prop ? prop : defaultValue;
} }
module.exports = mediaInfoModule;

View File

@ -1,7 +1,7 @@
const { Menu, app } = require("electron"); import { BrowserWindow, Menu, app } from "electron";
const { showSettingsWindow } = require("./settings"); import { showSettingsWindow } from "./settings";
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
const { name } = require("./../constants/values"); import name from "./../constants/values";
const settingsMenuEntry = { const settingsMenuEntry = {
label: "Settings", label: "Settings",
@ -19,9 +19,7 @@ const quitMenuEntry = {
accelerator: "Control+Q", accelerator: "Control+Q",
}; };
const menuModule = {}; export const getMenu = function (mainWindow: BrowserWindow) {
menuModule.getMenu = function (mainWindow) {
const toggleWindow = { const toggleWindow = {
label: "Toggle Window", label: "Toggle Window",
click: function () { click: function () {
@ -113,11 +111,9 @@ menuModule.getMenu = function (mainWindow) {
quitMenuEntry, quitMenuEntry,
]; ];
return Menu.buildFromTemplate(mainMenu); return Menu.buildFromTemplate(mainMenu as any);
}; };
menuModule.addMenu = function (mainWindow) { export const addMenu = function (mainWindow: BrowserWindow) {
Menu.setApplicationMenu(menuModule.getMenu(mainWindow)); Menu.setApplicationMenu(getMenu(mainWindow));
}; };
module.exports = menuModule;

View File

@ -1,36 +1,39 @@
const Store = require("electron-store"); import Store from "electron-store";
const settings = require("./../constants/settings");
const path = require("path");
const { BrowserWindow } = require("electron");
let settingsWindow; import { settings } from "../constants/settings";
import path from "path";
import { BrowserWindow } from "electron";
const store = new Store({ let settingsWindow: BrowserWindow;
export const settingsStore = new Store({
defaults: { defaults: {
notifications: true, adBlock: false,
api: true, api: true,
playBackControl: true,
muteArtists: false,
mutedArtists: ["TIDAL"],
skipArtists: false,
skippedArtists: [""],
disableBackgroundThrottle: true,
menuBar: true,
apiSettings: { apiSettings: {
port: 47836, port: 47836,
}, },
singleInstance: true, customCSS: [],
disableBackgroundThrottle: true,
disableHardwareMediaKeys: false, disableHardwareMediaKeys: false,
trayIcon: true,
minimizeOnClose: false,
mpris: false,
enableCustomHotkeys: false, enableCustomHotkeys: false,
enableDiscord: false, enableDiscord: false,
windowBounds: { width: 800, height: 600 },
flags: { flags: {
gpuRasterization: true, gpuRasterization: true,
disableHardwareMediaKeys: false, disableHardwareMediaKeys: false,
}, },
menuBar: true,
minimizeOnClose: false,
mpris: false,
notifications: true,
playBackControl: true,
singleInstance: true,
skipArtists: false,
skippedArtists: [""],
theme: "none",
trayIcon: true,
updateFrequency: 500,
windowBounds: { width: 800, height: 600 },
}, },
migrations: { migrations: {
"3.1.0": (migrationStore) => { "3.1.0": (migrationStore) => {
@ -44,16 +47,15 @@ const store = new Store({
}); });
const settingsModule = { const settingsModule = {
store, // settings,
settings,
settingsWindow, settingsWindow,
}; };
settingsModule.createSettingsWindow = function () { export const createSettingsWindow = function () {
settingsWindow = new BrowserWindow({ settingsWindow = new BrowserWindow({
width: 700, width: 700,
height: 600, height: 600,
resizable: false, resizable: true,
show: false, show: false,
transparent: true, transparent: true,
frame: false, frame: false,
@ -65,7 +67,7 @@ settingsModule.createSettingsWindow = function () {
}, },
}); });
settingsWindow.on("close", (event) => { settingsWindow.on("close", (event: Event) => {
if (settingsWindow != null) { if (settingsWindow != null) {
event.preventDefault(); event.preventDefault();
settingsWindow.hide(); settingsWindow.hide();
@ -77,19 +79,17 @@ settingsModule.createSettingsWindow = function () {
settingsModule.settingsWindow = settingsWindow; settingsModule.settingsWindow = settingsWindow;
}; };
settingsModule.showSettingsWindow = function (tab = "general") { export const showSettingsWindow = function (tab = "general") {
settingsWindow.webContents.send("goToTab", tab); settingsWindow.webContents.send("goToTab", tab);
// refresh data just before showing the window // refresh data just before showing the window
settingsWindow.webContents.send("refreshData"); settingsWindow.webContents.send("refreshData");
settingsWindow.show(); settingsWindow.show();
}; };
settingsModule.hideSettingsWindow = function () { export const hideSettingsWindow = function () {
settingsWindow.hide(); settingsWindow.hide();
}; };
settingsModule.closeSettingsWindow = function () { export const closeSettingsWindow = function () {
settingsWindow = null; settingsWindow = null;
}; };
module.exports = settingsModule;

View File

@ -1,26 +0,0 @@
const { Tray } = require("electron");
const { getMenu } = require("./menu");
const trayModule = {};
let tray;
trayModule.addTray = function (mainWindow, options = { icon: "" }) {
tray = new Tray(options.icon);
tray.setIgnoreDoubleClickEvents(true);
tray.setToolTip("Tidal-hifi");
const menu = getMenu(mainWindow);
tray.setContextMenu(menu);
tray.on("click", function () {
mainWindow.isVisible() ? mainWindow.focus() : mainWindow.show();
});
};
trayModule.refreshTray = function (mainWindow) {
if (!tray) {
trayModule.addTray(mainWindow);
}
};
module.exports = trayModule;

32
src/scripts/tray.ts Normal file
View File

@ -0,0 +1,32 @@
import { BrowserWindow, Tray } from "electron";
import { getMenu } from "./menu";
let tray: Tray;
export const addTray = function (mainWindow: BrowserWindow, options = { icon: "" }) {
tray = new Tray(options.icon);
tray.setIgnoreDoubleClickEvents(true);
tray.setToolTip("Tidal-hifi");
const menu = getMenu(mainWindow);
tray.setContextMenu(menu);
tray.on("click", function () {
if (mainWindow.isVisible()) {
if (!mainWindow.isFocused()) {
mainWindow.focus();
} else {
mainWindow.hide();
}
} else {
mainWindow.show();
}
});
};
export const refreshTray = function (mainWindow: BrowserWindow) {
if (!tray) {
addTray(mainWindow);
}
};

View File

@ -1,11 +0,0 @@
const windowFunctions = {};
windowFunctions.setTitle = function(title) {
window.document.title = title;
};
windowFunctions.getTitle = function() {
return window.document.title;
};
module.exports = windowFunctions;

View File

@ -0,0 +1,7 @@
export const setTitle = function (title: string) {
window.document.title = title;
};
export const getTitle = function () {
return window.document.title;
};

10
src/themes/Blood.scss Normal file
View File

@ -0,0 +1,10 @@
$foreground: red;
$background: black;
span {
color: $foreground;
}
.sidebar--WvRg_ {
background-color: $background;
}

View File

@ -0,0 +1,82 @@
:root {
--footer-player-background: #1a1b26;
--sidebar-background: #1a1b26;
--sidebar-hover-background: #414868;
--sidebar-menu-top-text: #565f89;
--sidebar-menu-playlist-text: #565f89;
--search-background: #1a1b26;
--main-background: #16161e;
--main-navigation-control-background: #1a1b26;
--main-feed-button-background: #1a1b26;
--player-control-background: #24283b;
--player-control-active-button: #ff9e64;
--player-progress-bar: #ff9e64;
--indicator-hifi-background: #9ece6a;
--indicator-hifi-span: #1a1b26;
--player-control-favorite: #f7768e;
--search-dialog-background: #24283b;
--right-queue-background: #24283b;
}
.player--fNPGt.notFullscreen--ugyc2 {
background-color: var(--footer-player-background);
}
.sidebar--WvRg_ {
background-color: var(--sidebar-background);
contain: strict;
flex-grow: 1;
overflow-y: auto;
}
.item--VTpWS:hover {
background-color: var(--sidebar-hover-background);
}
.main--LUnJp {
background-color: var(--main-background);
}
button.button--ncJwL {
background-color: var(--main-navigation-control-background);
}
.player--fNPGt.lossLess--g5Jss button.withBackground[aria-checked="true"] path {
fill: var(--player-control-active-button);
}
.player--fNPGt.lossLess--g5Jss button.withBackground[aria-checked="true"] {
background-color: var(--player-control-background);
}
.activeItem--qV6eL .activeItem--qV6eL .playlistItem--YARJh .section--FI41E.playingItem--eWkYS {
color: #565f89;
}
.progressBarWrapper--WZfox {
color: var(--player-progress-bar);
}
.playbackControls--FLeZA button .tidal-ui__icon {
transform: scale(1);
}
.css-11m9iw3 {
background-color: var(--indicator-hifi-background);
}
.css-11m9iw3 span {
color: var(--indicator-hifi-span);
}
.activeItem--qV6eL {
color: var(--sidebar-menu-top-text);
}
.activeItem--qV6eL .playlistItem--YARJh {
color: var(--sidebar-menu-playlist-text);
}
button.feedBell--B8anb {
background-color: var(--main-feed-button-background);
}
.baseContainer--cbf17 {
background-color: var(--search-dialog-background);
}
.favoriteButton--TtBlM.is-favorite path {
fill: var(--player-control-favorite);
}
.container--mkEWd {
background-color: var(--right-queue-background);
}
.container--vJVjO {
background-color: var(--search-background);
}
.searchFieldHighlighted--Fitvs {
color: var(--snow-white);
}

60
src/types/mpris-service.d.ts vendored Normal file
View File

@ -0,0 +1,60 @@
declare class InitOptions {
name: string;
identity: string;
supportedUriSchemes: string[];
supportedMimeTypes: string[];
supportedInterfaces: string[];
desktopEntry: string;
}
declare class Player {
metadata: {
"xesam:title": string;
"xesam:artist": string[];
"xesam:album": string;
"mpris:artUrl": string;
"mpris:length": number;
"mpris:trackid": string;
// other options
[key: string]: string | number | string[] | object;
};
playbackStatus: string;
identity: string;
fullscreen: boolean;
supportedUriSchemes: string[];
supportedMimeTypes: string[];
canQuit: boolean;
canRaise: boolean;
canSetFullscreen: boolean;
hasTrackList: boolean;
desktopEntry: string;
loopStatus: string;
shuffle: boolean;
volume: number;
canControl: boolean;
canPause: boolean;
canPlay: boolean;
canSeek: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
rate: number;
minimumRate: number;
maximumRate: number;
playlists: string[];
activePlaylist: string;
constructor(opts: { name: string; supportedInterfaces?: string[] });
constructor(opts: InitOptions);
getPosition(): number;
seeked(): void;
getTrackIndex(trackId: number): number;
getTrack(trackId: number): string;
addTrack(track: object): void;
removeTrack(trackId: number): number;
getPlaylistIndex(playlistId: number): number;
setPlaylists(playlists: object): void;
setActivePlaylist(playlistId: number): void;
on(event: string | symbol, listener: (...args: object[]) => void): this;
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"typeRoots": ["src/types"],
"module": "commonjs",
"target": "ES6",
"noImplicitAny": true,
"sourceMap": true,
"allowJs": true,
"outDir": "ts-dist",
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"*": ["node_modules/*"]
}
},
"include": ["src/**/*"]
}