Compare commits

..

No commits in common. "master" and "5.3.0" have entirely different histories.

91 changed files with 3052 additions and 7884 deletions

View File

@ -13,4 +13,4 @@ steps:
commands: commands:
- apt-get update && apt-get upgrade -y - apt-get update && apt-get upgrade -y
- apt-get install -y libarchive-tools rpm - apt-get install -y libarchive-tools rpm
- npm run build-unpacked - npm run build

View File

@ -1,9 +1,5 @@
{ {
"root": true, "root": true,
"env": {
"node": true,
"browser": true
},
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": [ "plugins": [
"@typescript-eslint" "@typescript-eslint"

View File

@ -9,7 +9,6 @@ on:
branches-ignore: branches-ignore:
- master - master
- develop - develop
workflow_dispatch:
jobs: jobs:
build_on_linux: build_on_linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -21,30 +20,26 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@master - uses: actions/setup-node@master
with: with:
node-version: 22.4 node-version: 19
- run: npm install - run: npm install
- run: npm run build - run: npm run build
# - uses: actions/upload-artifact@master
# with:
# name: linux-builds
# 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: 22.4 node-version: 19
# - run: npm install - run: npm install
# - run: npm run build - run: npm run build
# build_on_win: build_on_win:
# runs-on: windows-latest runs-on: windows-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: 22.4 node-version: 19
# - run: npm install - run: npm install
# - run: npm run build - run: npm run build

View File

@ -8,7 +8,6 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
workflow_dispatch:
jobs: jobs:
build_on_linux: build_on_linux:
@ -21,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: 22.4 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
@ -29,30 +28,30 @@ jobs:
name: linux-builds name: linux-builds
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: 22.4 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
# with: with:
# name: mac-builds name: mac-builds
# path: ./dist/ path: ./dist/
# build_on_win: build_on_win:
# runs-on: windows-latest runs-on: windows-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: 22.4 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
# with: with:
# name: windows-builds name: windows-builds
# path: dist/ path: dist/

View File

@ -5,9 +5,6 @@
"extends": [ "extends": [
"stylelint-config-standard-scss" "stylelint-config-standard-scss"
], ],
"ignoreFiles": [
"src/themes/**.scss"
],
"rules": { "rules": {
"prettier/prettier": true, "prettier/prettier": true,
"scss/at-extend-no-missing-placeholder": null, "scss/at-extend-no-missing-placeholder": null,

View File

@ -1,5 +0,0 @@
POST /settings/skipped-artists HTTP/1.1
Host: localhost:47836
Content-Type: application/json
["abc", "def"]

View File

@ -1,2 +0,0 @@
POST /settings/skipped-artists/current HTTP/1.1
Host: localhost:47836

View File

@ -1,5 +0,0 @@
POST /settings/skipped-artists/delete HTTP/1.1
Host: localhost:47836
Content-Type: application/json
["abc", "def"]

View File

@ -1,2 +0,0 @@
DELETE /settings/skipped-artists/current HTTP/1.1
Host: localhost:47836

15
.vscode/settings.json vendored
View File

@ -1,26 +1,13 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Brainz",
"Castlabs",
"Fi's",
"flac", "flac",
"Flatpak",
"geqnfr", "geqnfr",
"hifi", "hifi",
"libnotify",
"listenbrainz",
"playpause", "playpause",
"prs",
"rescrobbler", "rescrobbler",
"scrobble",
"scrobbling",
"trackid", "trackid",
"tracklist", "tracklist",
"widevine", "widevine",
"xesam" "xesam"
], ]
"sonarlint.connectedMode.project": {
"connectionId": "public-sonarcloud",
"projectKey": "Mastermindzh_tidal-hifi"
}
} }

View File

@ -4,191 +4,11 @@ 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.18.2]
- Reverted to sass 1.79.4 to fix `Nix` builds
- Changed electron-builder.base.yml to now generate the correct .desktop entries again
- Should fix flatpak build
## [5.18.1]
- Fixed the login bug
- Upgraded electron to 35.1.1
- Added Widevine/CDM info to startup
- delayed remote electron initializer
## [5.18.0]
- [Dianoga](https://github.com/Dianoga) fixed the duration selector, restoring mpris & partial API data.
- PR: #554
- Added `xesam:url` property to mpris metadata fixes [#506](https://github.com/Mastermindzh/tidal-hifi/issues/506)
## [5.17.0]
- Added an option to disable the dynamic title and set it to a static one, [#491](https://github.com/Mastermindzh/tidal-hifi/pull/491)
- Discord integration now says "Listening to" instead of "playing" [#488](https://github.com/Mastermindzh/tidal-hifi/pull/488) && [#454](https://github.com/Mastermindzh/tidal-hifi/pull/454)
- Fixed several element names in the dom scraper
- Removed the Songwhip (they shut down) integration and replaced it with TIDAL's universal link system
## [5.16.0]
- Fix issue #449 Discord RPC stuck on "Browsing Tidal".
- Fix issue #448 Add option to disable the discord rpc idle text
- Notifications are now send at the end of the update process, allowing other events to happen sooner.
## [5.15.0]
- Added all missing swagger/openApi info with the help of [Times-Z](https://github.com/Times-Z)
- Updated most dependency versions
- This includes Electron 31!
- Added a channel selector so we can now use Tidal's staging environment directly from the app
- implements [#437](https://github.com/Mastermindzh/tidal-hifi/issues/437)
## [5.14.1]
- Fixed `getAlbumName` not finding album name whilst on queue page
- Added all mediaInfo to mpris interface using the `custom:` prefix
## [5.14]
- Simplified `MediaInfo` & `Options` types
- Added `playingFrom` information to the info API
- also changed the way we update Album info since Playing From now shows the correct Album.
- API now allows you to set the `hostname` so you can control who can interact with the API.
- Reworked swagger generation hotfix to properly generate `swagger.json` during the compile step
- Might switch to tsoa in the future, idk yet.
- Added [Tidal Magazine](https://tidal.com/magazine/) integration (in the menubar or use `Ctrl + M`)
## [5.13.1]
- removed Swagger generation step in favor of pre-generated file.
- This also fixes the API issue [#409](https://github.com/Mastermindzh/tidal-hifi/issues/409)
- This also stops TIDAL-hifi from scanning your entire home directory... the glob was very broad apparently.
## [5.13.0]
- Fixed [#403](https://github.com/Mastermindzh/tidal-hifi/issues/403) "cannot read shuffle of undefined" error
- Added an API to add & delete entries from the skippedArtists list in the settings. fixes [#405](https://github.com/Mastermindzh/tidal-hifi/issues/405)
- `GET /settings/skipped-artists` -> get list of skipped artists
- `POST /settings/skipped-artists` -> add to the list of skipped artists
- `POST /settings/skipped-artists/delete` -> delete from the list of skipped artists
- `POST /settings/skipped-artists/current` -> skip the current artist
- `DELETE /settings/skipped-artists/current` -> delete the current artist from the skip list
- Added Swagger documentation to the new endpoints:
![picture of swagger documentation](./docs/images/swagger.png)
- CORS support added by [Mjokfox](https://github.com/Mjokfox)
## [5.12.0]
- Added Shuffle and Repeat state to API response - By [ThatGravyBoat](https://github.com/ThatGravyBoat)
## [5.11.0]
- Re-implemented the API, added support for duration/current in seconds & shuffle+repeat
- made the original API "legacy" (still works the same)
- Now using the correct HTTP verb for all new endpoints
- Implemented TIDAL's universal links. All links are now universal.
- Custom `tidal://` protocol fixed - By [TheRockYT](https://github.com/TheRockYT)
- Global media shortcuts removed since TIDAL includes them by default - By [TheRockYT](https://github.com/TheRockYT)
- Fixes
- [#390](https://github.com/Mastermindzh/tidal-hifi/issues/390)
- [#376](https://github.com/Mastermindzh/tidal-hifi/issues/376)
- [#383](https://github.com/Mastermindzh/tidal-hifi/issues/383)
- [#393](https://github.com/Mastermindzh/tidal-hifi/issues/393)
## [5.10.0]
- TIDAL will now close the previous notification if a new one is sent whilst the old is still visible. [#364](https://github.com/Mastermindzh/tidal-hifi/pull/364)
- Updated developer documentation to get started in README [#365](https://github.com/Mastermindzh/tidal-hifi/pull/365)
- Links in the about window now open in the user's default browser. fixes [#360](https://github.com/Mastermindzh/tidal-hifi/issues/360)
- Refactored "nowPlaying" code to always display the current state, even when the built-in UI is updated.
- fixes [#351](https://github.com/Mastermindzh/tidal-hifi/issues/351)
- fixes [#356](https://github.com/Mastermindzh/tidal-hifi/issues/356)
- fixes [#370](https://github.com/Mastermindzh/tidal-hifi/issues/370)
- Reverted to using old icon syntax with icons in the build directory. fixes [#350](https://github.com/Mastermindzh/tidal-hifi/issues/350)
- Enabled wayland platform flags by default when launching through .desktop file
- fixes [#273](https://github.com/Mastermindzh/tidal-hifi/issues/273)
- fixes [#347](https://github.com/Mastermindzh/tidal-hifi/issues/347)
## [5.9.0]
- More Discord options:
- Added the ability to hide the current song from the discord activity and display a custom text instead
- Added the ability to customize the text that is shown when no song is playing
- Discord now reacts to pausing/unpausing events
- Refactored media info updates so it only updates the required info, fixes #342, #306
- Added 5.9.0 logs/versions/migrations
### Fixed
- Fixed chromium mediaSession instance showing up. fixes #338 #198
- Set a new icon, should fix #302
- Made sure settingsWindow exists before operating on it. fixes #344
## [5.8.0]
- Updated Electron to 28.1.1 (fixes [325](https://github.com/Mastermindzh/tidal-hifi/issues/325))
- Updated dependencies to latest
- added theme files to stylelint ignore
- fixed other stylelint errors
- Added functionality to favorite a song (fixes [#323](https://github.com/Mastermindzh/tidal-hifi/issues/323))
- Added a hotkey to favorite ("Add to collection") songs: Control+a
- Added the "favorite" field in the `mediaInfo` and the API `/current` endpoint
- Added an endpoint to toggle favoriting a song: `http://localhost:47836/favorite/toggle`
- Fixed wrong "end time stamp" for currently playing song (fixes [#282](https://github.com/Mastermindzh/tidal-hifi/issues/282))
- Affected the API + all integrations
- As requested we also added toggle to sync the timestamps to Discord (default = true)
## [5.7.1]
- Fixed mpris not being set up correctly due to capitalization of the instance name.
## [5.7.0]
- Renamed app to TIDAL Hi-Fi.
- Made sure all windows run with the same web preferences set (compared to main app).
- Fixes the last.fm bug.
- Added settings to customize the Discord rich presence information
- Discord settings are now also collapsible like the ListenBrainz ones are
- Restyled settings menu to include version number and useful links on the about page
![The new about page](./docs/images/new-about.png)
- The ListenBrainz integration has been extended with a configurable (5 seconds by default) delay in song reporting so that it doesn't spam the API when you are cycling through songs.
- Custom CSS now also applies to settings window
![Tokyo Night theme on settings window](./docs/images/customcss-menu.png)
## [5.6.0]
- Added support for Wayland (on by default) fixes [#262](https://github.com/Mastermindzh/tidal-hifi/issues/262) and [#157](https://github.com/Mastermindzh/tidal-hifi/issues/157)
- Made it clear in the readme that this TIDAL Hi-Fi client supports High & Max audio settings. fixes [#261](https://github.com/Mastermindzh/tidal-hifi/issues/261)
- Added app suspension inhibitors when music is playing. fixes [#257](https://github.com/Mastermindzh/tidal-hifi/issues/257)
- Fixed bug with theme files from user directory trying to load: "an error occurred reading the theme file"
- Fixed: config flags not being set correctly
- [DEV]:
- Logger is now static and will automatically call either ipcRenderer or ipcMain
## 5.5.0
- ListenBrainz integration added (thanks @Mar0xy)
## 5.4.0
- Removed Windows builds (from publishes) as they don't work anymore.
- Added [Songwhip](https://songwhip.com/) integration
- Fixed bug with several hotkeys not working due to Tidal's HTML/css changes
- [DEV]:
- added a logger to log into STDout
- added "watchStart" which will automatically restart electron when it detects a source code change
- added "listen.tidal.com-parsing-scripts" folder with a script to verify whether all elements (in the main preload.ts) are present on the page
## 5.3.0 ## 5.3.0
- SPKChaosPhoenix updated the beautiful Tokyo Night theme: - SPKChaosPhoenix updated the beautiful Tokyo Night theme:
![tidal with the tokyo night theme applied](./docs/images/tokyo-night.png) ![](./docs/images/tokyo-night.png)
## 5.2.0 ## 5.2.0
@ -245,7 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New settings window by BlueManCZ - New settings window by BlueManCZ
- Fixed the desktop files in electron-builder - Fixed the desktop files in electron-builder
- icon is set to new static path based on Arch/Debian - icon is set to new static path based on Arch/Debian
- Name has changed to TIDAL Hi-Fi - Name has changed to Tidal-Hifi
## 4.1.2 ## 4.1.2
@ -293,7 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated to Electron 15 - Updated to Electron 15
- Fixed the develop "build-unpacked" command - Fixed the develop "build-unpacked" command
- Added setting to disable multiple TIDAL Hi-Fi windows (defaults to true) - Added setting to disable multiple tidal-hifi windows (defaults to true)
- Added setting to disable HardwareMediaKeyHandling (defaults to false) - Added setting to disable HardwareMediaKeyHandling (defaults to false)
## 2.8.2 ## 2.8.2
@ -331,7 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 2.5.0 ## 2.5.0
- Notify-send now correctly shows "Tidal Hi-Fi" as the program name - Notify-send now correctly shows "Tidal HiFi" as the program name
- Updated dependencies (including electron itself) - Updated dependencies (including electron itself)
### known issues ### known issues

View File

@ -1,20 +1,20 @@
# TIDAL Hi-Fi (Max quality)<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) [![github builds](https://github.com/mastermindzh/tidal-hifi/actions/workflows/build.yml/badge.svg)](https://github.com/Mastermindzh/tidal-hifi/actions) [![Build Status](https://ci.mastermindzh.tech/api/badges/Mastermindzh/tidal-hifi/status.svg)](https://ci.mastermindzh.tech/Mastermindzh/tidal-hifi) [![Discord logo](./docs/images/discord.png)](https://discord.gg/yhNwf4v4He) ![GitHub release](https://img.shields.io/github/release/Mastermindzh/tidal-hifi.svg) [![github builds](https://github.com/mastermindzh/tidal-hifi/actions/workflows/build.yml/badge.svg)](https://github.com/Mastermindzh/tidal-hifi/actions) [![Build Status](https://ci.mastermindzh.tech/api/badges/Mastermindzh/tidal-hifi/status.svg)](https://ci.mastermindzh.tech/Mastermindzh/tidal-hifi) [![Discord logo](./docs/images/discord.png)](https://discord.gg/yhNwf4v4He)
The web version of [listen.tidal.com](https://listen.tidal.com) running in electron with Hi-Fi (High & Max) 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 Hi-Fi preview](./docs/images/preview.png) ![tidal-hifi preview](./docs/images/preview.png)
## Table of Contents ## Table of Contents
<!-- toc --> <!-- toc -->
- [TIDAL Hi-Fi (Max quality)](#tidal-hi-fi-max-quality) - [Tidal-hifi](#tidal-hifi)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Features](#features) - [Features](#features)
- [Contributions](#contributions) - [Contributions](#contributions)
- [Why did I create TIDAL Hi-Fi?](#why-did-i-create-tidal-hi-fi) - [Why did I create tidal-hifi?](#why-did-i-create-tidal-hifi)
- [Why not extend existing projects?](#why-not-extend-existing-projects) - [Why not extend existing projects?](#why-not-extend-existing-projects)
- [Installation](#installation) - [Installation](#installation)
- [Dependencies](#dependencies) - [Dependencies](#dependencies)
@ -25,8 +25,8 @@ The web version of [listen.tidal.com](https://listen.tidal.com) running in elect
- [Nix](#nix) - [Nix](#nix)
- [Using source](#using-source) - [Using source](#using-source)
- [Integrations](#integrations) - [Integrations](#integrations)
- [Known bugs](#known-bugs) - [Known bugs](#known-bugs)
- [DRM not working on Windows (error S6007)](#drm-not-working-on-windows-error-s6007) - [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)
- [Special thanks to](#special-thanks-to) - [Special thanks to](#special-thanks-to)
- [Donations](#donations) - [Donations](#donations)
- [Images](#images) - [Images](#images)
@ -37,33 +37,27 @@ The web version of [listen.tidal.com](https://listen.tidal.com) running in elect
## Features ## Features
- HiFi playback (High & Max settings) - HiFi playback
- Notifications - Notifications
- Custom [theming](./docs/theming.md) - Custom [theming](./docs/theming.md)
- Custom hotkeys ([source](https://defkey.com/tidal-desktop-shortcuts)) - Custom hotkeys ([source](https://defkey.com/tidal-desktop-shortcuts))
- Better icons thanks to [Papirus-icon-theme](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme/) - API for status and playback
- [Settings feature](./docs/images/settings.png) to disable certain functionality. (`ctrl+=` or `ctrl+0`)
- API for status, playback and settings (see the [/docs](http://localhost:47836/docs/) route)
- 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)) - 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))
- AlbumArt in integrations ([best-effort](https://github.com/Mastermindzh/tidal-hifi/pull/88#pullrequestreview-840814847))
- Custom [integrations](#integrations) - Custom [integrations](#integrations)
- [ListenBrainz](https://listenbrainz.org/?redirect=false) integration - [Settings feature](./docs/images/settings.png) to disable certain functionality. (`ctrl+=` or `ctrl+0`)
- Discord RPC integration (showing "now listening", "Browsing", etc) - AlbumArt in integrations ([best-effort](https://github.com/Mastermindzh/tidal-hifi/pull/88#pullrequestreview-840814847))
- Flatpak version only works if both Discord and Tidal-HiFi are flatpaks
- MPRIS integration
- UI + Json config (`~/.config/tidal-hifi/`, or `~/.var/app/com.mastermindzh.tidal-hifi/` for Flatpak)
## Contributions ## Contributions
To contribute you can use the standard GitHub features (issues, prs, etc.) or join the discord server to talk with like-minded individuals. 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) - ![Discord logo](./docs/images/discord.png) [Join the Discord server](https://discord.gg/yhNwf4v4He)
## Why did I create TIDAL Hi-Fi? ## Why did I create tidal-hifi?
I moved from Spotify over to Tidal and found Linux support to be lacking. 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. When I started this project there weren't any Linux apps that offered Tidal's "hifi" options nor any scripts to control it.
I made this app to support the highest quality audio available on the Linux platform. It used to be "hifi" but now is ["High & Max"](https://tidal.com/sound-quality).
### Why not extend existing projects? ### Why not extend existing projects?
@ -71,10 +65,10 @@ Whilst there are a handful of projects attempting to run Tidal on Electron they
- Lack of maintainers/developers. (no hotfixes, no issues being handled etc) - Lack of maintainers/developers. (no hotfixes, no issues being handled etc)
- Most are simple web wrappers, not my cup of tea. - Most are simple web wrappers, not my cup of tea.
- Some are DE-oriented. I want this to work on WM's too. - Some are DE oriented. I want this to work on WM's too.
- None have Widevine working at the moment - None have widevine working at the moment
Sometimes it's just easier to start over, cover my own needs and after that making it available to the public :) Sometimes it's just easier to start over, cover my own needs and then making it available to the public :)
## Installation ## Installation
@ -104,10 +98,10 @@ To install with `snap` you need to download the pre-packaged snap-package from t
### Arch Linux ### Arch Linux
Arch Linux users can use the AUR to install TIDAL Hi-Fi: Arch Linux users can use the AUR to install tidal-hifi:
```sh ```sh
trizen tidal-hifi-git trizen tidal-hifi-bin
``` ```
### Flatpak ### Flatpak
@ -130,34 +124,34 @@ nix-env -iA nixpkgs.tidal-hifi
To install and work with the code on this project follow these steps: To install and work with the code on this project follow these steps:
- `git clone https://github.com/Mastermindzh/tidal-hifi.git` - git clone [https://github.com/Mastermindzh/tidal-hifi.git](https://github.com/Mastermindzh/tidal-hifi.git)
- `cd tidal-hifi` - cd tidal-hifi
- `npm install` - npm install
- `npm run watch` to watch for auto-reload of Typescript/SCSS changes. - npm start
- `npm run compile` can be used to trigger it once
- `npm watchStart` to auto watch for any updates files and reload Tidal Hi-Fi
- `npm start` can be used to run Tidal Hi-Fi manually once
## Integrations ## Integrations
TIDAL Hi-Fi 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/images/integrations.png) ![integrations menu, showing a list of integrations](./docs/images/integrations.png)
Integrations with other projects that are not included natively: It currently includes:
- MPRIS - MPRIS media player controls/status
- Discord - Shows what you're listening to on Discord.
Not included:
- [i3 blocks config](https://github.com/Mastermindzh/dotfiles/commit/9714b2fa1d670108ce811d5511fd3b7a43180647) - My dotfiles where I use this app to fetch currently playing music (direct commit) - [i3 blocks config](https://github.com/Mastermindzh/dotfiles/commit/9714b2fa1d670108ce811d5511fd3b7a43180647) - My dotfiles where I use this app to fetch currently playing music (direct commit)
- [neptune](https://github.com/uwu/neptune) third party plugins & theming
## Known bugs ### Known bugs
### DRM not working on Windows (error S6007) #### last.fm doesn't work out of the box. Use rescrobbler as a workaround
Most Windows users run into DRM issues when trying to use TIDAL Hi-Fi. The last.fm login doesn't work, as is evident from the following issue: [Last.fm login doesn't work](https://github.com/Mastermindzh/tidal-hifi/issues/4).
Nothing I can do about that I'm afraid... Tidal is working on removing/changing DRM so when they finish with that we can give it another shot. 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.
Until then you'll have to use the official app unfortunately.
## Special thanks to ## Special thanks to

View File

@ -1,11 +0,0 @@
# Security Policy
## Supported Versions
Only the very latest 😄.
## Reporting a Vulnerability
If you find a vulnerability just add it as an issue.
If there's an especially bad vulnerability that you don't want to make public just send me a private message (email, discord, wherever).

BIN
assets/TIDAL.icns Executable file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,7 +1,7 @@
appId: com.rickvanlieshout.tidal-hifi appId: com.rickvanlieshout.tidal-hifi
electronVersion: 35.1.1 electronVersion: 24.1.2
electronDownload: electronDownload:
version: 35.1.1+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:
@ -11,30 +11,24 @@ extraResources:
- "themes/**" - "themes/**"
linux: linux:
category: AudioVideo category: AudioVideo
icon: build/icons icon: assets/icons
target: target:
- dir - dir
executableName: tidal-hifi executableName: tidal-hifi
executableArgs:
[
"--enable-features=UseOzonePlatform",
"--ozone-platform-hint=auto",
"--enable-features=WaylandWindowDecorations",
]
desktop: desktop:
entry: Encoding: UTF-8
Encoding: "UTF-8" 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: tidal-hifi
Icon: "tidal-hifi" StartupNotify: true
StartupNotify: "true" Terminal: false
Terminal: "false" Type: Application
Type: "Application" Categories: Network;Application;AudioVideo;Audio;Video
Categories: "Network;Application;AudioVideo;Audio;Video" StartupWMClass: tidal-hifi
StartupWMClass: "tidal-hifi" X-PulseAudio-Properties: media.role=music
X-PulseAudio-Properties: "media.role=music" MimeType: x-scheme-handler/tidal;
MimeType: "x-scheme-handler/tidal;"
mac: mac:
category: public.app-category.entertainment category: public.app-category.entertainment
win: win:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 102 KiB

BIN
build/icon.icns Normal file → Executable file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@ -1,10 +1,10 @@
# Theming TIDAL Hi-Fi # Theming tidal-hifi
## Table of contents ## Table of contents
<!-- toc --> <!-- toc -->
- [Theming TIDAL Hi-Fi](#theming-TIDAL Hi-Fi) - [Theming tidal-hifi](#theming-tidal-hifi)
- [Table of contents](#table-of-contents) - [Table of contents](#table-of-contents)
- [Custom CSS](#custom-css) - [Custom CSS](#custom-css)
- [config](#config) - [config](#config)
@ -12,7 +12,7 @@
<!-- tocstop --> <!-- tocstop -->
By default TIDAL Hi-Fi comes with a few themes. By default tidal-hifi comes with a few themes.
You can select these in the settings window under the theming tab as shown below. 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) ![Settings window with the theming tab opened](./images/theming.png)

6960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,14 @@
{ {
"name": "tidal-hifi", "name": "tidal-hifi",
"version": "5.18.2", "version": "5.3.0",
"description": "Tidal on Electron with widevine(hifi) support", "description": "Tidal on Electron with widevine(hifi) support",
"main": "ts-dist/main.js", "main": "ts-dist/main.js",
"scripts": { "scripts": {
"start": "electron --inspect=0.0.0.0:5858 --remote-debugging-port=8315 --remote-allow-origins=* .", "start": "electron .",
"watchStart": "nodemon dist -x \"npm run start\"",
"compile": "tsc && npm run sass-and-copy", "compile": "tsc && npm run sass-and-copy",
"deps": "npm run watch", "watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
"watch": "tsc-watch --onSuccess \"npm run compile-all\"",
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist", "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", "copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
"compile-all": "npm run sass-and-copy && ts-node scripts/generate-swagger.ts",
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev", "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",
@ -32,52 +29,41 @@
"electron", "electron",
"hifi", "hifi",
"widevine", "widevine",
"linux", "linux"
"drm",
"castlabs"
], ],
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)", "author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
"homepage": "https://github.com/Mastermindzh/tidal-hifi", "homepage": "https://github.com/Mastermindzh/tidal-hifi",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron/remote": "^2.1.2", "@electron/remote": "^2.0.9",
"@types/swagger-jsdoc": "^6.0.4", "discord-rpc": "^4.0.1",
"@xhayper/discord-rpc": "^1.2.1", "electron-store": "^8.1.0",
"axios": "^1.8.4", "express": "^4.18.2",
"electron-store": "^8.2.0", "hotkeys-js": "^3.10.2",
"express": "^4.21.2",
"hotkeys-js": "^3.13.9",
"mpris-service": "^2.1.2", "mpris-service": "^2.1.2",
"sass": "1.86.0", "request": "^2.88.2",
"swagger-ui-express": "^5.0.1", "sass": "^1.62.0"
"cors": "^2.8.5",
"request": "^2.88.2"
}, },
"devDependencies": { "devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0", "@mastermindzh/prettier-config": "^1.0.0",
"@types/cors": "^2.8.17", "@types/discord-rpc": "^4.0.4",
"@types/express": "^4.17.21", "@types/express": "^4.17.17",
"@types/node": "^20.14.10", "@types/request": "^2.48.8",
"@types/request": "^2.48.12", "@typescript-eslint/eslint-plugin": "^5.59.1",
"@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/parser": "^5.59.1",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.15.0",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"electron": "github:castlabs/electron-releases#v35.1.1+wvcus", "electron": "git+https://github.com/castlabs/electron-releases.git#v24.1.2+wvcus",
"electron-builder": "~26.0.12", "electron-builder": "^24.2.1",
"eslint": "^8.57.0", "eslint": "^8.39.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"markdown-toc": "^1.2.0", "markdown-toc": "^1.2.0",
"nodemon": "^3.1.4", "prettier": "^2.8.8",
"prettier": "^3.3.2", "stylelint": "^15.6.0",
"stylelint": "^16.6.1", "stylelint-config-standard": "^33.0.0",
"stylelint-config-standard": "^36.0.1", "stylelint-config-standard-scss": "^9.0.0",
"stylelint-config-standard-scss": "^13.1.0", "stylelint-prettier": "^3.0.0",
"stylelint-prettier": "^5.0.0", "tsc-watch": "^6.0.4",
"swagger-jsdoc": "^6.2.8", "typescript": "^5.0.4"
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.5.3"
}, },
"prettier": "@mastermindzh/prettier-config" "prettier": "@mastermindzh/prettier-config"
} }

View File

@ -1,30 +0,0 @@
import fs from "fs";
import swaggerjsdoc from "swagger-jsdoc";
import packagejson from "./../package.json";
const specs = swaggerjsdoc({
definition: {
openapi: "3.1.0",
info: {
title: "TIDAL Hi-Fi API",
version: packagejson.version,
description: "",
license: {
name: packagejson.license,
url: "https://github.com/Mastermindzh/tidal-hifi/blob/master/LICENSE",
},
contact: {
name: "Rick <mastermindzh> van Lieshout",
url: "https://www.rickvanlieshout.com",
},
},
externalDocs: {
description: "swagger.json",
url: "swagger.json",
},
},
apis: ["**/*.ts"],
});
fs.writeFileSync("src/features/api/swagger.json", JSON.stringify(specs, null, 2), "utf8");
console.log("Written swagger.json");

View File

@ -1,16 +0,0 @@
#!/bin/bash
if [ "$1" != "" ]; then # check if arg 1 is present
FILE=$1
else
echo "Please provide a file as an argument."
exit 1
fi
SIZES=("16x16" "22x22" "24x24" "32x32" "48x48" "64x64" "128x128" "256x256" "384x384")
echo "Resizing $FILE..."
for i in "${SIZES[@]}"; do
convert "$FILE" -resize "$i" "$i.png"
done

View File

@ -1,48 +0,0 @@
// for some dumb reason `listen.tidal.com` has disabled console.log
// we can simply return an array with values though...
// run this on a playlist or mix page and observe the result
// NOTE: play & pause can't live together so one or the other will throw an error
(() => {
let elements = {
play: '*[data-test="play"]',
pause: '*[data-test="pause"]',
next: '*[data-test="next"]',
previous: 'button[data-test="previous"]',
title: '*[data-test^="footer-track-title"]',
artists: '*[data-test^="grid-item-detail-text-title-artist"]',
home: '*[data-test="menu--home"]',
back: '[title^="Back"]',
forward: '[title^="Next"]',
search: '[class^="searchField"]',
shuffle: '*[data-test="shuffle"]',
repeat: '*[data-test="repeat"]',
account: '*[data-test^="profile-image-button"]',
settings: '*[data-test^="sidebar-menu-button"]',
openSettings: '*[data-test^="open-settings"]',
media: '*[data-test="current-media-imagery"]',
image: "img",
current: '*[data-test="current-time"]',
duration: '*[class^=_playbackControlsContainer] *[data-test="duration"]',
bar: '*[data-test="progress-bar"]',
footer: "#footerPlayer",
mediaItem: "[data-type='mediaItem']",
album_header_title: '*[class^="_playingFrom"] span:nth-child(2)',
playing_from: '*[class^="_playingFrom"] span:nth-child(2)',
queue_album: "*[class^=playQueueItemsContainer] *[class^=groupTitle] span:nth-child(2)",
currentlyPlaying: "[class^='isPlayingIcon'], [data-test-is-playing='true']",
album_name_cell: '[class^="album"]',
tracklist_row: '[data-test="tracklist-row"]',
volume: '*[data-test="volume"]',
favorite: '*[data-test="footer-favorite-button"]',
};
let results = [];
Object.entries(elements).forEach(([key, value]) => {
const returnValue = document.querySelector(`${value}`);
if (!returnValue) {
results.push(`element ${key} not found`);
}
});
return results;
})();

View File

@ -1,9 +1,4 @@
export const flags: { [key: string]: { flag: string; value?: string }[] } = { 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" }],
enableWaylandSupport: [
{ flag: "enable-features", value: "UseOzonePlatform" },
{ flag: "ozone-platform-hint", value: "auto" },
{ flag: "enable-features", value: "WaylandWindowDecorations" },
],
}; };

View File

@ -10,9 +10,4 @@ export const globalEvents = {
showSettings: "showSettings", showSettings: "showSettings",
storeChanged: "storeChanged", storeChanged: "storeChanged",
error: "error", error: "error",
getUniversalLink: "getUniversalLink",
log: "log",
toggleFavorite: "toggleFavorite",
toggleShuffle: "toggleShuffle",
toggleRepeat: "toggleRepeat",
}; };

View File

@ -10,42 +10,20 @@
*/ */
export const settings = { export const settings = {
adBlock: "adBlock", adBlock: "adBlock",
advanced: {
root: "advanced",
tidalUrl: "advanced.tidalUrl",
},
api: "api", api: "api",
apiSettings: { apiSettings: {
root: "apiSettings", root: "apiSettings",
port: "apiSettings.port", port: "apiSettings.port",
hostname: "apiSettings.hostname",
}, },
customCSS: "customCSS", customCSS: "customCSS",
disableBackgroundThrottle: "disableBackgroundThrottle", disableBackgroundThrottle: "disableBackgroundThrottle",
disableHardwareMediaKeys: "disableHardwareMediaKeys", disableHardwareMediaKeys: "disableHardwareMediaKeys",
enableCustomHotkeys: "enableCustomHotkeys", enableCustomHotkeys: "enableCustomHotkeys",
enableDiscord: "enableDiscord", enableDiscord: "enableDiscord",
discord: {
detailsPrefix: "discord.detailsPrefix",
buttonText: "discord.buttonText",
includeTimestamps: "discord.includeTimestamps",
showSong: "discord.showSong",
showIdle: "discord.showIdle",
idleText: "discord.idleText",
usingText: "discord.usingText",
},
ListenBrainz: {
root: "ListenBrainz",
enabled: "ListenBrainz.enabled",
api: "ListenBrainz.api",
token: "ListenBrainz.token",
delay: "ListenBrainz.delay",
},
flags: { flags: {
root: "flags", root: "flags",
disableHardwareMediaKeys: "flags.disableHardwareMediaKeys", disableHardwareMediaKeys: "flags.disableHardwareMediaKeys",
gpuRasterization: "flags.gpuRasterization", gpuRasterization: "flags.gpuRasterization",
enableWaylandSupport: "flags.enableWaylandSupport",
}, },
menuBar: "menuBar", menuBar: "menuBar",
minimizeOnClose: "minimizeOnClose", minimizeOnClose: "minimizeOnClose",
@ -55,7 +33,6 @@ export const settings = {
singleInstance: "singleInstance", singleInstance: "singleInstance",
skipArtists: "skipArtists", skipArtists: "skipArtists",
skippedArtists: "skippedArtists", skippedArtists: "skippedArtists",
staticWindowTitle: "staticWindowTitle",
theme: "theme", theme: "theme",
trayIcon: "trayIcon", trayIcon: "trayIcon",
updateFrequency: "updateFrequency", updateFrequency: "updateFrequency",

View File

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

View File

@ -1,3 +1,3 @@
export default { export default {
name: "TIDAL Hi-Fi", name: "tidal-hifi",
}; };

View File

@ -1,123 +0,0 @@
import { Request, Response, Router } from "express";
import fs from "fs";
import { mediaInfo } from "../../../scripts/mediaInfo";
export const addCurrentInfo = (expressApp: Router) => {
/**
* @swagger
* tags:
* name: current
* description: The current media info API
* components:
* schemas:
* MediaInfo:
* type: object
* properties:
* title:
* type: string
* artists:
* type: string
* album:
* type: string
* icon:
* type: string
* format: uri
* playingFrom:
* type: string
* status:
* type: string
* url:
* type: string
* format: uri
* current:
* type: string
* currentInSeconds:
* type: integer
* duration:
* type: string
* durationInSeconds:
* type: integer
* image:
* type: string
* format: uri
* favorite:
* type: boolean
* player:
* type: object
* properties:
* status:
* type: string
* shuffle:
* type: boolean
* repeat:
* type: string
* artist:
* type: string
* example:
* title: "Sample Title"
* artists: "Sample Artist"
* album: "Sample Album"
* icon: "/path/to/sample/icon.jpg"
* playingFrom: "Sample Playlist"
* status: "playing"
* url: "https://tidal.com/browse/track/sample"
* current: "1:23"
* currentInSeconds: 83
* duration: "3:45"
* durationInSeconds: 225
* image: "https://example.com/sample-image.jpg"
* favorite: true
* player:
* status: "playing"
* shuffle: true
* repeat: "one"
* artist: "Sample Artist"
*/
/**
* @swagger
* /current:
* get:
* summary: Get current media info
* tags: [current]
* responses:
* 200:
* description: Current media info
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/MediaInfo'
*/
expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists }));
/**
* @swagger
* /current/image:
* get:
* summary: Get current media image
* tags: [current]
* responses:
* 200:
* description: Current media image
* content:
* image/png:
* schema:
* type: string
* format: binary
* 404:
* description: Not found
*/
expressApp.get("/current/image", getCurrentImage);
};
export const getCurrentImage = (req: Request, res: Response) => {
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");
});
};

View File

@ -1,164 +0,0 @@
import { BrowserWindow } from "electron";
import { Router } from "express";
import { globalEvents } from "../../../constants/globalEvents";
import { settings } from "../../../constants/settings";
import { MediaStatus } from "../../../models/mediaStatus";
import { mediaInfo } from "../../../scripts/mediaInfo";
import { settingsStore } from "../../../scripts/settings";
import { handleWindowEvent } from "../helpers/handleWindowEvent";
export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow) => {
const windowEvent = handleWindowEvent(mainWindow);
const createRoute = (route: string) => `/player${route}`;
/**
* @swagger
* tags:
* name: player
* description: The player control API
* components:
* schemas:
* OkResponse:
* type: string
* example: "OK"
*/
const createPlayerAction = (route: string, action: string) => {
expressApp.post(createRoute(route), (req, res) => windowEvent(res, action));
};
if (settingsStore.get(settings.playBackControl)) {
/**
* @swagger
* /player/play:
* post:
* summary: Play the current media
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/play", globalEvents.play);
/**
* @swagger
* /player/favorite/toggle:
* post:
* summary: Add the current media to your favorites, or remove it if its already added to your favorites
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/favorite/toggle", globalEvents.toggleFavorite);
/**
* @swagger
* /player/pause:
* post:
* summary: Pause the current media
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/pause", globalEvents.pause);
/**
* @swagger
* /player/next:
* post:
* summary: Play the next song
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/next", globalEvents.next);
/**
* @swagger
* /player/previous:
* post:
* summary: Play the previous song
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/previous", globalEvents.previous);
/**
* @swagger
* /player/shuffle/toggle:
* post:
* summary: Play the previous song
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/shuffle/toggle", globalEvents.toggleShuffle);
/**
* @swagger
* /player/repeat/toggle:
* post:
* summary: Toggle the repeat status, toggles between "off" , "single" and "all"
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
createPlayerAction("/repeat/toggle", globalEvents.toggleRepeat);
/**
* @swagger
* /player/playpause:
* post:
* summary: Start playing the media if paused, or pause the media if playing
* tags: [player]
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.post(createRoute("/playpause"), (req, res) => {
if (mediaInfo.status === MediaStatus.playing) {
windowEvent(res, globalEvents.pause);
} else {
windowEvent(res, globalEvents.play);
}
});
}
};

View File

@ -1,121 +0,0 @@
import { Request, Router } from "express";
import { settings } from "../../../../constants/settings";
import { mediaInfo } from "../../../../scripts/mediaInfo";
import {
addSkippedArtists,
removeSkippedArtists,
settingsStore,
} from "../../../../scripts/settings";
import { BrowserWindow } from "electron";
import { globalEvents } from "../../../../constants/globalEvents";
/**
* @swagger
* tags:
* name: settings
* description: The settings management API
* components:
* schemas:
* StringArray:
* type: array
* items:
* type: string
* example: ["Artist1", "Artist2"]
*
* @param expressApp
* @param mainWindow
*/
export const addSettingsAPI = (expressApp: Router, mainWindow: BrowserWindow) => {
/**
* @swagger
* /settings/skipped-artists:
* get:
* summary: get a list of artists that TIDAL Hi-Fi will skip if skipping is enabled
* tags: [settings]
* responses:
* 200:
* description: The list book.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StringArray'
*/
expressApp.get("/settings/skipped-artists", (req, res) => {
res.json(settingsStore.get<string, string[]>(settings.skippedArtists));
});
/**
* @swagger
* /settings/skipped-artists:
* post:
* summary: Add new artists to the list of skipped artists
* tags: [settings]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StringArray'
* responses:
* 200:
* description: Ok
*/
expressApp.post("/settings/skipped-artists", (req: Request<object, object, string[]>, res) => {
addSkippedArtists(req.body);
res.sendStatus(200);
});
/**
* @swagger
* /settings/skipped-artists/delete:
* post:
* summary: Remove artists from the list of skipped artists
* tags: [settings]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StringArray'
* responses:
* 200:
* description: Ok
*/
expressApp.post(
"/settings/skipped-artists/delete",
(req: Request<object, object, string[]>, res) => {
removeSkippedArtists(req.body);
res.sendStatus(200);
}
);
/**
* @swagger
* /settings/skipped-artists/current:
* post:
* summary: Add the current artist to the list of skipped artists
* tags: [settings]
* responses:
* 200:
* description: Ok
*/
expressApp.post("/settings/skipped-artists/current", (req, res) => {
addSkippedArtists([mediaInfo.artists]);
mainWindow.webContents.send("globalEvent", globalEvents.next);
res.sendStatus(200);
});
/**
* @swagger
* /settings/skipped-artists/current:
* delete:
* summary: Remove the current artist from the list of skipped artists
* tags: [settings]
* responses:
* 200:
* description: Ok
*/
expressApp.delete("/settings/skipped-artists/current", (req, res) => {
removeSkippedArtists([mediaInfo.artists]);
res.sendStatus(200);
});
};

View File

@ -1,12 +0,0 @@
import { BrowserWindow } from "electron";
import { Response } from "express";
/**
* Shorthand to handle a fire and forget global event
* @param {*} res
* @param {*} action
*/
export const handleWindowEvent = (mainWindow: BrowserWindow) => (res: Response, action: string) => {
mainWindow.webContents.send("globalEvent", action);
res.sendStatus(200);
};

View File

@ -1,42 +0,0 @@
import cors from "cors";
import { BrowserWindow, dialog } from "electron";
import express from "express";
import swaggerUi from "swagger-ui-express";
import { settingsStore } from "../../scripts/settings";
import { settings } from "./../../constants/settings";
import { addCurrentInfo } from "./features/current";
import { addPlaybackControl } from "./features/player";
import { addSettingsAPI } from "./features/settings/settings";
import { addLegacyApi } from "./legacy";
import swaggerSpec from "./swagger.json";
/**
* Function to enable TIDAL Hi-Fi's express api
*/
export const startApi = (mainWindow: BrowserWindow) => {
const port = settingsStore.get<string, number>(settings.apiSettings.port);
const hostname = settingsStore.get<string, string>(settings.apiSettings.hostname) ?? "127.0.0.1";
const expressApp = express();
expressApp.use(cors());
expressApp.use(express.json());
expressApp.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
expressApp.get("/", (req, res) => res.send("Hello World!"));
expressApp.get("/swagger.json", (req, res) => res.json(swaggerSpec));
// add features
addLegacyApi(expressApp, mainWindow);
addPlaybackControl(expressApp, mainWindow);
addCurrentInfo(expressApp);
addSettingsAPI(expressApp, mainWindow);
const expressInstance = expressApp.listen(port, hostname);
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,160 +0,0 @@
import { BrowserWindow } from "electron";
import { Response, Router } from "express";
import { globalEvents } from "../../constants/globalEvents";
import { settings } from "../../constants/settings";
import { MediaStatus } from "../../models/mediaStatus";
import { mediaInfo } from "../../scripts/mediaInfo";
import { settingsStore } from "../../scripts/settings";
import { getCurrentImage } from "./features/current";
/**
* The legacy API, this will not be maintained and probably has duplicate code :)
* @param expressApp
* @param mainWindow
*/
export const addLegacyApi = (expressApp: Router, mainWindow: BrowserWindow) => {
/**
* @swagger
* /image:
* get:
* summary: Get current image
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Current image
* content:
* image/png:
* schema:
* type: string
* format: binary
* 404:
* description: Not found
*/
expressApp.get("/image", getCurrentImage);
if (settingsStore.get(settings.playBackControl)) {
addLegacyControls();
}
function addLegacyControls() {
/**
* @swagger
* /play:
* get:
* summary: Play the current media
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Action performed
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.get("/play", ({ res }) => handleGlobalEvent(res, globalEvents.play));
/**
* @swagger
* /favorite/toggle:
* get:
* summary: Add the current media to your favorites, or remove it if its already added to your favorites
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.post("/favorite/toggle", (req, res) =>
handleGlobalEvent(res, globalEvents.toggleFavorite)
);
/**
* @swagger
* /pause:
* get:
* summary: Pause the current media
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause));
/**
* @swagger
* /next:
* get:
* summary: Play the next song
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next));
/**
* @swagger
* /previous:
* get:
* summary: Play the previous song
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous));
/**
* @swagger
* /playpause:
* get:
* summary: Toggle play/pause
* tags: [legacy]
* deprecated: true
* responses:
* 200:
* description: Ok
* content:
* text/plain:
* schema:
* $ref: '#/components/schemas/OkResponse'
*/
expressApp.get("/playpause", (req, res) => {
if (mediaInfo.status === MediaStatus.playing) {
handleGlobalEvent(res, globalEvents.pause);
} else {
handleGlobalEvent(res, globalEvents.play);
}
});
}
/**
* 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);
}
};

View File

@ -1,535 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "TIDAL Hi-Fi API",
"version": "5.18.2",
"description": "",
"license": {
"name": "MIT",
"url": "https://github.com/Mastermindzh/tidal-hifi/blob/master/LICENSE"
},
"contact": {
"name": "Rick <mastermindzh> van Lieshout",
"url": "https://www.rickvanlieshout.com"
}
},
"externalDocs": {
"description": "swagger.json",
"url": "swagger.json"
},
"paths": {
"/current": {
"get": {
"summary": "Get current media info",
"tags": ["current"],
"responses": {
"200": {
"description": "Current media info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MediaInfo"
}
}
}
}
}
}
},
"/current/image": {
"get": {
"summary": "Get current media image",
"tags": ["current"],
"responses": {
"200": {
"description": "Current media image",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"404": {
"description": "Not found"
}
}
}
},
"/player/play": {
"post": {
"summary": "Play the current media",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/favorite/toggle": {
"post": {
"summary": "Add the current media to your favorites, or remove it if its already added to your favorites",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/pause": {
"post": {
"summary": "Pause the current media",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/next": {
"post": {
"summary": "Play the next song",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/previous": {
"post": {
"summary": "Play the previous song",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/shuffle/toggle": {
"post": {
"summary": "Play the previous song",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/repeat/toggle": {
"post": {
"summary": "Toggle the repeat status, toggles between \"off\" , \"single\" and \"all\"",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/player/playpause": {
"post": {
"summary": "Start playing the media if paused, or pause the media if playing",
"tags": ["player"],
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/settings/skipped-artists": {
"get": {
"summary": "get a list of artists that TIDAL Hi-Fi will skip if skipping is enabled",
"tags": ["settings"],
"responses": {
"200": {
"description": "The list book.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StringArray"
}
}
}
}
}
},
"post": {
"summary": "Add new artists to the list of skipped artists",
"tags": ["settings"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StringArray"
}
}
}
},
"responses": {
"200": {
"description": "Ok"
}
}
}
},
"/settings/skipped-artists/delete": {
"post": {
"summary": "Remove artists from the list of skipped artists",
"tags": ["settings"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StringArray"
}
}
}
},
"responses": {
"200": {
"description": "Ok"
}
}
}
},
"/settings/skipped-artists/current": {
"post": {
"summary": "Add the current artist to the list of skipped artists",
"tags": ["settings"],
"responses": {
"200": {
"description": "Ok"
}
}
},
"delete": {
"summary": "Remove the current artist from the list of skipped artists",
"tags": ["settings"],
"responses": {
"200": {
"description": "Ok"
}
}
}
},
"/image": {
"get": {
"summary": "Get current image",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Current image",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"404": {
"description": "Not found"
}
}
}
},
"/play": {
"get": {
"summary": "Play the current media",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Action performed",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/favorite/toggle": {
"get": {
"summary": "Add the current media to your favorites, or remove it if its already added to your favorites",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/pause": {
"get": {
"summary": "Pause the current media",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/next": {
"get": {
"summary": "Play the next song",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/previous": {
"get": {
"summary": "Play the previous song",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
},
"/playpause": {
"get": {
"summary": "Toggle play/pause",
"tags": ["legacy"],
"deprecated": true,
"responses": {
"200": {
"description": "Ok",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/OkResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"MediaInfo": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"artists": {
"type": "string"
},
"album": {
"type": "string"
},
"icon": {
"type": "string",
"format": "uri"
},
"playingFrom": {
"type": "string"
},
"status": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"current": {
"type": "string"
},
"currentInSeconds": {
"type": "integer"
},
"duration": {
"type": "string"
},
"durationInSeconds": {
"type": "integer"
},
"image": {
"type": "string",
"format": "uri"
},
"favorite": {
"type": "boolean"
},
"player": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"shuffle": {
"type": "boolean"
},
"repeat": {
"type": "string"
}
}
},
"artist": {
"type": "string"
}
},
"example": {
"title": "Sample Title",
"artists": "Sample Artist",
"album": "Sample Album",
"icon": "/path/to/sample/icon.jpg",
"playingFrom": "Sample Playlist",
"status": "playing",
"url": "https://tidal.com/browse/track/sample",
"current": "1:23",
"currentInSeconds": 83,
"duration": "3:45",
"durationInSeconds": 225,
"image": "https://example.com/sample-image.jpg",
"favorite": true,
"player": {
"status": "playing",
"shuffle": true,
"repeat": "one"
},
"artist": "Sample Artist"
}
},
"OkResponse": {
"type": "string",
"example": "OK"
},
"StringArray": {
"type": "array",
"items": {
"type": "string"
},
"example": ["Artist1", "Artist2"]
}
}
},
"tags": [
{
"name": "current",
"description": "The current media info API"
},
{
"name": "player",
"description": "The player control API"
},
{
"name": "settings",
"description": "The settings management API"
}
]
}

View File

@ -1,41 +0,0 @@
import { App } from "electron";
import { flags } from "../../constants/flags";
import { settings } from "../../constants/settings";
import { settingsStore } from "../../scripts/settings";
import { Logger } from "../logger";
/**
* Set default Electron flags
*/
export function setDefaultFlags(app: App) {
setFlag(app, "disable-seccomp-filter-sandbox");
}
/**
* Set Tidal's managed flags from the user settings
* @param app
*/
export function setManagedFlagsFromSettings(app: App) {
const flagsFromSettings = settingsStore.get(settings.flags.root);
if (flagsFromSettings) {
for (const [key, value] of Object.entries(flagsFromSettings)) {
if (value) {
flags[key].forEach((flag) => {
setFlag(app, flag.flag, flag.value);
});
}
}
}
}
/**
* Set a single flag for Electron
* @param app app to set it on
* @param flag flag name
* @param value value to be set for the flag
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setFlag(app: App, flag: string, value?: any) {
Logger.log(`enabling command line option ${flag} with value ${value}`);
app.commandLine.appendSwitch(flag, value);
}

View File

@ -1,65 +0,0 @@
import { PowerSaveBlocker, powerSaveBlocker } from "electron";
import { Logger } from "../logger";
/**
* Start blocking idle/screen timeouts
* @param blocker optional instance of the powerSaveBlocker to use
* @returns id of current block
*/
export const acquireInhibitor = (blocker?: PowerSaveBlocker): number => {
const currentBlocker = blocker ?? powerSaveBlocker;
const blockId = currentBlocker.start("prevent-app-suspension");
Logger.log(`Started preventing app suspension with id: ${blockId}`);
return blockId;
};
/**
* Check whether there is a blocker active for the current id, if not start it.
* @param id id of inhibitor you want to check activity against
* @param blocker optional instance of the powerSaveBlocker to use
*/
export const acquireInhibitorIfInactive = (id: number, blocker?: PowerSaveBlocker): number => {
const currentBlocker = blocker ?? powerSaveBlocker;
if (!isInhibitorActive(id, currentBlocker)) {
return acquireInhibitor();
}
return id;
};
/**
* stop blocking idle/screen timeouts
* @param id id of inhibitor you want to check activity against
* @param blocker optional instance of the powerSaveBlocker to use
*/
export const releaseInhibitor = (id: number, blocker?: PowerSaveBlocker) => {
try {
const currentBlocker = blocker ?? powerSaveBlocker;
currentBlocker.stop(id);
Logger.log(`Released inhibitor with id: ${id}`);
} catch (error) {
Logger.log("Releasing inhibitor failed");
}
};
/**
* stop blocking idle/screen timeouts if a inhibitor is active
* @param id id of inhibitor you want to check activity against
* @param blocker optional instance of the powerSaveBlocker to use
*/
export const releaseInhibitorIfActive = (id: number, blocker?: PowerSaveBlocker) => {
const currentBlocker = blocker ?? powerSaveBlocker;
if (isInhibitorActive(id, currentBlocker)) {
releaseInhibitor(id, currentBlocker);
}
};
/**
* check whether the inhibitor is active
* @param id id of inhibitor you want to check activity against
* @param blocker optional instance of the powerSaveBlocker to use
*/
export const isInhibitorActive = (id: number, blocker?: PowerSaveBlocker) => {
const currentBlocker = blocker ?? powerSaveBlocker;
return currentBlocker.isStarted(id);
};

View File

@ -1,135 +0,0 @@
import axios from "axios";
import Store from "electron-store";
import { settings } from "../../constants/settings";
import { MediaStatus } from "../../models/mediaStatus";
import { settingsStore } from "../../scripts/settings";
import { Logger } from "../logger";
import { StoreData } from "./models/storeData";
const ListenBrainzStore = new Store({ name: "listenbrainz" });
export const ListenBrainzConstants = {
oldData: "oldData",
};
export class ListenBrainz {
/**
* Create the object to store old information in the Store :)
* @param title
* @param artists
* @param duration
* @returns data passed along in an object + a "listenedAt" key with the current time
*/
private static constructStoreData(title: string, artists: string, duration: number): StoreData {
return {
listenedAt: Math.floor(new Date().getTime() / 1000),
title,
artists,
duration,
};
}
/**
* Call the ListenBrainz API and create playing now payload and scrobble old song
* @param title
* @param artists
* @param status
* @param duration
*/
public static async scrobble(
title: string,
artists: string,
status: string,
duration: number
): Promise<void> {
try {
if (status === MediaStatus.paused) {
return;
} else {
// Fetches the oldData required for scrobbling and proceeds to construct a playing_now data payload for the Playing Now area
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
const tidalUrl =
settingsStore.get<string, string>(settings.advanced.tidalUrl) ||
"https://listen.tidal.com";
const playing_data = {
listen_type: "playing_now",
payload: [
{
track_metadata: {
additional_info: {
media_player: "Tidal Hi-Fi",
submission_client: "Tidal Hi-Fi",
music_service: "tidal.com",
duration: duration,
},
artist_name: artists,
track_name: title,
},
},
],
};
await axios.post(
`${settingsStore.get<string, string>(settings.ListenBrainz.api)}/1/submit-listens`,
playing_data,
{
headers: {
"Content-Type": "application/json",
Authorization: `Token ${settingsStore.get<string, string>(
settings.ListenBrainz.token
)}`,
},
}
);
if (!oldData) {
ListenBrainzStore.set(
ListenBrainzConstants.oldData,
this.constructStoreData(title, artists, duration)
);
} else {
if (oldData.title !== title) {
// This constructs the data required to scrobble the data after the song finishes
const scrobble_data = {
listen_type: "single",
payload: [
{
listened_at: oldData.listenedAt,
track_metadata: {
additional_info: {
media_player: "Tidal Hi-Fi",
submission_client: "Tidal Hi-Fi",
music_service: tidalUrl,
duration: oldData.duration,
},
artist_name: oldData.artists,
track_name: oldData.title,
},
},
],
};
await axios.post(
`${settingsStore.get<string, string>(settings.ListenBrainz.api)}/1/submit-listens`,
scrobble_data,
{
headers: {
"Content-Type": "application/json",
Authorization: `Token ${settingsStore.get<string, string>(
settings.ListenBrainz.token
)}`,
},
}
);
ListenBrainzStore.set(
ListenBrainzConstants.oldData,
this.constructStoreData(title, artists, duration)
);
}
}
}
} catch (error) {
Logger.log(JSON.stringify(error));
}
}
}
export { ListenBrainzStore };

View File

@ -1,9 +0,0 @@
/**
* Data saved for ListenBrainz
*/
export interface StoreData {
listenedAt: number;
title: string;
artists: string;
duration: number;
}

View File

@ -1,52 +0,0 @@
import { IpcMain, ipcMain, IpcMainEvent, ipcRenderer } from "electron";
import { globalEvents } from "../constants/globalEvents";
export class Logger {
/**
* Subscribe to watch for logs from the IPC client
* @param ipcMain main thread IPC client so we can subscribe to events
*/
public static watch(ipcMain: IpcMain) {
ipcMain.on(
globalEvents.log,
(event: IpcMainEvent | { content: string; message: string }, message) => {
const { content, object } = message ?? event;
this.logToSTDOut(content, object);
}
);
}
/**
* Log content to STDOut
* @param content
* @param object js(on) object that will be prettyPrinted
*/
public static log(content: string, object: object = {}) {
if (ipcRenderer) {
ipcRenderer.send(globalEvents.log, { content, object });
} else {
ipcMain.emit(globalEvents.log, { content, object });
}
}
/**
* Log content to STDOut and use the provided alert function to alert
* @param content
* @param object js(on) object that will be prettyPrinted
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public static alert(content: string, object: any = {}, alert?: (msg: string) => void) {
Logger.log(content, object);
if (alert) {
alert(`${content} \n\nwith details: \n${JSON.stringify(object, null, 2)}`);
}
}
/**
* Log to STDOut
* @param content
* @param object
*/
private static logToSTDOut(content: string, object = {}) {
console.log(content, Object.keys(object).length > 0 ? JSON.stringify(object, null, 2) : "");
}
}

View File

@ -1,10 +0,0 @@
export class SharingService {
/**
* Retrieve the universal link given a regular track link
* @param currentUrl
* @returns
*/
public static getUniversalLink(currentUrl: string): string {
return `${currentUrl}?u`;
}
}

View File

@ -1,31 +0,0 @@
import fs from "fs";
import { settings } from "../../constants/settings";
import { settingsStore } from "../../scripts/settings";
import { Logger } from "../logger";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function addCustomCss(app: any) {
window.addEventListener("DOMContentLoaded", () => {
const selectedTheme = settingsStore.get<string, string>(settings.theme);
if (selectedTheme !== "none") {
const userThemePath = `${app.getPath("userData")}/themes/${selectedTheme}`;
const resourcesThemePath = `${process.resourcesPath}/${selectedTheme}`;
const themeFile = fs.existsSync(userThemePath) ? userThemePath : resourcesThemePath;
fs.readFile(themeFile, "utf-8", (err, data) => {
if (err) {
Logger.alert("An error ocurred reading the theme file.", err, alert);
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);
});
}

View File

@ -1,14 +0,0 @@
/**
* Convert a HH:MM:SS string (or variants such as MM:SS or SS) to plain seconds
* @param duration in HH:MM:SS format
* @returns number of seconds in duration
*/
export const convertDurationToSeconds = (duration: string) => {
return duration
.split(":")
.reverse()
.map((val) => Number(val))
.reduce((previous, current, index) => {
return index === 0 ? current : previous + current * Math.pow(60, index);
}, 0);
};

View File

@ -1,48 +1,62 @@
import { enable, initialize } from "@electron/remote/main"; import { enable, initialize } from "@electron/remote/main";
import { BrowserWindow, app, components, ipcMain, session } from "electron";
import path from "path";
import { globalEvents } from "./constants/globalEvents";
import { settings } from "./constants/settings";
import { startApi } from "./features/api";
import { setDefaultFlags, setManagedFlagsFromSettings } from "./features/flags/flags";
import { import {
acquireInhibitorIfInactive, BrowserWindow,
releaseInhibitorIfActive, app,
} from "./features/idleInhibitor/idleInhibitor"; components,
import { Logger } from "./features/logger"; globalShortcut,
import { SharingService } from "./features/sharingService/sharingService"; ipcMain,
import { MediaInfo } from "./models/mediaInfo"; protocol,
import { MediaStatus } from "./models/mediaStatus"; 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 { initRPC, rpc, unRPC } from "./scripts/discord";
import { startExpress } from "./scripts/express";
import { updateMediaInfo } from "./scripts/mediaInfo"; import { updateMediaInfo } from "./scripts/mediaInfo";
import { addMenu } from "./scripts/menu"; import { addMenu } from "./scripts/menu";
import { import {
closeSettingsWindow, closeSettingsWindow,
createSettingsWindow, createSettingsWindow,
hideSettingsWindow, hideSettingsWindow,
settingsStore,
showSettingsWindow, showSettingsWindow,
settingsStore,
} from "./scripts/settings"; } from "./scripts/settings";
import { settings } from "./constants/settings";
import { addTray, refreshTray } from "./scripts/tray"; import { addTray, refreshTray } from "./scripts/tray";
let mainInhibitorId = -1; import { MediaInfo } from "./models/mediaInfo";
const tidalUrl = "https://listen.tidal.com";
initialize();
let mainWindow: BrowserWindow; let mainWindow: BrowserWindow;
const icon = path.join(__dirname, "../assets/icon.png"); const icon = path.join(__dirname, "../assets/icon.png");
const PROTOCOL_PREFIX = "tidal"; const PROTOCOL_PREFIX = "tidal";
const windowPreferences = {
sandbox: false,
plugins: true,
devTools: true, // I like tinkering, others might too
};
setDefaultFlags(app); setFlags();
setManagedFlagsFromSettings(app);
const tidalUrl = function setFlags() {
settingsStore.get<string, string>(settings.advanced.tidalUrl) || "https://listen.tidal.com"; const flagsFromSettings = settingsStore.get(settings.flags.root);
if (flagsFromSettings) {
for (const [key, value] of Object.entries(flags)) {
if (value) {
flags[key].forEach((flag) => {
console.log(`enabling command line switch ${flag.flag} with value ${flag.value}`);
app.commandLine.appendSwitch(flag.flag, flag.value);
});
}
}
}
/**
* Fix Display Compositor issue.
*/
app.commandLine.appendSwitch("disable-seccomp-filter-sandbox");
}
/** /**
* Update the menuBarVisibility according to the store value * Update the menuBarVisbility according to the store value
* *
*/ */
function syncMenuBarWithStore() { function syncMenuBarWithStore() {
@ -53,31 +67,20 @@ function syncMenuBarWithStore() {
} }
/** /**
* @returns true/false based on whether the current window is the main window * Determine whether the current window is the main window
* if singleInstance is requested.
* If singleInstance isn't requested simply return true
* @returns true if singInstance is not requested, otherwise true/false based on whether the current window is the main window
*/ */
function isMainInstance() { function isMainInstanceOrMultipleInstancesAllowed() {
return app.requestSingleInstanceLock(); if (settingsStore.get(settings.singleInstance)) {
} const gotTheLock = app.requestSingleInstanceLock();
/** if (!gotTheLock) {
* @returns true/false based on whether multiple instances are allowed return false;
*/ }
function isMultipleInstancesAllowed() {
return !settingsStore.get(settings.singleInstance);
}
/**
* @param args the arguments passed to the app
* @returns the custom protocol url if it exists, otherwise null
*/
function getCustomProtocolUrl(args: string[]) {
const customProtocolArg = args.find((arg) => arg.startsWith(PROTOCOL_PREFIX));
if (!customProtocolArg) {
return null;
} }
return true;
return tidalUrl + "/" + customProtocolArg.substring(PROTOCOL_PREFIX.length + 3);
} }
function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
@ -85,33 +88,24 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
x: options.x, x: options.x,
y: options.y, y: options.y,
width: settingsStore?.get(settings.windowBounds.width), width: settingsStore && settingsStore.get(settings.windowBounds.width),
height: settingsStore?.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: {
...windowPreferences, sandbox: false,
...{ preload: path.join(__dirname, "preload.js"),
preload: path.join(__dirname, "preload.js"), plugins: true,
}, devTools: true, // I like tinkering, others might too
}, },
}); });
enable(mainWindow.webContents); enable(mainWindow.webContents);
registerHttpProtocols(); registerHttpProtocols();
syncMenuBarWithStore(); syncMenuBarWithStore();
// find the custom protocol argument // load the Tidal website
const customProtocolUrl = getCustomProtocolUrl(process.argv); mainWindow.loadURL(tidalUrl);
if (customProtocolUrl) {
// load the url received from the custom protocol
mainWindow.loadURL(customProtocolUrl);
} else {
// load the Tidal website
mainWindow.loadURL(tidalUrl);
}
if (settingsStore.get(settings.disableBackgroundThrottle)) { if (settingsStore.get(settings.disableBackgroundThrottle)) {
// prevent setInterval lag // prevent setInterval lag
@ -126,10 +120,8 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
} }
return false; return false;
}); });
// Emitted when the window is closed. // Emitted when the window is closed.
mainWindow.on("closed", function () { mainWindow.on("closed", function () {
releaseInhibitorIfActive(mainInhibitorId);
closeSettingsWindow(); closeSettingsWindow();
app.quit(); app.quit();
}); });
@ -137,49 +129,31 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
const { width, height } = mainWindow.getBounds(); const { width, height } = mainWindow.getBounds();
settingsStore.set(settings.windowBounds.root, { width, height }); settingsStore.set(settings.windowBounds.root, { width, height });
}); });
mainWindow.webContents.setWindowOpenHandler(() => {
return {
action: "allow",
overrideBrowserWindowOptions: {
webPreferences: {
sandbox: false,
plugins: true,
devTools: true, // I like tinkering, others might too
},
},
};
});
} }
function registerHttpProtocols() { function registerHttpProtocols() {
protocol.registerHttpProtocol(PROTOCOL_PREFIX, (request) => {
mainWindow.loadURL(`${tidalUrl}/${request.url.substring(PROTOCOL_PREFIX.length + 3)}`);
});
if (!app.isDefaultProtocolClient(PROTOCOL_PREFIX)) { if (!app.isDefaultProtocolClient(PROTOCOL_PREFIX)) {
app.setAsDefaultProtocolClient(PROTOCOL_PREFIX); app.setAsDefaultProtocolClient(PROTOCOL_PREFIX);
} }
} }
function addGlobalShortcuts() {
Object.keys(mediaKeys).forEach((key) => {
globalShortcut.register(`${key}`, () => {
mainWindow.webContents.send("globalEvent", `${(mediaKeys as any)[key]}`);
});
});
}
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.on("ready", async () => { app.on("ready", async () => {
// check if the app is the main instance and multiple instances are not allowed if (isMainInstanceOrMultipleInstancesAllowed()) {
if (isMainInstance() && !isMultipleInstancesAllowed()) {
app.on("second-instance", (_, commandLine) => {
const customProtocolUrl = getCustomProtocolUrl(commandLine);
if (customProtocolUrl) {
mainWindow.loadURL(customProtocolUrl);
}
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
if (isMainInstance() || isMultipleInstancesAllowed()) {
await components.whenReady(); await components.whenReady();
initialize();
// Adblock // Adblock
if (settingsStore.get(settings.adBlock)) { if (settingsStore.get(settings.adBlock)) {
@ -190,16 +164,15 @@ app.on("ready", async () => {
}); });
} }
Logger.log("components ready:", components.status());
createWindow(); createWindow();
addMenu(mainWindow); addMenu(mainWindow);
createSettingsWindow(); createSettingsWindow();
addGlobalShortcuts();
if (settingsStore.get(settings.trayIcon)) { if (settingsStore.get(settings.trayIcon)) {
addTray(mainWindow, { icon }); addTray(mainWindow, { icon });
refreshTray(mainWindow); refreshTray(mainWindow);
} }
settingsStore.get(settings.api) && startApi(mainWindow); settingsStore.get(settings.api) && startExpress(mainWindow);
settingsStore.get(settings.enableDiscord) && initRPC(); settingsStore.get(settings.enableDiscord) && initRPC();
} else { } else {
app.quit(); app.quit();
@ -221,12 +194,6 @@ app.on("browser-window-created", (_, window) => {
// IPC // IPC
ipcMain.on(globalEvents.updateInfo, (_event, arg: MediaInfo) => { ipcMain.on(globalEvents.updateInfo, (_event, arg: MediaInfo) => {
updateMediaInfo(arg); updateMediaInfo(arg);
if (arg.status === MediaStatus.playing) {
mainInhibitorId = acquireInhibitorIfInactive(mainInhibitorId);
} else {
releaseInhibitorIfActive(mainInhibitorId);
mainInhibitorId = -1;
}
}); });
ipcMain.on(globalEvents.hideSettings, () => { ipcMain.on(globalEvents.hideSettings, () => {
@ -253,9 +220,3 @@ ipcMain.on(globalEvents.storeChanged, () => {
ipcMain.on(globalEvents.error, (event) => { ipcMain.on(globalEvents.error, (event) => {
console.log(event); console.log(event);
}); });
ipcMain.handle(globalEvents.getUniversalLink, async (event, url) => {
return SharingService.getUniversalLink(url);
});
Logger.watch(ipcMain);

View File

@ -1,4 +1,3 @@
import { MediaPlayerInfo } from "./mediaPlayerInfo";
import { MediaStatus } from "./mediaStatus"; import { MediaStatus } from "./mediaStatus";
export interface MediaInfo { export interface MediaInfo {
@ -8,12 +7,7 @@ export interface MediaInfo {
icon: string; icon: string;
status: MediaStatus; status: MediaStatus;
url: string; url: string;
playingFrom: string;
current: string; current: string;
currentInSeconds?: number;
duration: string; duration: string;
durationInSeconds?: number;
image: string; image: string;
favorite: boolean;
player?: MediaPlayerInfo;
} }

View File

@ -1,8 +0,0 @@
import { RepeatState } from "./repeatState";
import { MediaStatus } from "./mediaStatus";
export interface MediaPlayerInfo {
status: MediaStatus;
shuffle: boolean;
repeat: RepeatState;
}

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,5 +0,0 @@
export enum RepeatState {
off = "off",
all = "all",
single = "single",
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -1,42 +1,19 @@
import { app } from "@electron/remote"; import remote, { app } from "@electron/remote";
import { ipcRenderer, shell } from "electron"; import { ipcRenderer, shell } from "electron";
import fs from "fs"; import fs from "fs";
import { globalEvents } from "../../constants/globalEvents"; import { globalEvents } from "../../constants/globalEvents";
import { settings } from "../../constants/settings"; import { settings } from "../../constants/settings";
import { Logger } from "../../features/logger";
import { addCustomCss } from "../../features/theming/theming";
import { settingsStore } from "./../../scripts/settings"; import { settingsStore } from "./../../scripts/settings";
import { getOptions, getOptionsHeader, getThemeListFromDirectory } from "./theming"; import { getOptions, getOptionsHeader, getThemeListFromDirectory } from "./theming";
// All switches on the settings screen that show additional options based on their state
const switchesWithSettings = {
listenBrainz: {
switch: "enableListenBrainz",
classToHide: "listenbrainz__options",
settingsKey: settings.ListenBrainz.enabled,
},
discord: {
switch: "enableDiscord",
classToHide: "discord_options",
settingsKey: settings.enableDiscord,
},
discord_show_song: {
switch: "discord_show_song",
classToHide: "discord_show_song_options",
settingsKey: settings.discord.showSong,
},
};
let adBlock: HTMLInputElement, let adBlock: HTMLInputElement,
api: HTMLInputElement, api: HTMLInputElement,
channel: HTMLSelectElement,
customCSS: HTMLInputElement, customCSS: HTMLInputElement,
disableBackgroundThrottle: HTMLInputElement, disableBackgroundThrottle: HTMLInputElement,
disableHardwareMediaKeys: HTMLInputElement, disableHardwareMediaKeys: HTMLInputElement,
enableCustomHotkeys: HTMLInputElement, enableCustomHotkeys: HTMLInputElement,
enableDiscord: HTMLInputElement, enableDiscord: HTMLInputElement,
gpuRasterization: HTMLInputElement, gpuRasterization: HTMLInputElement,
hostname: HTMLInputElement,
menuBar: HTMLInputElement, menuBar: HTMLInputElement,
minimizeOnClose: HTMLInputElement, minimizeOnClose: HTMLInputElement,
mpris: HTMLInputElement, mpris: HTMLInputElement,
@ -46,25 +23,9 @@ let adBlock: HTMLInputElement,
singleInstance: HTMLInputElement, singleInstance: HTMLInputElement,
skipArtists: HTMLInputElement, skipArtists: HTMLInputElement,
skippedArtists: HTMLInputElement, skippedArtists: HTMLInputElement,
staticWindowTitle: HTMLInputElement,
theme: HTMLSelectElement, theme: HTMLSelectElement,
trayIcon: HTMLInputElement, trayIcon: HTMLInputElement,
updateFrequency: HTMLInputElement, updateFrequency: HTMLInputElement;
enableListenBrainz: HTMLInputElement,
ListenBrainzAPI: HTMLInputElement,
ListenBrainzToken: HTMLInputElement,
listenbrainz_delay: HTMLInputElement,
enableWaylandSupport: HTMLInputElement,
discord_details_prefix: HTMLInputElement,
discord_include_timestamps: HTMLInputElement,
discord_button_text: HTMLInputElement,
discord_show_song: HTMLInputElement,
discord_show_idle: HTMLInputElement,
discord_idle_text: HTMLInputElement,
discord_using_text: HTMLInputElement;
addCustomCss(app);
function getThemeFiles() { function getThemeFiles() {
const selectElement = document.getElementById("themesList") as HTMLSelectElement; const selectElement = document.getElementById("themesList") as HTMLSelectElement;
const builtInThemes = getThemeListFromDirectory(process.resourcesPath); const builtInThemes = getThemeListFromDirectory(process.resourcesPath);
@ -92,7 +53,6 @@ function handleFileUploads() {
const fileMessage = document.getElementById("file-message"); const fileMessage = document.getElementById("file-message");
fileMessage.innerText = "or drag and drop files here"; fileMessage.innerText = "or drag and drop files here";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
document.getElementById("theme-files").addEventListener("change", function (e: any) { document.getElementById("theme-files").addEventListener("change", function (e: any) {
Array.from(e.target.files).forEach((file: File) => { Array.from(e.target.files).forEach((file: File) => {
const destination = `${app.getPath("userData")}/themes/${file.name}`; const destination = `${app.getPath("userData")}/themes/${file.name}`;
@ -103,68 +63,30 @@ function handleFileUploads() {
}); });
} }
/**
* hide or unhide an element
* @param checked
* @param toggleOptions
*/
function setElementHidden(
checked: boolean,
toggleOptions: { switch: string; classToHide: string }
) {
const element = document.getElementById(toggleOptions.classToHide);
checked ? element.classList.remove("hidden") : element.classList.add("hidden");
}
/** /**
* Sync the UI forms with the current settings * Sync the UI forms with the current settings
*/ */
function refreshSettings() { function refreshSettings() {
try { adBlock.checked = settingsStore.get(settings.adBlock);
adBlock.checked = settingsStore.get(settings.adBlock); api.checked = settingsStore.get(settings.api);
api.checked = settingsStore.get(settings.api); customCSS.value = settingsStore.get<string, string[]>(settings.customCSS).join("\n");
channel.value = settingsStore.get(settings.advanced.tidalUrl); disableBackgroundThrottle.checked = settingsStore.get(settings.disableBackgroundThrottle);
customCSS.value = settingsStore.get<string, string[]>(settings.customCSS).join("\n"); disableHardwareMediaKeys.checked = settingsStore.get(settings.flags.disableHardwareMediaKeys);
disableBackgroundThrottle.checked = settingsStore.get(settings.disableBackgroundThrottle); enableCustomHotkeys.checked = settingsStore.get(settings.enableCustomHotkeys);
disableHardwareMediaKeys.checked = settingsStore.get(settings.flags.disableHardwareMediaKeys); enableDiscord.checked = settingsStore.get(settings.enableDiscord);
enableCustomHotkeys.checked = settingsStore.get(settings.enableCustomHotkeys); gpuRasterization.checked = settingsStore.get(settings.flags.gpuRasterization);
enableDiscord.checked = settingsStore.get(settings.enableDiscord); menuBar.checked = settingsStore.get(settings.menuBar);
enableWaylandSupport.checked = settingsStore.get(settings.flags.enableWaylandSupport); minimizeOnClose.checked = settingsStore.get(settings.minimizeOnClose);
gpuRasterization.checked = settingsStore.get(settings.flags.gpuRasterization); mpris.checked = settingsStore.get(settings.mpris);
hostname.value = settingsStore.get(settings.apiSettings.hostname); notifications.checked = settingsStore.get(settings.notifications);
menuBar.checked = settingsStore.get(settings.menuBar); playBackControl.checked = settingsStore.get(settings.playBackControl);
minimizeOnClose.checked = settingsStore.get(settings.minimizeOnClose); port.value = settingsStore.get(settings.apiSettings.port);
mpris.checked = settingsStore.get(settings.mpris); singleInstance.checked = settingsStore.get(settings.singleInstance);
notifications.checked = settingsStore.get(settings.notifications); skipArtists.checked = settingsStore.get(settings.skipArtists);
playBackControl.checked = settingsStore.get(settings.playBackControl); theme.value = settingsStore.get(settings.theme);
port.value = settingsStore.get(settings.apiSettings.port); skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
singleInstance.checked = settingsStore.get(settings.singleInstance); trayIcon.checked = settingsStore.get(settings.trayIcon);
skipArtists.checked = settingsStore.get(settings.skipArtists); updateFrequency.value = settingsStore.get(settings.updateFrequency);
skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
staticWindowTitle.checked = settingsStore.get(settings.staticWindowTitle);
theme.value = settingsStore.get(settings.theme);
trayIcon.checked = settingsStore.get(settings.trayIcon);
updateFrequency.value = settingsStore.get(settings.updateFrequency);
enableListenBrainz.checked = settingsStore.get(settings.ListenBrainz.enabled);
ListenBrainzAPI.value = settingsStore.get(settings.ListenBrainz.api);
ListenBrainzToken.value = settingsStore.get(settings.ListenBrainz.token);
listenbrainz_delay.value = settingsStore.get(settings.ListenBrainz.delay);
discord_details_prefix.value = settingsStore.get(settings.discord.detailsPrefix);
discord_include_timestamps.checked = settingsStore.get(settings.discord.includeTimestamps);
discord_button_text.value = settingsStore.get(settings.discord.buttonText);
discord_show_song.checked = settingsStore.get(settings.discord.showSong);
discord_show_idle.checked = settingsStore.get(settings.discord.showIdle);
discord_idle_text.value = settingsStore.get(settings.discord.idleText);
discord_using_text.value = settingsStore.get(settings.discord.usingText);
// set state of all switches with additional settings
Object.values(switchesWithSettings).forEach((settingSwitch) => {
setElementHidden(settingsStore.get(settingSwitch.settingsKey), settingSwitch);
});
} catch (error) {
Logger.log("Refreshing settings failed.", error);
}
} }
/** /**
@ -181,6 +103,14 @@ function hide() {
ipcRenderer.send(globalEvents.hideSettings); ipcRenderer.send(globalEvents.hideSettings);
} }
/**
* Restart tidal-hifi after changes
*/
function restart() {
remote.app.relaunch();
remote.app.exit();
}
/** /**
* Bind UI components to functions after DOMContentLoaded * Bind UI components to functions after DOMContentLoaded
*/ */
@ -193,29 +123,20 @@ window.addEventListener("DOMContentLoaded", () => {
handleFileUploads(); handleFileUploads();
document.getElementById("close").addEventListener("click", hide); document.getElementById("close").addEventListener("click", hide);
document.getElementById("restart").addEventListener("click", restart);
document.querySelectorAll(".external-link").forEach((elem) => document.querySelectorAll(".external-link").forEach((elem) =>
elem.addEventListener("click", function (event) { elem.addEventListener("click", function (event) {
openExternal((event.target as HTMLElement).getAttribute("data-url")); openExternal((event.target as HTMLElement).getAttribute("data-url"));
}) })
); );
function addInputListener( function addInputListener(source: HTMLInputElement, key: string) {
source: HTMLInputElement,
key: string,
toggleOptions?: { switch: string; classToHide: string }
) {
source.addEventListener("input", () => { source.addEventListener("input", () => {
if (source.value === "on") { if (source.value === "on") {
settingsStore.set(key, source.checked); settingsStore.set(key, source.checked);
} else { } else {
settingsStore.set(key, source.value); settingsStore.set(key, source.value);
} }
if (toggleOptions) {
if (source.value === "on" && source.id === toggleOptions.switch) {
setElementHidden(source.checked, toggleOptions);
}
}
ipcRenderer.send(globalEvents.storeChanged); ipcRenderer.send(globalEvents.storeChanged);
}); });
} }
@ -244,15 +165,12 @@ window.addEventListener("DOMContentLoaded", () => {
adBlock = get("adBlock"); adBlock = get("adBlock");
api = get("apiCheckbox"); api = get("apiCheckbox");
channel = get<HTMLSelectElement>("channel");
customCSS = get("customCSS"); customCSS = get("customCSS");
disableBackgroundThrottle = get("disableBackgroundThrottle"); disableBackgroundThrottle = get("disableBackgroundThrottle");
disableHardwareMediaKeys = get("disableHardwareMediaKeys"); disableHardwareMediaKeys = get("disableHardwareMediaKeys");
enableCustomHotkeys = get("enableCustomHotkeys"); enableCustomHotkeys = get("enableCustomHotkeys");
enableDiscord = get("enableDiscord"); enableDiscord = get("enableDiscord");
enableWaylandSupport = get("enableWaylandSupport");
gpuRasterization = get("gpuRasterization"); gpuRasterization = get("gpuRasterization");
hostname = get("hostname");
menuBar = get("menuBar"); menuBar = get("menuBar");
minimizeOnClose = get("minimizeOnClose"); minimizeOnClose = get("minimizeOnClose");
mpris = get("mprisCheckbox"); mpris = get("mprisCheckbox");
@ -263,33 +181,19 @@ window.addEventListener("DOMContentLoaded", () => {
trayIcon = get("trayIcon"); trayIcon = get("trayIcon");
skipArtists = get("skipArtists"); skipArtists = get("skipArtists");
skippedArtists = get("skippedArtists"); skippedArtists = get("skippedArtists");
staticWindowTitle = get("staticWindowTitle");
singleInstance = get("singleInstance"); singleInstance = get("singleInstance");
updateFrequency = get("updateFrequency"); updateFrequency = get("updateFrequency");
enableListenBrainz = get("enableListenBrainz");
ListenBrainzAPI = get("ListenBrainzAPI");
ListenBrainzToken = get("ListenBrainzToken");
discord_details_prefix = get("discord_details_prefix");
discord_include_timestamps = get("discord_include_timestamps");
listenbrainz_delay = get("listenbrainz_delay");
discord_button_text = get("discord_button_text");
discord_show_song = get("discord_show_song");
discord_show_idle = get("discord_show_idle");
discord_using_text = get("discord_using_text");
discord_idle_text = get("discord_idle_text");
refreshSettings(); refreshSettings();
addInputListener(adBlock, settings.adBlock); addInputListener(adBlock, settings.adBlock);
addInputListener(api, settings.api); addInputListener(api, settings.api);
addSelectListener(channel, settings.advanced.tidalUrl);
addTextAreaListener(customCSS, settings.customCSS); addTextAreaListener(customCSS, settings.customCSS);
addInputListener(disableBackgroundThrottle, settings.disableBackgroundThrottle); addInputListener(disableBackgroundThrottle, settings.disableBackgroundThrottle);
addInputListener(disableHardwareMediaKeys, settings.flags.disableHardwareMediaKeys); addInputListener(disableHardwareMediaKeys, settings.flags.disableHardwareMediaKeys);
addInputListener(enableCustomHotkeys, settings.enableCustomHotkeys); addInputListener(enableCustomHotkeys, settings.enableCustomHotkeys);
addInputListener(enableDiscord, settings.enableDiscord, switchesWithSettings.discord); addInputListener(enableDiscord, settings.enableDiscord);
addInputListener(enableWaylandSupport, settings.flags.enableWaylandSupport);
addInputListener(gpuRasterization, settings.flags.gpuRasterization); addInputListener(gpuRasterization, settings.flags.gpuRasterization);
addInputListener(hostname, settings.apiSettings.hostname);
addInputListener(menuBar, settings.menuBar); addInputListener(menuBar, settings.menuBar);
addInputListener(minimizeOnClose, settings.minimizeOnClose); addInputListener(minimizeOnClose, settings.minimizeOnClose);
addInputListener(mpris, settings.mpris); addInputListener(mpris, settings.mpris);
@ -298,28 +202,8 @@ window.addEventListener("DOMContentLoaded", () => {
addInputListener(port, settings.apiSettings.port); addInputListener(port, settings.apiSettings.port);
addInputListener(skipArtists, settings.skipArtists); addInputListener(skipArtists, settings.skipArtists);
addTextAreaListener(skippedArtists, settings.skippedArtists); addTextAreaListener(skippedArtists, settings.skippedArtists);
addInputListener(staticWindowTitle, settings.staticWindowTitle);
addInputListener(singleInstance, settings.singleInstance); addInputListener(singleInstance, settings.singleInstance);
addSelectListener(theme, settings.theme); addSelectListener(theme, settings.theme);
addInputListener(trayIcon, settings.trayIcon); addInputListener(trayIcon, settings.trayIcon);
addInputListener(updateFrequency, settings.updateFrequency); addInputListener(updateFrequency, settings.updateFrequency);
addInputListener(
enableListenBrainz,
settings.ListenBrainz.enabled,
switchesWithSettings.listenBrainz
);
addInputListener(ListenBrainzAPI, settings.ListenBrainz.api);
addInputListener(ListenBrainzToken, settings.ListenBrainz.token);
addInputListener(listenbrainz_delay, settings.ListenBrainz.delay);
addInputListener(discord_details_prefix, settings.discord.detailsPrefix);
addInputListener(discord_include_timestamps, settings.discord.includeTimestamps);
addInputListener(discord_button_text, settings.discord.buttonText);
addInputListener(
discord_show_song,
settings.discord.showSong,
switchesWithSettings.discord_show_song
);
addInputListener(discord_show_idle, settings.discord.showIdle);
addInputListener(discord_idle_text, settings.discord.idleText);
addInputListener(discord_using_text, settings.discord.usingText);
}); });

View File

@ -7,7 +7,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="./settings.css" /> <link rel="stylesheet" href="./settings.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
</head> </head>
<body class="settings-window"> <body class="settings-window">
@ -49,7 +48,7 @@
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Notifications</h4> <h4>Notifications</h4>
<p>Show a notification when new media 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" />
@ -106,16 +105,6 @@
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
<div class="group__option">
<div class="group__description">
<h4>Static Window Title</h4>
<p>Makes the window title "TIDAL Hi-Fi" instead of changing to the currently playing song.</p>
</div>
<label class="switch">
<input id="staticWindowTitle" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Minimize on Close</h4> <h4>Minimize on Close</h4>
@ -157,7 +146,7 @@
<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 media information. TIDAL Hi-Fi has a built-in web API to allow users to get current song information.
You can optionally enable playback control as well. You can optionally enable playback control as well.
</p> </p>
</div> </div>
@ -177,16 +166,6 @@
<input id="port" type="number" 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__description">
<h4>API hostname</h4>
<p>By default (127.0.0.1) only local apps can interface with the API. <br />
Change to 0.0.0.0 to allow <strong>anyone</strong> to interact with it. <br />
Other options are available
</p>
<input id="hostname" type="text" class="text-input" name="hostname" />
</div>
</div>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Playback control</h4> <h4>Playback control</h4>
@ -222,9 +201,6 @@
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
</div>
<div class="group">
<p class="group__title">Discord</p>
<div class="group__option"> <div class="group__option">
<div class="group__description"> <div class="group__description">
<h4>Discord RPC</h4> <h4>Discord RPC</h4>
@ -235,112 +211,6 @@
<span class="switch__slider"></span> <span class="switch__slider"></span>
</label> </label>
</div> </div>
<div id="discord_options">
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Show Idle Text</h4>
<p>Should the idle text be shown when idle?</p>
</div>
<label class="switch">
<input id="discord_show_idle" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Idle Text</h4>
<p>The text displayed on Discord's rich presence while idling in the app.</p>
<input id="discord_idle_text" type="text" class="text-input" name="discord_idle_text" />
</div>
</div>
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Using Tidal Text</h4>
<p>The text displayed on Discord's rich presence while "showSong" is turned off</p>
<input id="discord_using_text" type="text" class="text-input" name="discord_using_text" />
</div>
</div>
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Show media</h4>
<p>Show the current media in the Discord client</p>
</div>
<label class="switch">
<input id="discord_show_song" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div id="discord_show_song_options" class="hidden">
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Include timestamps</h4>
<p>Show current/end playtime in the Discord client</p>
</div>
<label class="switch">
<input id="discord_include_timestamps" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Details prefix</h4>
<p>Prefix for the "details" field of Discord's rich presence.</p>
<input id="discord_details_prefix" type="text" class="text-input" name="discord_details_prefix" />
</div>
</div>
<div class="group__option">
<div class="group__description">
<h4>Button text</h4>
<p>Text to display on the button below the media information.</p>
<input id="discord_button_text" type="text" class="text-input" name="discord_button_text" />
</div>
</div>
</div>
</div>
</div>
<div class="group">
<p class="group__title">ListenBrainz</p>
<div class="group__option">
<div class="group__description">
<h4>Enable ListenBrainz</h4>
<p>Scrobble your listens directly to ListenBrainz.</p>
</div>
<label class="switch">
<input id="enableListenBrainz" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
<div id="listenbrainz__options" class="hidden">
<div class="group__option">
<div class="group__description">
<h4>ListenBrainz API Url</h4>
<p>There are multiple instances for ListenBrainz you can set the corresponding API url below.</p>
<input id="ListenBrainzAPI" type="text" class="text-input" name="ListenBrainzAPI" />
</div>
</div>
<div class="group__option">
<div class="group__description">
<h4>ListenBrainz User Token</h4>
<p>Provide the user token you can get from the settings page.</p>
<input id="ListenBrainzToken" type="text" class="text-input" name="ListenBrainzToken" />
</div>
</div>
</div>
<div class="group__description">
<h4>ScrobbleDelay</h4>
<p>The delay (in ms) to send a listen to ListenBrainz. Prevents spamming the API when you fast forward
immediately</p>
<input id="listenbrainz_delay" type="number" class="text-input" name="listenbrainz_delay" />
</div>
</div> </div>
</section> </section>
@ -351,7 +221,7 @@
<div class="group__description"> <div class="group__description">
<h4>Update frequency</h4> <h4>Update frequency</h4>
<p> <p>
The amount of time, in milliseconds, that TIDAL Hi-Fi will refresh its playback info by scraping the The amount of time, in milliseconds, that tidal-hifi will refresh its playback info by scraping the
website. website.
The default of 500 seems to work in more cases but if you are fine with a bit more resource usage you 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. can decrease it as well.
@ -359,71 +229,44 @@
<input id="updateFrequency" type="number" class="text-input" name="updateFrequency" /> <input id="updateFrequency" type="number" class="text-input" name="updateFrequency" />
</div> </div>
</div> </div>
<div class="group">
<div class="group__option"> <p class="group__title">Flags</p>
<div class="group__description"> <div class="group__option">
<h4>Tidal channel / URL</h4> <div class="group__description">
<p> <h4>Disable hardware built-in media keys</h4>
Which URL Tidal Hi-Fi should use. <p>
<strong>note! Beta might break at any time</strong> Also prevents certain desktop environments from recognizing the chrome MPRIS
</p> client separately from the custom MPRIS client.
<select class="select-input" id="channel" name="channel"> </p>
<option value="https://listen.tidal.com">Stable (listen.tidal.com)</option> </div>
<option value="https://listen.stage.tidal.com">Staging (listen.stage.tidal.com)</option> <label class="switch">
</select> <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>
</div> </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 class="group__option">
<div class="group__description">
<h4>Wayland support</h4>
<p>
Adds a couple of Electron flags to help TIDAL Hi-Fi run smoothly on the Wayland window system.
</p>
</div>
<label class="switch">
<input id="enableWaylandSupport" type="checkbox" />
<span class="switch__slider"></span>
</label>
</div>
</div>
</section> </section>
<section id="theming-section" class="tabs__section"> <section id="theming-section" class="tabs__section">
@ -475,26 +318,21 @@
<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" />
<h4>TIDAL Hi-Fi</h4> <p class="about-section__text">
<div class="about-section__version"> <a class="external-link" data-url="https://github.com/Mastermindzh/tidal-hifi">TIDAL Hi-Fi</a>
<a target="_blank" rel="noopener" is made by
href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.18.2">5.18.2</a> <a class="external-link" data-url="https://www.rickvanlieshout.com">
</div> Rick van Lieshout</a>. <br />It uses
<div class="about-section__links"> <a class="external-link" data-url="https://castlabs.com/">Castlabs'</a>
<a target="_blank" rel="noopener" href="https://github.com/mastermindzh/tidal-hifi/" version of Electron for widevine support.
class="about-section__button">Github </p>
<i class="fa fa-external-link"></i></a>
<a target="_blank" rel="noopener" href="https://github.com/Mastermindzh/tidal-hifi/issues"
class="about-section__button">Report an issue <i class="fa fa-external-link"></i></a>
<a target="_blank" rel="noopener" href="https://github.com/Mastermindzh/tidal-hifi/graphs/contributors"
class="about-section__button">Contributors <i class="fa fa-external-link"></i></a>
</div>
</section> </section>
<footer class="footer"> <footer class="footer">
<p class="footer__note"> <p class="footer__note">
<strong>Note</strong>: some settings may require a restart of TIDAL Hi-Fi. 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>
</footer> </footer>
</div> </div>
</main> </main>

View File

@ -8,7 +8,6 @@ $tidal-grey: #72777f;
$tidal-grey-darker: #404248; $tidal-grey-darker: #404248;
$tidal-grey-darker-focus: #55585f; $tidal-grey-darker-focus: #55585f;
$tidal-grey-darkest: #242528; $tidal-grey-darkest: #242528;
$tidal-grey-darkest-focus: #2e2f33;
// --- Fonts --- // --- Fonts ---
@ -310,79 +309,26 @@ html {
} }
.about-section { .about-section {
padding-top: 40px; padding-top: 120px;
text-align: center; text-align: center;
&__icon { &__icon {
display: inline-block; display: inline-block;
width: 200px; width: 100px;
} }
&__text { &__text {
display: block; display: block;
max-width: 500px; max-width: 350px;
margin: -15px auto 0; margin: 20px auto 0;
} }
&__table { // --- Footer ---
width: 120px;
margin: 0 auto;
td {
text-align: left;
}
}
&__version {
margin: -10px 0 30px;
a {
background-color: $tidal-grey-darker;
border: none;
color: $tidal-blue;
padding: 8px 20px;
font-weight: bold;
text-align: center;
text-decoration: none;
display: inline-block;
border-radius: 100px;
}
}
&__links {
width: 300px;
margin: 0 auto;
a {
border-radius: 10px;
border: none;
color: $white;
padding: 10px 10px 10px 20px;
margin: 8px;
text-align: left;
font-size: 16px;
line-height: 30px;
display: flex;
text-decoration: none;
justify-content: space-between;
background-color: $tidal-grey-darkest;
i {
color: $white;
line-height: 30px;
font-size: 18px;
}
&:hover {
background-color: $tidal-grey-darkest-focus;
}
}
}
} }
// --- Footer ---
.footer { .footer {
position: sticky; position: sticky;
top: calc(100% - 120px);
height: 100px; height: 100px;
padding-top: 20px; padding-top: 20px;
text-align: center; text-align: center;
@ -415,17 +361,17 @@ html {
} }
// file upload // file upload
.file-drop-area { .file-drop-area {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
padding: 25px 0; padding: 25px 0 25px 0px;
border: 1px dashed $tidal-grey; border: 1px dashed $tidal-grey;
border-radius: 3px; border-radius: 3px;
transition: 0.2s; transition: 0.2s;
&.is-active { &.is-active {
background-color: $black; background-color: $black;
} }
@ -463,7 +409,6 @@ html {
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
&:focus { &:focus {
outline: none; outline: none;
} }
@ -498,7 +443,3 @@ html {
} }
} }
} }
.hidden {
display: none !important;
}

View File

@ -1,5 +1,4 @@
import fs from "fs"; import fs from "fs";
import { Logger } from "../../features/logger";
const cssFilter = (file: string) => file.endsWith(".css"); const cssFilter = (file: string) => file.endsWith(".css");
const sort = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase()); const sort = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase());
@ -37,7 +36,7 @@ export const getThemeListFromDirectory = (directory: string): string[] => {
makeUserThemesDirectory(directory); makeUserThemesDirectory(directory);
return fs.readdirSync(directory).filter(cssFilter).sort(sort); return fs.readdirSync(directory).filter(cssFilter).sort(sort);
} catch (err) { } catch (err) {
Logger.log(`Failed to get files from ${directory}`, err); console.error(err);
return []; return [];
} }
}; };
@ -50,6 +49,6 @@ export const makeUserThemesDirectory = (directory: string) => {
try { try {
fs.mkdirSync(directory, { recursive: true }); fs.mkdirSync(directory, { recursive: true });
} catch (err) { } catch (err) {
Logger.log(`Failed to make user theme directory: ${directory}`, err); console.error(err);
} }
}; };

View File

@ -1,39 +1,21 @@
import { app, dialog, Notification } from "@electron/remote"; import { Notification, app, dialog } from "@electron/remote";
import { clipboard, ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import fs from "fs";
import Player from "mpris-service"; import Player from "mpris-service";
import { globalEvents } from "./constants/globalEvents"; import { globalEvents } from "./constants/globalEvents";
import { settings } from "./constants/settings"; import { settings } from "./constants/settings";
import { import { statuses } from "./constants/statuses";
ListenBrainz, import { Options } from "./models/options";
ListenBrainzConstants,
ListenBrainzStore,
} from "./features/listenbrainz/listenbrainz";
import { StoreData } from "./features/listenbrainz/models/storeData";
import { Logger } from "./features/logger";
import { SharingService } from "./features/sharingService/sharingService";
import { addCustomCss } from "./features/theming/theming";
import { convertDurationToSeconds } from "./features/time/parse";
import { MediaInfo } from "./models/mediaInfo";
import { MediaStatus } from "./models/mediaStatus";
import { RepeatState } from "./models/repeatState";
import { downloadFile } from "./scripts/download"; import { downloadFile } from "./scripts/download";
import { addHotkey } from "./scripts/hotkeys"; import { addHotkey } from "./scripts/hotkeys";
import { ObjectToDotNotation } from "./scripts/objectUtilities";
import { settingsStore } from "./scripts/settings"; import { settingsStore } from "./scripts/settings";
import { setTitle } from "./scripts/window-functions"; import { setTitle } from "./scripts/window-functions";
const notificationPath = `${app.getPath("userData")}/notification.jpg`; const notificationPath = `${app.getPath("userData")}/notification.jpg`;
const appName = "Tidal Hifi";
let currentSong = ""; let currentSong = "";
let player: Player; let player: Player;
let currentPlayStatus = MediaStatus.paused; let currentPlayStatus = statuses.paused;
let currentListenBrainzDelayId: ReturnType<typeof setTimeout>;
let scrobbleWaitingForDelay = false;
let currentlyPlaying = MediaStatus.paused;
let currentRepeatState: RepeatState = RepeatState.off;
let currentShuffleState = false;
let currentMediaInfo: MediaInfo;
let currentNotification: Electron.Notification;
const elements = { const elements = {
play: '*[data-test="play"]', play: '*[data-test="play"]',
@ -43,29 +25,25 @@ const elements = {
title: '*[data-test^="footer-track-title"]', title: '*[data-test^="footer-track-title"]',
artists: '*[data-test^="grid-item-detail-text-title-artist"]', artists: '*[data-test^="grid-item-detail-text-title-artist"]',
home: '*[data-test="menu--home"]', home: '*[data-test="menu--home"]',
back: '[title^="Back"]', back: '[class^="backwardButton"]',
forward: '[title^="Next"]', forward: '[class^="forwardButton"]',
search: '[class^="searchField"]', search: '[class^="searchField"]',
shuffle: '*[data-test="shuffle"]', shuffle: '*[data-test="shuffle"]',
repeat: '*[data-test="repeat"]', repeat: '*[data-test="repeat"]',
block: '[class="blockButton"]',
account: '*[data-test^="profile-image-button"]', account: '*[data-test^="profile-image-button"]',
settings: '*[data-test^="sidebar-menu-button"]', settings: '*[data-test^="open-settings"]',
openSettings: '*[data-test^="open-settings"]',
media: '*[data-test="current-media-imagery"]', media: '*[data-test="current-media-imagery"]',
image: "img", image: "img",
current: '*[data-test="current-time"]', current: '*[data-test="current-time"]',
duration: '*[class^=_playbackControlsContainer] *[data-test="duration"]', duration: '*[data-test="duration"]',
bar: '*[data-test="progress-bar"]', bar: '*[data-test="progress-bar"]',
footer: "#footerPlayer", footer: "#footerPlayer",
mediaItem: "[data-type='mediaItem']", album_header_title: '.header-details [data-test="title"]',
album_header_title: '*[class^="_playingFrom"] span:nth-child(2)', playing_title: 'span[data-test="table-cell-title"].css-1vjc1xk',
playing_from: '*[class^="_playingFrom"] span:nth-child(2)', album_name_cell: '[data-test="table-cell-album"]',
queue_album: "*[class^=playQueueItemsContainer] *[class^=groupTitle] span:nth-child(2)",
currentlyPlaying: "[class^='isPlayingIcon'], [data-test-is-playing='true']",
album_name_cell: '[class^="album"]',
tracklist_row: '[data-test="tracklist-row"]', tracklist_row: '[data-test="tracklist-row"]',
volume: '*[data-test="volume"]', volume: '*[data-test="volume"]',
favorite: '*[data-test="footer-favorite-button"]',
/** /**
* 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
@ -75,7 +53,7 @@ const elements = {
}, },
/** /**
* Get the icon of the current media * Get the icon of the current song
*/ */
getSongIcon: function () { getSongIcon: function () {
const figure = this.get("media"); const figure = this.get("media");
@ -91,7 +69,7 @@ const elements = {
}, },
/** /**
* returns an array of all artists in the current media * returns an array of all artists in the current song
* @returns {Array} artists * @returns {Array} artists
*/ */
getArtistsArray: function () { getArtistsArray: function () {
@ -126,22 +104,14 @@ const elements = {
window.location.href.includes("/playlist/") || window.location.href.includes("/playlist/") ||
window.location.href.includes("/mix/") window.location.href.includes("/mix/")
) { ) {
if (currentPlayStatus === MediaStatus.playing) { if (currentPlayStatus === statuses.playing) {
// find the currently playing element from the list (which might be in an album icon), traverse back up to the mediaItem (row) and select the album cell. const row = window.document.querySelector(this.playing_title).closest(this.tracklist_row);
// document.querySelector("[class^='isPlayingIcon'], [data-test-is-playing='true']").closest('[data-type="mediaItem"]').querySelector('[class^="album"]').textContent
const row = window.document.querySelector(this.currentlyPlaying).closest(this.mediaItem);
if (row) { if (row) {
return row.querySelector(this.album_name_cell).textContent; return row.querySelector(this.album_name_cell).textContent;
} }
} }
} }
// see whether we're on the queue page and get it from there
const queueAlbumName = elements.getText("queue_album");
if (queueAlbumName) {
return queueAlbumName;
}
return ""; return "";
}, },
@ -149,10 +119,6 @@ const elements = {
return this.get("volume").getAttribute("aria-checked") === "false"; // it's muted if aria-checked is false return this.get("volume").getAttribute("aria-checked") === "false"; // it's muted if aria-checked is false
}, },
isFavorite: function () {
return this.get("favorite").getAttribute("aria-checked") === "true";
},
/** /**
* 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
@ -180,12 +146,36 @@ const elements = {
}, },
}; };
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 * Get the update frequency from the store
* make sure it returns a number, if not use the default * make sure it returns a number, if not use the default
*/ */
function getUpdateFrequency() { function getUpdateFrequency() {
const storeValue = settingsStore.get<string, number>(settings.updateFrequency); const storeValue = settingsStore.get(settings.updateFrequency) as number;
const defaultValue = 500; const defaultValue = 500;
if (!isNaN(storeValue)) { if (!isNaN(storeValue)) {
@ -196,7 +186,7 @@ function getUpdateFrequency() {
} }
/** /**
* Play or pause the current media * Play or pause the current song
*/ */
function playPause() { function playPause() {
const play = elements.get("play"); const play = elements.get("play");
@ -208,11 +198,6 @@ function playPause() {
} }
} }
/**
* Clears the old listenbrainz data on launch
*/
ListenBrainzStore.clear();
/** /**
* Add hotkeys for when tidal is focused * Add hotkeys for when tidal is focused
* Reflects the desktop hotkeys found on: * Reflects the desktop hotkeys found on:
@ -221,19 +206,12 @@ ListenBrainzStore.clear();
function addHotKeys() { function addHotKeys() {
if (settingsStore.get(settings.enableCustomHotkeys)) { if (settingsStore.get(settings.enableCustomHotkeys)) {
addHotkey("Control+p", function () { addHotkey("Control+p", function () {
elements.click("settings"); elements.click("account").click("settings");
setTimeout(() => {
elements.click("openSettings");
}, 100);
}); });
addHotkey("Control+l", function () { addHotkey("Control+l", function () {
handleLogout(); handleLogout();
}); });
addHotkey("Control+a", function () {
elements.click("favorite");
});
addHotkey("Control+h", function () { addHotkey("Control+h", function () {
elements.click("home"); elements.click("home");
}); });
@ -254,14 +232,6 @@ function addHotKeys() {
addHotkey("control+r", function () { addHotkey("control+r", function () {
elements.click("repeat"); elements.click("repeat");
}); });
addHotkey("control+w", async function () {
const url = SharingService.getUniversalLink(getTrackURL());
clipboard.writeText(url);
new Notification({
title: `Universal link generated: `,
body: `URL copied to clipboard: ${url}`,
}).show();
});
} }
// always add the hotkey for the settings window // always add the hotkey for the settings window
@ -289,7 +259,7 @@ function handleLogout() {
defaultId: 2, defaultId: 2,
}) })
.then((result: { response: number }) => { .then((result: { response: number }) => {
if (logoutOptions.indexOf("Yes, please") === result.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")) {
@ -317,8 +287,6 @@ function addIPCEventListeners() {
ipcRenderer.on("globalEvent", (_event, args) => { ipcRenderer.on("globalEvent", (_event, args) => {
switch (args) { switch (args) {
case globalEvents.playPause: case globalEvents.playPause:
case globalEvents.play:
case globalEvents.pause:
playPause(); playPause();
break; break;
case globalEvents.next: case globalEvents.next:
@ -327,16 +295,11 @@ function addIPCEventListeners() {
case globalEvents.previous: case globalEvents.previous:
elements.click("previous"); elements.click("previous");
break; break;
case globalEvents.toggleFavorite: case globalEvents.play:
elements.click("favorite"); elements.click("play");
break; break;
case globalEvents.toggleShuffle: case globalEvents.pause:
elements.click("shuffle"); elements.click("pause");
break;
case globalEvents.toggleRepeat:
elements.click("repeat");
break;
default:
break; break;
} }
}); });
@ -352,30 +315,13 @@ function getCurrentlyPlayingStatus() {
// if pause button is visible tidal is playing // if pause button is visible tidal is playing
if (pause) { if (pause) {
status = MediaStatus.playing; status = statuses.playing;
} else { } else {
status = MediaStatus.paused; status = statuses.paused;
} }
return status; return status;
} }
function getCurrentShuffleState() {
const shuffle = elements.get("shuffle");
return shuffle?.getAttribute("aria-checked") === "true";
}
function getCurrentRepeatState() {
const repeat = elements.get("repeat");
switch (repeat?.getAttribute("data-type")) {
case "button__repeatAll":
return RepeatState.all;
case "button__repeatSingle":
return RepeatState.single;
default:
return RepeatState.off;
}
}
/** /**
* Convert the duration from MM:SS to seconds * Convert the duration from MM:SS to seconds
* @param {*} duration * @param {*} duration
@ -388,138 +334,27 @@ function convertDuration(duration: string) {
/** /**
* Update Tidal-hifi's media info * Update Tidal-hifi's media info
* *
* @param {*} mediaInfo * @param {*} options
*/ */
function updateMediaInfo(mediaInfo: MediaInfo, notify: boolean) { function updateMediaInfo(options: Options, notify: boolean) {
if (mediaInfo) { if (options) {
currentMediaInfo = mediaInfo; ipcRenderer.send(globalEvents.updateInfo, options);
ipcRenderer.send(globalEvents.updateInfo, mediaInfo); if (settingsStore.get(settings.notifications) && notify) {
updateMpris(mediaInfo); new Notification({ title: options.title, body: options.artists, icon: options.icon }).show();
updateListenBrainz(mediaInfo);
if (notify) {
sendNotification(mediaInfo);
} }
} if (player) {
} player.metadata = {
...player.metadata,
/** ...{
* send a desktop notification if enabled in settings "xesam:title": options.title,
* @param mediaInfo "xesam:artist": [options.artists],
* @param notify Whether to notify "xesam:album": options.album,
*/ "mpris:artUrl": options.image,
async function sendNotification(mediaInfo: MediaInfo) { "mpris:length": convertDuration(options.duration) * 1000 * 1000,
if (settingsStore.get(settings.notifications)) { "mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
if (currentNotification) { },
currentNotification.close();
}
currentNotification = new Notification({
title: mediaInfo.title,
body: mediaInfo.artists,
icon: mediaInfo.icon,
});
currentNotification.show();
}
}
function addMPRIS() {
if (process.platform === "linux" && settingsStore.get(settings.mpris)) {
try {
player = Player({
name: "tidal-hifi",
identity: "tidal-hifi",
supportedUriSchemes: ["file"],
supportedMimeTypes: [
"audio/mpeg",
"audio/flac",
"audio/x-flac",
"application/ogg",
"audio/wav",
],
supportedInterfaces: ["player"],
desktopEntry: "tidal-hifi",
});
// Events
const events = {
next: "next",
previous: "previous",
pause: "pause",
playpause: "playpause",
stop: "stop",
play: "play",
loopStatus: "repeat",
shuffle: "shuffle",
seek: "seek",
} as { [key: string]: string };
Object.keys(events).forEach(function (eventName) {
player.on(eventName, function () {
const eventValue = events[eventName];
switch (events[eventValue]) {
case events.playpause:
playPause();
break;
default:
elements.click(eventValue);
}
});
});
// Override get position function
player.getPosition = function () {
return convertDuration(elements.getText("current")) * 1000 * 1000;
}; };
player.on("quit", function () { player.playbackStatus = options.status == statuses.paused ? "Paused" : "Playing";
app.quit();
});
} catch (exception) {
Logger.log("MPRIS player api not working", exception);
}
}
}
function updateMpris(mediaInfo: MediaInfo) {
if (player) {
player.metadata = {
...player.metadata,
...{
"xesam:title": mediaInfo.title,
"xesam:artist": [mediaInfo.artists],
"xesam:album": mediaInfo.album,
"xesam:url": mediaInfo.url,
"mpris:artUrl": mediaInfo.image,
"mpris:length": convertDuration(mediaInfo.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
},
...ObjectToDotNotation(mediaInfo, "custom:"),
};
player.playbackStatus = mediaInfo.status === MediaStatus.paused ? "Paused" : "Playing";
}
}
/**
* Update the listenbrainz service with new data based on a few conditions
*/
function updateListenBrainz(mediaInfo: MediaInfo) {
if (settingsStore.get(settings.ListenBrainz.enabled)) {
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
if (
(!oldData && mediaInfo.status === MediaStatus.playing) ||
(oldData && oldData.title !== mediaInfo.title)
) {
if (!scrobbleWaitingForDelay) {
scrobbleWaitingForDelay = true;
clearTimeout(currentListenBrainzDelayId);
currentListenBrainzDelayId = setTimeout(
() => {
ListenBrainz.scrobble(
mediaInfo.title,
mediaInfo.artists,
mediaInfo.status,
convertDuration(mediaInfo.duration)
);
scrobbleWaitingForDelay = false;
},
settingsStore.get(settings.ListenBrainz.delay) ?? 0
);
}
} }
} }
} }
@ -543,6 +378,23 @@ function getTrackID() {
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
*/ */
@ -550,88 +402,59 @@ setInterval(function () {
const title = elements.getText("title"); const title = elements.getText("title");
const artistsArray = elements.getArtistsArray(); const artistsArray = elements.getArtistsArray();
const artistsString = elements.getArtistsString(artistsArray); const artistsString = elements.getArtistsString(artistsArray);
const songDashArtistTitle = `${title} - ${artistsString}`; skipArtistsIfFoundInSkippedArtistsList(artistsArray);
const staticTitle = "TIDAL Hi-Fi";
const titleOrArtistsChanged = currentSong !== songDashArtistTitle; const album = elements.getAlbumName();
const current = elements.getText("current"); const current = elements.getText("current");
const duration = elements.getText("duration");
const songDashArtistTitle = `${title} - ${artistsString}`;
const currentStatus = getCurrentlyPlayingStatus(); const currentStatus = getCurrentlyPlayingStatus();
const shuffleState = getCurrentShuffleState(); const options = {
const repeatState = getCurrentRepeatState(); title,
artists: artistsString,
album: album,
status: currentStatus,
url: getTrackURL(),
current,
duration,
"app-name": appName,
image: "",
icon: "",
};
const playStateChanged = currentStatus != currentlyPlaying; const titleOrArtistsChanged = currentSong !== songDashArtistTitle;
const shuffleStateChanged = shuffleState != currentShuffleState;
const repeatStateChanged = repeatState != currentRepeatState;
const hasStateChanged = playStateChanged || shuffleStateChanged || repeatStateChanged; // update title, url and play info with new info
setTitle(songDashArtistTitle);
getTrackURL();
currentSong = songDashArtistTitle;
currentPlayStatus = currentStatus;
// update info if song changed or was just paused/resumed const image = elements.getSongIcon();
if (titleOrArtistsChanged || hasStateChanged) {
if (playStateChanged) currentlyPlaying = currentStatus;
if (shuffleStateChanged) currentShuffleState = shuffleState;
if (repeatStateChanged) currentRepeatState = repeatState;
skipArtistsIfFoundInSkippedArtistsList(artistsArray); new Promise<void>((resolve) => {
const album = elements.getAlbumName(); if (image.startsWith("http")) {
const duration = elements.getText("duration"); options.image = image;
const options: MediaInfo = { downloadFile(image, notificationPath).then(
title, () => {
artists: artistsString, options.icon = notificationPath;
album: album, resolve();
playingFrom: elements.getText("playing_from"), },
status: currentStatus, () => {
url: getTrackURL(), // if the image can't be downloaded then continue without it
current, resolve();
currentInSeconds: convertDurationToSeconds(current), }
duration, );
durationInSeconds: convertDurationToSeconds(duration), } else {
image: "", // if the image can't be found on the page continue without it
icon: "", resolve();
favorite: elements.isFavorite(), }
}).then(() => {
player: { updateMediaInfo(options, titleOrArtistsChanged);
status: currentStatus, if (titleOrArtistsChanged) {
shuffle: shuffleState, updateMediaSession(options);
repeat: repeatState, }
}, });
};
// update title, url and play info with new info
settingsStore.get(settings.staticWindowTitle)
? setTitle(staticTitle)
: setTitle(songDashArtistTitle);
getTrackURL();
currentSong = songDashArtistTitle;
currentPlayStatus = currentStatus;
const image = elements.getSongIcon();
new Promise<void>((resolve) => {
if (image.startsWith("http")) {
options.image = image;
downloadFile(image, notificationPath).then(
() => {
options.icon = notificationPath;
resolve();
},
() => {
// if the image can't be downloaded then continue without it
resolve();
}
);
} else {
// if the image can't be found on the page continue without it
resolve();
}
}).then(() => {
updateMediaInfo(options, titleOrArtistsChanged);
});
} else {
// just update the time
updateMediaInfo(
{ ...currentMediaInfo, ...{ current, currentInSeconds: convertDurationToSeconds(current) } },
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
@ -652,8 +475,61 @@ setInterval(function () {
} }
}, getUpdateFrequency()); }, getUpdateFrequency());
addMPRIS(); if (process.platform === "linux" && settingsStore.get(settings.mpris)) {
addCustomCss(app); try {
player = Player({
name: "tidal-hifi",
identity: "tidal-hifi",
supportedUriSchemes: ["file"],
supportedMimeTypes: [
"audio/mpeg",
"audio/flac",
"audio/x-flac",
"application/ogg",
"audio/wav",
],
supportedInterfaces: ["player"],
desktopEntry: "tidal-hifi",
});
// Events
const events = {
next: "next",
previous: "previous",
pause: "pause",
playpause: "playpause",
stop: "stop",
play: "play",
loopStatus: "repeat",
shuffle: "shuffle",
seek: "seek",
} as { [key: string]: string };
Object.keys(events).forEach(function (eventName) {
player.on(eventName, function () {
const eventValue = events[eventName];
switch (events[eventValue]) {
case events.playpause:
playPause();
break;
default:
elements.click(eventValue);
}
});
});
// Override get position function
player.getPosition = function () {
return convertDuration(elements.getText("current")) * 1000 * 1000;
};
player.on("quit", function () {
app.quit();
});
} catch (exception) {
console.log("player api not working");
}
}
addCustomCss();
addHotKeys(); addHotKeys();
addIPCEventListeners(); addIPCEventListeners();
addFullScreenListeners(); addFullScreenListeners();

View File

@ -1,141 +1,78 @@
import { Client, SetActivity } from "@xhayper/discord-rpc"; import { Client } from "discord-rpc";
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import { globalEvents } from "../constants/globalEvents"; import { globalEvents } from "../constants/globalEvents";
import { settings } from "../constants/settings";
import { Logger } from "../features/logger";
import { convertDurationToSeconds } from "../features/time/parse";
import { MediaStatus } from "../models/mediaStatus"; import { MediaStatus } from "../models/mediaStatus";
import { mediaInfo } from "./mediaInfo"; import { mediaInfo } from "./mediaInfo";
import { settingsStore } from "./settings";
const clientId = "833617820704440341"; 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; export let rpc: Client;
const ACTIVITY_LISTENING = 2;
const MAX_RETRIES = 5;
const RETRY_DELAY = 10000;
const observer = () => { const observer = () => {
if (rpc) { if (mediaInfo.status == MediaStatus.paused && rpc) {
updateActivity(); rpc.setActivity(idleStatus);
} } else if (rpc) {
}; const currentSeconds = timeToSeconds(mediaInfo.current.split(":"));
const durationSeconds = timeToSeconds(mediaInfo.duration.split(":"));
const defaultPresence = { const date = new Date();
largeImageKey: "tidal-hifi-icon", const now = (date.getTime() / 1000) | 0;
largeImageText: `TIDAL Hi-Fi ${app.getVersion()}`, const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
instance: false,
type: ACTIVITY_LISTENING
};
const updateActivity = () => {
const showIdle = settingsStore.get<string, boolean>(settings.discord.showIdle) ?? true;
if (mediaInfo.status === MediaStatus.paused && !showIdle) {
rpc.user?.clearActivity();
} else {
rpc.user?.setActivity(getActivity());
}
};
const getActivity = (): SetActivity => {
const presence: SetActivity = { ...defaultPresence };
if (mediaInfo.status === MediaStatus.paused) {
presence.details =
settingsStore.get<string, string>(settings.discord.idleText) ?? "Browsing Tidal";
} else {
const showSong = settingsStore.get<string, boolean>(settings.discord.showSong) ?? false;
if (showSong) {
const { includeTimestamps, detailsPrefix, buttonText } = getFromStore();
includeTimeStamps(includeTimestamps);
setPresenceFromMediaInfo(detailsPrefix, buttonText);
} else {
presence.details =
settingsStore.get<string, string>(settings.discord.usingText) ?? "Playing media on TIDAL";
}
}
return presence;
function getFromStore() {
const includeTimestamps =
settingsStore.get<string, boolean>(settings.discord.includeTimestamps) ?? true;
const detailsPrefix =
settingsStore.get<string, string>(settings.discord.detailsPrefix) ?? "Listening to ";
const buttonText =
settingsStore.get<string, string>(settings.discord.buttonText) ?? "Play on TIDAL";
return { includeTimestamps, detailsPrefix, buttonText };
}
/**
* Pad a string using spaces to at least 2 characters
* @param input string to pad with 2 characters
* @returns
*/
function pad(input: string): string {
return input.padEnd(2, " ");
}
function setPresenceFromMediaInfo(detailsPrefix: string, buttonText: string) {
// discord requires a minimum of 2 characters
const title = pad(mediaInfo.title);
const album = pad(mediaInfo.album);
const artists = pad(mediaInfo.artists);
if (mediaInfo.url) { if (mediaInfo.url) {
presence.details = `${detailsPrefix}${title}`; rpc.setActivity({
presence.state = artists ? artists : "unknown artist(s)"; ...idleStatus,
presence.largeImageKey = mediaInfo.image; ...{
if (album) { details: `Listening to ${mediaInfo.title}`,
presence.largeImageText = album; state: mediaInfo.artists ? mediaInfo.artists : "unknown artist(s)",
} startTimestamp: now,
endTimestamp: remaining,
presence.buttons = [{ label: buttonText, url: mediaInfo.url }]; largeImageKey: mediaInfo.image,
largeImageText: mediaInfo.album ? mediaInfo.album : `${idleStatus.largeImageText}`,
buttons: [{ label: "Play on Tidal", url: mediaInfo.url }],
},
});
} else { } else {
presence.details = `Watching ${title}`; rpc.setActivity({
presence.state = artists; ...idleStatus,
} ...{
} details: `Watching ${mediaInfo.title}`,
state: mediaInfo.artists,
function includeTimeStamps(includeTimestamps: boolean) { startTimestamp: now,
if (includeTimestamps) { endTimestamp: remaining,
const currentSeconds = convertDurationToSeconds(mediaInfo.current); },
const durationSeconds = convertDurationToSeconds(mediaInfo.duration); });
const now = Math.trunc((Date.now() + 500) / 1000);
presence.startTimestamp = now - currentSeconds;
presence.endTimestamp = presence.startTimestamp + durationSeconds;
} }
} }
}; };
/** const idleStatus = {
* Try to login to RPC and retry if it errors details: `Browsing Tidal`,
* @param retryCount Max retry count largeImageKey: "tidal-hifi-icon",
*/ largeImageText: `Tidal HiFi ${app.getVersion()}`,
const connectWithRetry = async (retryCount = 0) => { instance: false,
try {
await rpc.login();
Logger.log('Connected to Discord');
rpc.on("ready", updateActivity);
Object.values(globalEvents).forEach(event => ipcMain.on(event, observer));
} catch (error) {
if (retryCount < MAX_RETRIES) {
Logger.log(`Failed to connect to Discord, retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => connectWithRetry(retryCount + 1), RETRY_DELAY);
} else {
Logger.log('Failed to connect to Discord after maximum retry attempts');
}
}
}; };
/** /**
* Set up the discord rpc and listen on globalEvents.updateInfo * Set up the discord rpc and listen on globalEvents.updateInfo
*/ */
export const initRPC = () => { export const initRPC = () => {
rpc = new Client({ transport: {type: "ipc"}, clientId }); rpc = new Client({ transport: "ipc" });
connectWithRetry(); 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?");
}
);
}; };
/** /**
@ -143,7 +80,7 @@ export const initRPC = () => {
*/ */
export const unRPC = () => { export const unRPC = () => {
if (rpc) { if (rpc) {
rpc.user?.clearActivity(); rpc.clearActivity();
rpc.destroy(); rpc.destroy();
rpc = null; rpc = null;
ipcMain.removeListener(globalEvents.updateInfo, observer); ipcMain.removeListener(globalEvents.updateInfo, observer);

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,45 +1,35 @@
import { MediaInfo } from "../models/mediaInfo"; import { MediaInfo } from "../models/mediaInfo";
import { MediaStatus } from "../models/mediaStatus"; import { statuses } from "./../constants/statuses";
import { RepeatState } from "../models/repeatState";
const defaultInfo: MediaInfo = { export const mediaInfo = {
title: "", title: "",
artists: "", artists: "",
album: "", album: "",
icon: "", icon: "",
playingFrom: "", status: statuses.paused,
status: MediaStatus.paused,
url: "", url: "",
current: "", current: "",
currentInSeconds: 0,
duration: "", duration: "",
durationInSeconds: 0,
image: "tidal-hifi-icon", image: "tidal-hifi-icon",
favorite: false,
player: {
status: MediaStatus.paused,
shuffle: false,
repeat: RepeatState.off,
},
}; };
export let mediaInfo: MediaInfo = { ...defaultInfo };
export const updateMediaInfo = (arg: MediaInfo) => { export const updateMediaInfo = (arg: MediaInfo) => {
mediaInfo = { ...defaultInfo, ...arg }; mediaInfo.title = propOrDefault(arg.title);
mediaInfo.url = toUniversalUrl(mediaInfo.url); mediaInfo.artists = propOrDefault(arg.artists);
mediaInfo.album = propOrDefault(arg.album);
mediaInfo.icon = propOrDefault(arg.icon);
mediaInfo.url = propOrDefault(arg.url);
mediaInfo.status = propOrDefault(arg.status);
mediaInfo.current = propOrDefault(arg.current);
mediaInfo.duration = propOrDefault(arg.duration);
mediaInfo.image = propOrDefault(arg.image);
}; };
/** /**
* Append the universal link syntax (?u) to any url * Return the property or a default value
* @param url url to append the universal link syntax to * @param {*} prop property to check
* @returns url with `?u` appended, or the original value of url if falsy * @param {*} defaultValue defaults to ""
*/ */
function toUniversalUrl(url: string) { function propOrDefault(prop: string, defaultValue = "") {
if (url) { return prop ? prop : defaultValue;
const queryParamsSet = url.indexOf("?");
return queryParamsSet > -1 ? `${url}&u` : `${url}?u`;
}
return url;
} }

View File

@ -11,23 +11,6 @@ const settingsMenuEntry = {
accelerator: "Control+=", accelerator: "Control+=",
}; };
const tidalMagazineEntry = {
label: "Magazine",
click() {
const magazineWindow = new BrowserWindow({
autoHideMenuBar: true,
webPreferences: {
sandbox: false,
plugins: true,
devTools: true, // I like tinkering, others might too
},
});
magazineWindow.loadURL("https://tidal.com/magazine/");
magazineWindow.show();
},
accelerator: "Control+M",
};
const quitMenuEntry = { const quitMenuEntry = {
label: "Quit", label: "Quit",
click() { click() {
@ -50,6 +33,7 @@ export const getMenu = function (mainWindow: BrowserWindow) {
{ {
label: name, label: name,
submenu: [ submenu: [
{ role: "about" },
settingsMenuEntry, settingsMenuEntry,
{ type: "separator" }, { type: "separator" },
{ role: "services" }, { role: "services" },
@ -58,7 +42,6 @@ export const getMenu = function (mainWindow: BrowserWindow) {
{ role: "hideothers" }, { role: "hideothers" },
{ role: "unhide" }, { role: "unhide" },
{ type: "separator" }, { type: "separator" },
tidalMagazineEntry,
quitMenuEntry, quitMenuEntry,
], ],
}, },
@ -66,7 +49,7 @@ export const getMenu = function (mainWindow: BrowserWindow) {
: []), : []),
{ {
label: "File", label: "File",
submenu: [settingsMenuEntry, tidalMagazineEntry, isMac ? { role: "close" } : quitMenuEntry], submenu: [settingsMenuEntry, isMac ? { role: "close" } : quitMenuEntry],
}, },
{ {
label: "Edit", label: "Edit",
@ -118,8 +101,13 @@ export const getMenu = function (mainWindow: BrowserWindow) {
], ],
}, },
settingsMenuEntry, settingsMenuEntry,
{
label: "About",
click() {
showSettingsWindow("about");
},
},
toggleWindow, toggleWindow,
tidalMagazineEntry,
quitMenuEntry, quitMenuEntry,
]; ];

View File

@ -1,13 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const ObjectToDotNotation = (obj: any, prefix: string = "", target: any = {}) => {
Object.keys(obj).forEach((key: string) => {
if (typeof obj[key] === "object" && obj[key] !== null) {
ObjectToDotNotation(obj[key], prefix + key + ".", target);
} else {
const dotLocation = prefix + key;
target[dotLocation] = obj[key];
return target;
}
});
return target;
};

View File

@ -1,65 +1,26 @@
import Store from "electron-store"; import Store from "electron-store";
import { BrowserWindow, shell } from "electron";
import path from "path";
import { settings } from "../constants/settings"; import { settings } from "../constants/settings";
import path from "path";
import { BrowserWindow } from "electron";
let settingsWindow: BrowserWindow; let settingsWindow: BrowserWindow;
/**
* Build a migration step for several settings.
* All settings will be checked and set to the default if non-existent.
* @param version
* @param migrationStore
* @param options
*/
const buildMigration = (
version: string,
migrationStore: { get: (str: string) => string; set: (str: string, val: unknown) => void },
options: Array<{ key: string; value: unknown }>
) => {
console.log(`running migrations for ${version}`);
options.forEach(({ key, value }) => {
const valueToSet = migrationStore.get(key) ?? value;
console.log(` - setting ${key} to ${value}`);
migrationStore.set(key, valueToSet);
});
};
export const settingsStore = new Store({ export const settingsStore = new Store({
defaults: { defaults: {
adBlock: false, adBlock: false,
advanced: {
tidalUrl: "https://listen.tidal.com",
},
api: true, api: true,
apiSettings: { apiSettings: {
port: 47836, port: 47836,
hostname: "127.0.0.1",
}, },
customCSS: [], customCSS: [],
disableBackgroundThrottle: true, disableBackgroundThrottle: true,
disableHardwareMediaKeys: false, disableHardwareMediaKeys: false,
enableCustomHotkeys: false, enableCustomHotkeys: false,
enableDiscord: false, enableDiscord: false,
discord: {
showSong: true,
showIdle: true,
idleText: "Browsing Tidal",
usingText: "Playing media on TIDAL",
includeTimestamps: true,
detailsPrefix: "Listening to ",
buttonText: "Play on Tidal",
},
ListenBrainz: {
enabled: false,
api: "https://api.listenbrainz.org",
token: "",
delay: 5000,
},
flags: { flags: {
disableHardwareMediaKeys: false,
enableWaylandSupport: true,
gpuRasterization: true, gpuRasterization: true,
disableHardwareMediaKeys: false,
}, },
menuBar: true, menuBar: true,
minimizeOnClose: false, minimizeOnClose: false,
@ -69,7 +30,6 @@ export const settingsStore = new Store({
singleInstance: true, singleInstance: true,
skipArtists: false, skipArtists: false,
skippedArtists: [""], skippedArtists: [""],
staticWindowTitle: false,
theme: "none", theme: "none",
trayIcon: true, trayIcon: true,
updateFrequency: 500, updateFrequency: 500,
@ -83,43 +43,6 @@ export const settingsStore = new Store({
migrationStore.get("disableHardwareMediaKeys") ?? false migrationStore.get("disableHardwareMediaKeys") ?? false
); );
}, },
"5.7.0": (migrationStore) => {
console.log("running migrations for 5.7.0");
migrationStore.set(
settings.ListenBrainz.delay,
migrationStore.get(settings.ListenBrainz.delay) ?? 5000
);
},
"5.8.0": (migrationStore) => {
console.log("running migrations for 5.8.0");
migrationStore.set(
settings.discord.includeTimestamps,
migrationStore.get(settings.discord.includeTimestamps) ?? true
);
},
"5.9.0": (migrationStore) => {
buildMigration("5.9.0", migrationStore, [
{ key: settings.discord.showSong, value: "true" },
{ key: settings.discord.idleText, value: "Browsing Tidal" },
{
key: settings.discord.usingText,
value: "Playing media on TIDAL",
},
]);
},
"5.14.0": (migrationStore) => {
buildMigration("5.14.0", migrationStore, [
{ key: settings.apiSettings.hostname, value: "127.0.0.1" },
]);
},
"5.15.0": (migrationStore) => {
buildMigration("5.15.0", migrationStore, [
{ key: settings.advanced.tidalUrl, value: "https://listen.tidal.com" },
]);
},
"5.16.0": (migrationStore) => {
buildMigration("5.16.0", migrationStore, [{ key: settings.discord.showIdle, value: "true" }]);
},
}, },
}); });
@ -130,8 +53,8 @@ const settingsModule = {
export const createSettingsWindow = function () { export const createSettingsWindow = function () {
settingsWindow = new BrowserWindow({ settingsWindow = new BrowserWindow({
width: 650, width: 700,
height: 700, height: 600,
resizable: true, resizable: true,
show: false, show: false,
transparent: true, transparent: true,
@ -153,18 +76,10 @@ export const createSettingsWindow = function () {
settingsWindow.loadURL(`file://${__dirname}/../pages/settings/settings.html`); settingsWindow.loadURL(`file://${__dirname}/../pages/settings/settings.html`);
settingsWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
settingsModule.settingsWindow = settingsWindow; settingsModule.settingsWindow = settingsWindow;
}; };
export const showSettingsWindow = function (tab = "general") { export const showSettingsWindow = function (tab = "general") {
if (!settingsWindow) {
console.log("Settings window is not initialized. Attempting to create it.");
createSettingsWindow();
}
settingsWindow.webContents.send("goToTab", tab); settingsWindow.webContents.send("goToTab", tab);
// refresh data just before showing the window // refresh data just before showing the window
@ -178,25 +93,3 @@ export const hideSettingsWindow = function () {
export const closeSettingsWindow = function () { export const closeSettingsWindow = function () {
settingsWindow = null; settingsWindow = null;
}; };
/**
* add artists to the list of skipped artists
* @param artists list of artists to append
*/
export const addSkippedArtists = (artists: string[]) => {
const { skippedArtists } = settings;
const previousStoreValue = settingsStore.get<string, string[]>(skippedArtists);
settingsStore.set(skippedArtists, Array.from(new Set([...previousStoreValue, ...artists])));
};
/**
* Remove artists from the list of skipped artists
* @param artists list of artists to remove
*/
export const removeSkippedArtists = (artists: string[]) => {
const { skippedArtists } = settings;
const previousStoreValue = settingsStore.get<string, string[]>(skippedArtists);
const filteredArtists = previousStoreValue.filter((value) => ![...artists].includes(value));
settingsStore.set(skippedArtists, filteredArtists);
};

View File

@ -74,7 +74,7 @@ button.feedBell--kvAbD {
.container--PFTHk { .container--PFTHk {
background-color: var(--right-queue-background); background-color: var(--right-queue-background);
} }
.container--cl4MJ { .container--cl4MJ{
background-color: var(--search-background); background-color: var(--search-background);
} }
.searchFieldHighlighted--Fitvs { .searchFieldHighlighted--Fitvs {
@ -83,122 +83,3 @@ button.feedBell--kvAbD {
.searchField--EGBSq { .searchField--EGBSq {
background-color: var(--search-background); background-color: var(--search-background);
} }
// Settings window styling
.settings-window {
color: var(--sidebar-menu-playlist-text);
}
.settings-window__wrapper {
background: var(--main-background);
box-shadow: inset 0 0 2px 0 var(--main-feed-button-background);
}
.settings-window__close-button:hover {
background: var(--main-feed-button-background);
}
.settings input:checked + label {
border-bottom: 2px solid var(--player-control-active-button);
color: var(--player-control-active-button);
}
.tabs::-webkit-scrollbar-thumb {
background-color: #404248;
box-shadow: inset 0 0 10px 2px var(--search-background);
}
.group {
border-bottom: 1px solid #333;
}
.group__description p {
color: var(--sidebar-menu-top-text);
}
.group__description .text-input {
border-bottom: solid 1px #333;
color: var(--sidebar-menu-top-text);
}
.group__description .text-input:focus {
border-color: var(--player-control-active-button);
color: var(--sidebar-menu-playlist-text);
}
.switch input:checked + .switch__slider {
background-color: var(--player-control-active-button);
}
.switch input:checked + .switch__slider::before {
background-color: var(--sidebar-menu-playlist-text);
}
.switch input:focus + .switch__slider {
box-shadow: inset 0 0 0 1px var(--player-control-active-button);
}
.switch__slider {
background-color: var(--search-background);
}
.switch__slider::before {
background-color: var(--sidebar-menu-playlist-text);
}
.textarea {
background: var(--search-background);
color: var(--sidebar-menu-top-text);
}
.textarea:focus {
border-color: var(--player-control-active-button);
color: var(--sidebar-menu-playlist-text);
}
.about-section__version a {
background-color: #404248;
color: var(--player-control-active-button);
}
.about-section__links a {
color: var(--sidebar-menu-playlist-text);
background-color: var(--search-background);
}
.about-section__links a i {
color: var(--sidebar-menu-playlist-text);
}
.about-section__links a:hover {
background-color: var(--player-control-favorite);
}
.footer__note {
color: var(--sidebar-menu-top-text);
}
.footer__button {
background: #404248;
color: var(--sidebar-menu-playlist-text);
}
.footer__button:hover {
background: #55585f;
}
.file-drop-area {
border: 1px dashed var(--sidebar-menu-top-text);
}
.file-drop-area.is-active {
background-color: #17171a;
}
.file-btn {
background-color: #17171a;
border: 1px solid var(--sidebar-menu-top-text);
}
.select-input {
border-bottom: solid 1px #333;
color: var(--sidebar-menu-top-text);
}
.select-input:focus {
border-color: var(--player-control-active-button);
color: var(--sidebar-menu-playlist-text);
}
.select-input option {
background-color: var(--search-background);
}
.select-input option:disabled {
color: var(--sidebar-menu-playlist-text);
}

View File

@ -1,14 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"typeRoots": ["src/types", "node_modules/@types"], "typeRoots": ["src/types"],
"module": "commonjs", "module": "commonjs",
"target": "ES6", "target": "ES6",
"lib": ["ES2020", "DOM"],
"noImplicitAny": true, "noImplicitAny": true,
"sourceMap": true, "sourceMap": true,
"allowJs": true, "allowJs": true,
"outDir": "ts-dist", "outDir": "ts-dist",
"resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {