Compare commits

...

74 Commits

Author SHA1 Message Date
ottomated
3f87ca82f1 Merge 9966561079 into 3740ce5a12 2024-05-17 15:39:03 +00:00
Ottomated
9966561079 bump version 2024-05-17 08:38:57 -07:00
Ottomated
da0893392a Merge branch 'master' of github.com:Mastermindzh/tidal-hifi 2024-05-16 19:33:18 -07:00
Ottomated
3f8ead8a05 Refactor preload script 2024-05-16 19:25:03 -07:00
3740ce5a12 Merge pull request #402 from Mastermindzh/5.12
5.12
2024-05-14 23:08:55 +02:00
a0f9faa753 chore: updating versions 2024-05-14 22:53:07 +02:00
5e3583534b Merge pull request #396 from ThatGravyBoat/api/shuffle-repeat-state
Api Feature: Add shuffle and repeat state to API
2024-05-14 22:50:59 +02:00
ThatGravyBoat
5f8cf33249 Fix mismatched import styling 2024-05-06 03:55:09 -02:30
ThatGravyBoat
2d94b4bf49 Add shuffle and repeat to current state api 2024-05-06 03:50:41 -02:30
6e43cbb4d7 Merge pull request #395 from Mastermindzh/next-version
Next version
2024-05-05 20:46:21 +02:00
f95f13b44a chore: doc fix 2024-05-05 20:35:58 +02:00
f911564d8a chore: version increase 2024-05-05 20:34:58 +02:00
db8a2c2741 feat: reworked the api, added duration/current in seconds + shuffle & repeat 2024-05-05 20:12:10 +02:00
000853414e feat: switched to TIDAL's universal link format in the entire app 2024-05-05 15:09:54 +02:00
53603c4cad Merge pull request #392 from TheRockYT/MediaSessionService
Media session service
2024-05-05 14:25:02 +02:00
0b595f920f Merge pull request #391 from Mastermindzh/dependabot/npm_and_yarn/ejs-3.1.10
chore(deps-dev): bump ejs from 3.1.9 to 3.1.10
2024-05-05 13:57:59 +02:00
81143af3fa Merge pull request #394 from Mastermindzh/snyk-upgrade-0d65fc86b872e70883e2ebe344b05405
[Snyk] Upgrade sass from 1.74.1 to 1.75.0
2024-05-05 13:57:41 +02:00
snyk-bot
8d1ac3be3b fix: upgrade sass from 1.74.1 to 1.75.0
Snyk has created this PR to upgrade sass from 1.74.1 to 1.75.0.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-05-03 22:13:17 +00:00
dependabot[bot]
666e602c02 chore(deps-dev): bump ejs from 3.1.9 to 3.1.10
Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-02 10:01:32 +00:00
TheRockYT
04ec850005 Merge branch 'Mastermindzh:master' into MediaSessionService 2024-05-01 20:55:39 +02:00
943d9b5bd8 Merge pull request #386 from Mastermindzh/snyk-upgrade-2399381ec4ed8a254535fddca6093bb9
[Snyk] Upgrade sass from 1.72.0 to 1.74.1
2024-04-25 10:26:22 +02:00
snyk-bot
755816c2b8 fix: upgrade sass from 1.72.0 to 1.74.1
Snyk has created this PR to upgrade sass from 1.72.0 to 1.74.1.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-04-25 04:29:39 +00:00
25afd05ad7 Merge pull request #374 from TheRockYT/custom_protocol_fix
Custom protocol fix (tidal://...)
2024-04-22 11:23:47 +02:00
6e5024742a Merge pull request #382 from Mastermindzh/snyk-upgrade-e8583a1804fc58a60a199bdcdfddb237
[Snyk] Upgrade sass from 1.71.1 to 1.72.0
2024-04-18 12:34:41 +02:00
417afaab85 Merge pull request #381 from Mastermindzh/snyk-upgrade-eddeada86cf707578042602e7a9acfd4
[Snyk] Upgrade electron-store from 8.1.0 to 8.2.0
2024-04-18 12:34:27 +02:00
d225c0056b Merge pull request #380 from Mastermindzh/snyk-upgrade-636acae2850bc80d0d46d524748be780
[Snyk] Upgrade axios from 1.6.5 to 1.6.8
2024-04-18 12:34:18 +02:00
snyk-bot
a75b0336db fix: upgrade sass from 1.71.1 to 1.72.0
Snyk has created this PR to upgrade sass from 1.71.1 to 1.72.0.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-04-16 20:25:03 +00:00
snyk-bot
29465ce13a fix: upgrade electron-store from 8.1.0 to 8.2.0
Snyk has created this PR to upgrade electron-store from 8.1.0 to 8.2.0.

See this package in npm:
https://www.npmjs.com/package/electron-store

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-04-16 20:25:00 +00:00
snyk-bot
d333047269 fix: upgrade axios from 1.6.5 to 1.6.8
Snyk has created this PR to upgrade axios from 1.6.5 to 1.6.8.

See this package in npm:
https://www.npmjs.com/package/axios

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-04-16 20:24:56 +00:00
TheRockYT
712330f8f1 Enable MediaSessionService flag to allow listen.tidal.com to control it. 2024-04-04 23:08:18 +02:00
TheRockYT
84fd35ce0e Remove implementation of global shortcuts for media control. 2024-04-04 23:06:29 +02:00
TheRockYT
326038f262 Remove custom implementation of MediaSessionService. It is disabled anyway. 2024-04-04 23:05:09 +02:00
a6c1d35a60 Merge pull request #375 from Mastermindzh/snyk-fix-a6b2d7614f87b9d818d7aaf7e6d7650d
[Snyk] Security upgrade express from 4.18.3 to 4.19.2
2024-03-28 14:09:29 +01:00
snyk-bot
c09a4bc4a8 fix: package.json & package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-EXPRESS-6474509
2024-03-26 21:37:31 +00:00
TheRockYT
554cb12a01 Fix Custom Protocol Handling
Fixed an issue where the custom protocol ('tidal://...') wasn't being recognized correctly. Now, the system checks for the presence of the custom protocol before the usual URL ('https://listen.tidal.com') is loaded.

Also implemented functionality to ensure that if only one instance is allowed at a time, the first instance changes its page accordingly.
2024-03-26 00:16:09 +01:00
2e31b5d913 Merge pull request #373 from Mastermindzh/develop
5.10.0
2024-03-24 21:11:58 +01:00
2fd29c1b83 added rel='noopener' to external links 2024-03-24 21:00:49 +01:00
b2f27a2afe Enabled wayland platform flags by default when launching through .desktop file fixes #273 #347 2024-03-24 20:54:46 +01:00
8e11fd7f09 Reverted to using old icon syntax with icons in the build directory. fixes #350 2024-03-24 16:17:46 +01:00
17b2818b70 Refactored nowPlaying code to always display the current state, even when the built-in UI is updated. fixes #351 #356 #370 2024-03-24 16:13:21 +01:00
4ef76c262e Links in the about window now open in the user's default browser. fixes #360 2024-03-24 15:55:33 +01:00
fd0dae2762 fix: rewrote docs to fix #365 2024-03-24 15:42:50 +01:00
aa59bdc6dd Merge branch 'master' of github.com:Mastermindzh/tidal-hifi into develop 2024-03-24 15:34:18 +01:00
5b5b6ecb38 Merge pull request #364 from lennart-k/master
feature: Track current notification to replace it on track change
2024-03-24 15:34:00 +01:00
5983145857 Merge pull request #369 from Mastermindzh/dependabot/npm_and_yarn/follow-redirects-1.15.6
chore(deps): bump follow-redirects from 1.15.4 to 1.15.6
2024-03-24 15:32:14 +01:00
0c7d579951 Merge pull request #371 from Mastermindzh/snyk-upgrade-30d8b55c8b39463bad9d0408a76bfa86
[Snyk] Upgrade express from 4.18.2 to 4.18.3
2024-03-24 15:32:02 +01:00
snyk-bot
235d916749 fix: upgrade express from 4.18.2 to 4.18.3
Snyk has created this PR to upgrade express from 4.18.2 to 4.18.3.

See this package in npm:
https://www.npmjs.com/package/express

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-03-21 17:32:39 +00:00
dependabot[bot]
2d9f268866 chore(deps): bump follow-redirects from 1.15.4 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-16 22:30:30 +00:00
ae65e57e32 Merge pull request #367 from Mastermindzh/snyk-upgrade-0829d5b61286f530a986191886019e0f
[Snyk] Upgrade sass from 1.71.0 to 1.71.1
2024-03-14 16:08:02 +01:00
snyk-bot
3f2d69f2f4 fix: upgrade sass from 1.71.0 to 1.71.1
Snyk has created this PR to upgrade sass from 1.71.0 to 1.71.1.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-03-14 01:58:53 +00:00
5ff2cc68d3 Merge pull request #359 from Mastermindzh/snyk-upgrade-f5ef319e06f8e24c1358e777b2415e49
[Snyk] Upgrade hotkeys-js from 3.13.6 to 3.13.7
2024-03-12 09:21:24 +01:00
daabe5bdbb Merge pull request #363 from Mastermindzh/snyk-upgrade-63565ec96f33448467289d767c1fb965
[Snyk] Upgrade sass from 1.70.0 to 1.71.0
2024-03-12 09:20:35 +01:00
Lennart
456727c0e0 feature: Track current notification to replace it on track change 2024-03-09 17:49:18 +01:00
snyk-bot
ba50e0c095 fix: upgrade sass from 1.70.0 to 1.71.0
Snyk has created this PR to upgrade sass from 1.70.0 to 1.71.0.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-03-08 19:44:36 +00:00
snyk-bot
312e90e8cb fix: upgrade hotkeys-js from 3.13.6 to 3.13.7
Snyk has created this PR to upgrade hotkeys-js from 3.13.6 to 3.13.7.

See this package in npm:
https://www.npmjs.com/package/hotkeys-js

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-03-01 19:11:09 +00:00
76769dfab3 Merge pull request #354 from Mastermindzh/snyk-upgrade-d64f8964b9d005d97354597114dfafa3
[Snyk] Upgrade hotkeys-js from 3.13.5 to 3.13.6
2024-02-29 09:11:22 +01:00
snyk-bot
565d32ae3d fix: upgrade hotkeys-js from 3.13.5 to 3.13.6
Snyk has created this PR to upgrade hotkeys-js from 3.13.5 to 3.13.6.

See this package in npm:
https://www.npmjs.com/package/hotkeys-js

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-02-24 02:43:08 +00:00
7be6f79040 Merge pull request #349 from Mastermindzh/snyk-upgrade-f0f8b19cd2b7f7662ced7cf1b914e52a
[Snyk] Upgrade @electron/remote from 2.1.1 to 2.1.2
2024-02-16 13:36:45 +01:00
snyk-bot
f894c82b12 fix: upgrade @electron/remote from 2.1.1 to 2.1.2
Snyk has created this PR to upgrade @electron/remote from 2.1.1 to 2.1.2.

See this package in npm:
https://www.npmjs.com/package/@electron/remote

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-02-16 02:47:27 +00:00
21cb0ea79d Merge pull request #343 from Mastermindzh/5.9.0
5.9.0
2024-02-12 22:56:05 +01:00
49bc737485 Made sure settingsWindow exists before operating on it. fixes #344 2024-02-12 22:37:03 +01:00
10a4af8e90 icons 2024-02-11 23:37:28 +01:00
317a685813 Fixed chromium mediaSession instance showing up. fixes #338 2024-02-11 23:17:20 +01:00
4da6d9feda Merge pull request #332 from TheRockYT/master
More customizable Discord-Presence
2024-02-11 22:55:06 +01:00
b11dbbd6d8 - 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
2024-02-11 22:42:45 +01:00
887a3d8a45 Merge pull request #341 from Mastermindzh/snyk-upgrade-e734c4e044f11fcf328091d8b0248af0
[Snyk] Upgrade sass from 1.69.7 to 1.70.0
2024-02-10 10:44:13 +01:00
snyk-bot
2e17b066a3 fix: upgrade sass from 1.69.7 to 1.70.0
Snyk has created this PR to upgrade sass from 1.69.7 to 1.70.0.

See this package in npm:
https://www.npmjs.com/package/sass

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-02-08 18:52:27 +00:00
TheRockYT
e37b2f99cc Merge branch 'Mastermindzh:master' into master 2024-02-07 09:53:03 +01:00
1afd4d22a6 Merge pull request #339 from Mastermindzh/snyk-upgrade-09d7345c3d57cddc47b571cbaecbf842
[Snyk] Upgrade hotkeys-js from 3.13.3 to 3.13.5
2024-02-06 09:18:00 +01:00
snyk-bot
12a919df45 fix: upgrade hotkeys-js from 3.13.3 to 3.13.5
Snyk has created this PR to upgrade hotkeys-js from 3.13.3 to 3.13.5.

See this package in npm:
https://www.npmjs.com/package/hotkeys-js

See this project in Snyk:
https://app.snyk.io/org/mastermindzh/project/dade8f03-2064-49a3-8957-edbacec3887c?utm_source=github&utm_medium=referral&page=upgrade-pr
2024-02-04 17:49:06 +00:00
TheRockYT
36a2367397 Implemented the logic in the discord.ts file as mentioned in earlier commits 2024-01-08 21:13:02 +01:00
TheRockYT
7c6d2df16a The setting "discord_include_timestamps" is now only shown if "discord_show_song_options" is enabled 2024-01-08 21:06:55 +01:00
TheRockYT
22383a9f45 Made order of settings more logical:
The settings now appear directly below the show song switch
2024-01-08 20:31:27 +01:00
TheRockYT
623033ccd7 Added discord options: showSong, idleText, listeningText
showSong (boolean): If enabled, the client will show the current song on discord.

idleText (string): This text is shown if no song is playing.

listeningText (string): This text is shown if a song is playing, but showSong is set to false.
2024-01-08 20:23:26 +01:00
54 changed files with 9460 additions and 9270 deletions

View File

@@ -4,6 +4,54 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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))

View File

@@ -130,10 +130,13 @@ nix-env -iA nixpkgs.tidal-hifi
To install and work with the code on this project follow these steps:
- git clone [https://github.com/Mastermindzh/tidal-hifi.git](https://github.com/Mastermindzh/tidal-hifi.git)
- cd TIDAL Hi-Fi
- npm install
- npm start
- `git clone [https://github.com/Mastermindzh/tidal-hifi.git](https://github.com/Mastermindzh/tidal-hifi.git)`
- `cd tidal-hifi`
- `npm install`
- `npm run watch` to watch for auto-reload of Typescript/SCSS changes.
- `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

View File

@@ -11,10 +11,16 @@ extraResources:
- "themes/**"
linux:
category: AudioVideo
icon: assets/icons
icon: build/icons
target:
- dir
executableName: tidal-hifi
executableArgs:
[
"--enable-features=UseOzonePlatform",
"--ozone-platform-hint=auto",
"--enable-features=WaylandWindowDecorations",
]
desktop:
Encoding: UTF-8
Name: TIDAL Hi-Fi

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
build/icons/22x22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
build/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
build/icons/384x384.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
build/icons/icon.icns Normal file

Binary file not shown.

BIN
build/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

16252
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +1,76 @@
{
"name": "tidal-hifi",
"version": "5.8.0",
"description": "Tidal on Electron with widevine(hifi) support",
"main": "ts-dist/main.js",
"scripts": {
"start": "electron --inspect=0.0.0.0:5858 .",
"watchStart": "nodemon dist -x \"npm run start\"",
"compile": "tsc && npm run sass-and-copy",
"deps": "npm run watch",
"watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist",
"copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev",
"build": "npm run builder -- -c ./build/electron-builder.yml",
"build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml",
"build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml",
"build-rpm": "npm run builder -- -c ./build/electron-builder.rpm.yml",
"build-snap": "npm run builder -- -c ./build/electron-builder.snap.yml",
"build-arch": "npm run builder -- -c ./build/electron-builder.pacman.yml",
"build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl",
"build-mac": "npm run builder -- -c ./build/electron-builder.yml -m",
"build-base": "npm run builder -- -c ./build/electron-builder.base.yml",
"prebuilder": "npm run compile",
"builder": "electron-builder --publish=never",
"sass": "sass ./src/pages/settings/settings.scss ./src/pages/settings/settings.css && sass --no-source-map src/themes:themes",
"style-lint": "npx stylelint **/*.scss",
"style-lint-fix": "npx stylelint --fix **/*.scss"
},
"keywords": [
"electron",
"hifi",
"widevine",
"linux",
"drm",
"castlabs"
],
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
"homepage": "https://github.com/Mastermindzh/tidal-hifi",
"license": "MIT",
"dependencies": {
"@electron/remote": "^2.1.1",
"axios": "^1.6.5",
"discord-rpc": "^4.0.1",
"electron-store": "^8.1.0",
"express": "^4.18.2",
"hotkeys-js": "^3.13.3",
"mpris-service": "^2.1.2",
"request": "^2.88.2",
"sass": "^1.69.7"
},
"devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0",
"@types/discord-rpc": "^4.0.8",
"@types/express": "^4.17.21",
"@types/node": "^20.10.6",
"@types/request": "^2.48.12",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"copyfiles": "^2.4.1",
"electron": "git+https://github.com/castlabs/electron-releases#v28.1.1+wvcus",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"js-yaml": "^4.1.0",
"markdown-toc": "^1.2.0",
"nodemon": "^3.0.2",
"prettier": "^3.1.1",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"stylelint-prettier": "^5.0.0",
"tsc-watch": "^6.0.4",
"typescript": "^5.3.3"
},
"prettier": "@mastermindzh/prettier-config"
}
"name": "tidal-hifi",
"version": "5.13.0",
"description": "Tidal on Electron with widevine(hifi) support",
"main": "ts-dist/main.js",
"scripts": {
"start": "electron --inspect=0.0.0.0:5858 .",
"watchStart": "nodemon dist -x \"npm run start\"",
"compile": "tsc && npm run sass-and-copy",
"deps": "npm run watch",
"watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist",
"copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev",
"build": "npm run builder -- -c ./build/electron-builder.yml",
"build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml",
"build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml",
"build-rpm": "npm run builder -- -c ./build/electron-builder.rpm.yml",
"build-snap": "npm run builder -- -c ./build/electron-builder.snap.yml",
"build-arch": "npm run builder -- -c ./build/electron-builder.pacman.yml",
"build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl",
"build-mac": "npm run builder -- -c ./build/electron-builder.yml -m",
"build-base": "npm run builder -- -c ./build/electron-builder.base.yml",
"prebuilder": "npm run compile",
"builder": "electron-builder --publish=never",
"sass": "sass ./src/pages/settings/settings.scss ./src/pages/settings/settings.css && sass --no-source-map src/themes:themes",
"style-lint": "npx stylelint **/*.scss",
"style-lint-fix": "npx stylelint --fix **/*.scss"
},
"keywords": [
"electron",
"hifi",
"widevine",
"linux",
"drm",
"castlabs"
],
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
"homepage": "https://github.com/Mastermindzh/tidal-hifi",
"license": "MIT",
"dependencies": {
"@electron/remote": "^2.1.2",
"axios": "^1.6.8",
"discord-rpc": "^4.0.1",
"electron-store": "^8.2.0",
"express": "^4.19.2",
"fast-deep-equal": "^3.1.3",
"hotkeys-js": "^3.13.7",
"mpris-service": "^2.1.2",
"request": "^2.88.2",
"sass": "^1.75.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0",
"@types/discord-rpc": "^4.0.8",
"@types/express": "^4.17.21",
"@types/node": "^20.10.6",
"@types/request": "^2.48.12",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"copyfiles": "^2.4.1",
"electron": "git+https://github.com/castlabs/electron-releases#v28.1.1+wvcus",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
"nodemon": "^3.0.2",
"prettier": "^3.1.1",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"stylelint-prettier": "^5.0.0",
"tsc-watch": "^6.0.4",
"typescript": "^5.3.3"
},
"prettier": "@mastermindzh/prettier-config"
}

View File

@@ -11,6 +11,9 @@ export const globalEvents = {
storeChanged: "storeChanged",
error: "error",
whip: "whip",
downloadCover: "downloadCover",
log: "log",
toggleFavorite: "toggleFavorite",
};
toggleShuffle: "toggleShuffle",
toggleRepeat: "toggleRepeat",
} as const;

View File

@@ -24,6 +24,9 @@ export const settings = {
detailsPrefix: "discord.detailsPrefix",
buttonText: "discord.buttonText",
includeTimestamps: "discord.includeTimestamps",
showSong: "discord.showSong",
idleText: "discord.idleText",
usingText: "discord.usingText",
},
ListenBrainz: {
root: "ListenBrainz",
@@ -54,4 +57,4 @@ export const settings = {
width: "windowBounds.width",
height: "windowBounds.height",
},
};
} as const;

View File

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

View File

@@ -0,0 +1,15 @@
import { Request, Response, Router } from "express";
import { getLegacyMediaInfo, mainTidalState } from "../../state";
export const addCurrentInfo = (expressApp: Router) => {
expressApp.get("/current", (_, res) => res.json(getLegacyMediaInfo()));
expressApp.get("/current/image", getCurrentImage);
};
export const getCurrentImage = (_: Request, res: Response) => {
if (!mainTidalState.currentTrack) {
res.sendStatus(404).end("No song is playing");
return;
}
res.redirect(mainTidalState.currentTrack.image);
};

View File

@@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserWindow } from "electron";
import { Router } from "express";
import { globalEvents } from "../../../constants/globalEvents";
import { settings } from "../../../constants/settings";
import { settingsStore } from "../../../scripts/settings";
export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow) => {
const createPlayerAction = (route: string, action: string) => {
expressApp.post(`/player${route}`, (_, res) => {
mainWindow.webContents.send("globalEvent", action);
res.sendStatus(200);
});
};
if (settingsStore.get(settings.playBackControl)) {
createPlayerAction("/play", globalEvents.play);
createPlayerAction("/favorite/toggle", globalEvents.toggleFavorite);
createPlayerAction("/pause", globalEvents.pause);
createPlayerAction("/next", globalEvents.next);
createPlayerAction("/previous", globalEvents.previous);
createPlayerAction("/shuffle/toggle", globalEvents.toggleShuffle);
createPlayerAction("/repeat/toggle", globalEvents.toggleRepeat);
createPlayerAction("/playpause", globalEvents.playPause);
}
};

31
src/features/api/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { BrowserWindow, dialog } from "electron";
import express from "express";
import { settings } from "../../constants/settings";
import { settingsStore } from "../../scripts/settings";
import { addCurrentInfo } from "./features/current";
import { addPlaybackControl } from "./features/player";
import { addLegacyApi } from "./legacy";
/**
* Function to enable TIDAL Hi-Fi's express api
*/
export const startApi = (mainWindow: BrowserWindow) => {
const expressApp = express();
expressApp.get("/", (req, res) => res.send("Hello World!"));
// add features
addLegacyApi(expressApp, mainWindow);
addPlaybackControl(expressApp, mainWindow);
addCurrentInfo(expressApp);
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

@@ -0,0 +1,39 @@
import { BrowserWindow } from "electron";
import { Response, Router } from "express";
import { globalEvents } from "../../constants/globalEvents";
import { settings } from "../../constants/settings";
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) => {
expressApp.get("/image", getCurrentImage);
if (settingsStore.get(settings.playBackControl)) {
addLegacyControls();
}
function addLegacyControls() {
expressApp.get("/play", ({ res }) => handleGlobalEvent(res, globalEvents.play));
expressApp.post("/favorite/toggle", (req, res) =>
handleGlobalEvent(res, globalEvents.toggleFavorite)
);
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) => handleGlobalEvent(res, globalEvents.playPause));
}
/**
* 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,7 +1,6 @@
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";
@@ -43,84 +42,81 @@ export class ListenBrainz {
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 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,
if (status === "Paused") return;
// 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 playing_data = {
listen_type: "playing_now",
payload: [
{
headers: {
"Content-Type": "application/json",
Authorization: `Token ${settingsStore.get<string, string>(
settings.ListenBrainz.token
)}`,
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)
);
if (!oldData) {
} 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: "listen.tidal.com",
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)
);
} 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: "listen.tidal.com",
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) {

36
src/features/state.ts Normal file
View File

@@ -0,0 +1,36 @@
import { TidalState } from "../models/tidalState";
export const mainTidalState: TidalState = {
status: "Stopped",
repeat: "Off",
shuffle: false,
};
export function getLegacyMediaInfo() {
function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? "0" : ""}${secondsLeft}`;
}
return {
title: mainTidalState.currentTrack?.title ?? "",
artists: mainTidalState.currentTrack?.artists.join(", ") ?? "",
artist: mainTidalState.currentTrack?.artists.join(", ") ?? "",
album: mainTidalState.currentTrack?.album ?? "",
icon: mainTidalState.currentTrack?.image ?? "",
status: mainTidalState.status.toLowerCase(),
url: mainTidalState.currentTrack?.url ?? "",
current: formatDuration(mainTidalState.currentTrack?.current ?? 0),
currentInSeconds: mainTidalState.currentTrack?.current ?? 0,
duration: formatDuration(mainTidalState.currentTrack?.duration ?? 0),
durationInSeconds: mainTidalState.currentTrack?.duration ?? 0,
image: "tidal-hifi-icon",
favorite: false,
player: {
status: mainTidalState.status.toLowerCase(),
shuffle: mainTidalState.shuffle,
repeat: mainTidalState.repeat,
},
};
}

View File

@@ -1,17 +1,9 @@
import { enable, initialize } from "@electron/remote/main";
import {
app,
BrowserWindow,
components,
globalShortcut,
ipcMain,
protocol,
session,
} from "electron";
import { BrowserWindow, app, components, ipcMain, session } from "electron";
import path from "path";
import { globalEvents } from "./constants/globalEvents";
import { mediaKeys } from "./constants/mediaKeys";
import { settings } from "./constants/settings";
import { startApi } from "./features/api";
import { setDefaultFlags, setManagedFlagsFromSettings } from "./features/flags/flags";
import {
acquireInhibitorIfInactive,
@@ -19,11 +11,7 @@ import {
} from "./features/idleInhibitor/idleInhibitor";
import { Logger } from "./features/logger";
import { Songwhip } from "./features/songwhip/songwhip";
import { MediaInfo } from "./models/mediaInfo";
import { MediaStatus } from "./models/mediaStatus";
import { initRPC, rpc, unRPC } from "./scripts/discord";
import { startExpress } from "./scripts/express";
import { updateMediaInfo } from "./scripts/mediaInfo";
import { addMenu } from "./scripts/menu";
import {
closeSettingsWindow,
@@ -33,6 +21,10 @@ import {
showSettingsWindow,
} from "./scripts/settings";
import { addTray, refreshTray } from "./scripts/tray";
import axios from "axios";
import { existsSync, createWriteStream } from "fs";
import { mainTidalState } from "./features/state";
import { TidalState } from "./models/tidalState";
const tidalUrl = "https://listen.tidal.com";
let mainInhibitorId = -1;
@@ -61,20 +53,31 @@ function syncMenuBarWithStore() {
}
/**
* 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
* @returns true/false based on whether the current window is the main window
*/
function isMainInstanceOrMultipleInstancesAllowed() {
if (settingsStore.get(settings.singleInstance)) {
const gotTheLock = app.requestSingleInstanceLock();
function isMainInstance() {
return app.requestSingleInstanceLock();
}
if (!gotTheLock) {
return false;
}
/**
* @returns true/false based on whether multiple instances are allowed
*/
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" }) {
@@ -89,17 +92,24 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
autoHideMenuBar: true,
webPreferences: {
...windowPreferences,
...{
preload: path.join(__dirname, "preload.js"),
},
preload: path.join(__dirname, "preload/index.js"),
contextIsolation: false,
},
});
enable(mainWindow.webContents);
registerHttpProtocols();
syncMenuBarWithStore();
// load the Tidal website
mainWindow.loadURL(tidalUrl);
// find the custom protocol argument
const customProtocolUrl = getCustomProtocolUrl(process.argv);
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)) {
// prevent setInterval lag
@@ -139,27 +149,32 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
}
function registerHttpProtocols() {
protocol.registerHttpProtocol(PROTOCOL_PREFIX, (request) => {
mainWindow.loadURL(`${tidalUrl}/${request.url.substring(PROTOCOL_PREFIX.length + 3)}`);
});
if (!app.isDefaultProtocolClient(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
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", async () => {
if (isMainInstanceOrMultipleInstancesAllowed()) {
// check if the app is the main instance and multiple instances are not allowed
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();
// Adblock
@@ -174,12 +189,11 @@ app.on("ready", async () => {
createWindow();
addMenu(mainWindow);
createSettingsWindow();
addGlobalShortcuts();
if (settingsStore.get(settings.trayIcon)) {
addTray(mainWindow, { icon });
refreshTray(mainWindow);
}
settingsStore.get(settings.api) && startExpress(mainWindow);
settingsStore.get(settings.api) && startApi(mainWindow);
settingsStore.get(settings.enableDiscord) && initRPC();
} else {
app.quit();
@@ -199,9 +213,9 @@ app.on("browser-window-created", (_, window) => {
});
// IPC
ipcMain.on(globalEvents.updateInfo, (_event, arg: MediaInfo) => {
updateMediaInfo(arg);
if (arg.status === MediaStatus.playing) {
ipcMain.on(globalEvents.updateInfo, (_event, arg: TidalState) => {
Object.assign(mainTidalState, arg);
if (arg.status === "Playing") {
mainInhibitorId = acquireInhibitorIfInactive(mainInhibitorId);
} else {
releaseInhibitorIfActive(mainInhibitorId);
@@ -234,8 +248,21 @@ ipcMain.on(globalEvents.error, (event) => {
console.log(event);
});
ipcMain.handle(globalEvents.whip, async (event, url) => {
ipcMain.handle(globalEvents.whip, async (_, url) => {
return Songwhip.whip(url);
});
ipcMain.handle(globalEvents.downloadCover, async (_, id, url) => {
const targetPath = `${app.getPath("userData")}/cover-${id}.jpg`;
if (existsSync(targetPath)) return targetPath;
const res = await axios.get(url, {
responseType: "stream",
});
res.data.pipe(createWriteStream(targetPath));
return new Promise((resolve, reject) => {
res.data.on("end", () => resolve(targetPath));
res.data.on("error", reject);
});
});
Logger.watch(ipcMain);

View File

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

View File

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

View File

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

16
src/models/tidalState.ts Normal file
View File

@@ -0,0 +1,16 @@
export type TidalState = {
status: "Playing" | "Paused" | "Stopped";
repeat: "Off" | "All" | "Single";
shuffle: boolean;
currentTrack?: {
id: number;
title: string;
// undefined for videos
album?: string;
artists: string[];
current: number;
duration: number;
url: string;
image: string;
};
};

View File

@@ -20,7 +20,12 @@ const switchesWithSettings = {
classToHide: "discord_options",
settingsKey: settings.enableDiscord,
},
};
discord_show_song: {
switch: "discord_show_song",
classToHide: "discord_show_song_options",
settingsKey: settings.discord.showSong,
},
} as const;
let adBlock: HTMLInputElement,
api: HTMLInputElement,
@@ -49,7 +54,10 @@ let adBlock: HTMLInputElement,
enableWaylandSupport: HTMLInputElement,
discord_details_prefix: HTMLInputElement,
discord_include_timestamps: HTMLInputElement,
discord_button_text: HTMLInputElement;
discord_button_text: HTMLInputElement,
discord_show_song: HTMLInputElement,
discord_idle_text: HTMLInputElement,
discord_using_text: HTMLInputElement;
addCustomCss(app);
@@ -130,7 +138,7 @@ function refreshSettings() {
theme.value = settingsStore.get(settings.theme);
skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
trayIcon.checked = settingsStore.get(settings.trayIcon);
updateFrequency.value = settingsStore.get(settings.updateFrequency);
updateFrequency.value = settingsStore.get(settings.updateFrequency).toString();
enableListenBrainz.checked = settingsStore.get(settings.ListenBrainz.enabled);
ListenBrainzAPI.value = settingsStore.get(settings.ListenBrainz.api);
ListenBrainzToken.value = settingsStore.get(settings.ListenBrainz.token);
@@ -138,11 +146,17 @@ function refreshSettings() {
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_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);
});
for (const settingSwitch of Object.values(switchesWithSettings)) {
setElementHidden(
settingsStore.get(settingSwitch.settingsKey as any) as boolean,
settingSwitch
);
}
} catch (error) {
Logger.log("Refreshing settings failed.", error);
}
@@ -251,6 +265,9 @@ window.addEventListener("DOMContentLoaded", () => {
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_using_text = get("discord_using_text");
discord_idle_text = get("discord_idle_text");
refreshSettings();
addInputListener(adBlock, settings.adBlock);
@@ -285,4 +302,11 @@ window.addEventListener("DOMContentLoaded", () => {
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_idle_text, settings.discord.idleText);
addInputListener(discord_using_text, settings.discord.usingText);
});

View File

@@ -216,32 +216,64 @@
</label>
</div>
<div id="discord_options">
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Include timestamps</h4>
<p>Show current/end playtime in the Discord client</p>
<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 song</h4>
<p>Show the current song in the Discord client</p>
</div>
<label class="switch">
<input id="discord_include_timestamps" type="checkbox" />
<input id="discord_show_song" 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 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 song information.</p>
<input id="discord_button_text" type="text" class="text-input" name="discord_button_text" />
</div>
</div>
</div>
<div class="group__option">
<div class="group__description">
<h4>Button text</h4>
<p>Text to display on the button below the song information.</p>
<input id="discord_button_text" type="text" class="text-input" name="discord_button_text" />
</div>
</div>
</div>
</div>
<div class="group">
@@ -400,14 +432,16 @@
<img alt="tidal icon" class="about-section__icon" src="./icon.png" />
<h4>TIDAL Hi-Fi</h4>
<div class="about-section__version">
<a href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.8.0">5.8.0</a>
<a target="_blank" rel="noopener"
href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.12.0">5.12.0</a>
</div>
<div class="about-section__links">
<a href="https://github.com/mastermindzh/tidal-hifi/" class="about-section__button">Github <i
class="fa fa-external-link"></i></a>
<a href="https://github.com/Mastermindzh/tidal-hifi/issues" class="about-section__button">Report an issue <i
class="fa fa-external-link"></i></a>
<a href="https://github.com/Mastermindzh/tidal-hifi/graphs/contributors"
<a target="_blank" rel="noopener" href="https://github.com/mastermindzh/tidal-hifi/"
class="about-section__button">Github
<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>

View File

@@ -415,7 +415,6 @@ html {
}
// file upload
.file-drop-area {
position: relative;
display: flex;

View File

@@ -1,591 +0,0 @@
import { app, dialog, Notification } from "@electron/remote";
import { clipboard, ipcRenderer } from "electron";
import Player from "mpris-service";
import { globalEvents } from "./constants/globalEvents";
import { settings } from "./constants/settings";
import {
ListenBrainz,
ListenBrainzConstants,
ListenBrainzStore,
} from "./features/listenbrainz/listenbrainz";
import { StoreData } from "./features/listenbrainz/models/storeData";
import { Logger } from "./features/logger";
import { Songwhip } from "./features/songwhip/songwhip";
import { addCustomCss } from "./features/theming/theming";
import { MediaStatus } from "./models/mediaStatus";
import { Options } from "./models/options";
import { downloadFile } from "./scripts/download";
import { addHotkey } from "./scripts/hotkeys";
import { settingsStore } from "./scripts/settings";
import { setTitle } from "./scripts/window-functions";
const notificationPath = `${app.getPath("userData")}/notification.jpg`;
const appName = "TIDAL Hi-Fi";
let currentSong = "";
let player: Player;
let currentPlayStatus = MediaStatus.paused;
let currentListenBrainzDelayId: ReturnType<typeof setTimeout>;
let scrobbleWaitingForDelay = false;
const 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: '*[class^="profileOptions"]',
settings: '*[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: '.header-details [data-test="title"]',
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"]',
/**
* Get an element from the dom
* @param {*} key key in elements object to fetch
*/
get: function (key: string) {
return window.document.querySelector(this[key.toLowerCase()]);
},
/**
* Get the icon of the current song
*/
getSongIcon: function () {
const figure = this.get("media");
if (figure) {
const mediaElement = figure.querySelector(this["image"]);
if (mediaElement) {
return mediaElement.src.replace("80x80", "640x640");
}
}
return "";
},
/**
* returns an array of all artists in the current song
* @returns {Array} artists
*/
getArtistsArray: function () {
const footer = this.get("footer");
if (footer) {
const artists = footer.querySelectorAll(this.artists);
if (artists) return Array.from(artists).map((artist) => (artist as HTMLElement).textContent);
}
return [];
},
/**
* unify the artists array into a string separated by commas
* @param {Array} artistsArray
* @returns {String} artists
*/
getArtistsString: function (artistsArray: string[]) {
if (artistsArray.length > 0) return artistsArray.join(", ");
return "unknown artist(s)";
},
getAlbumName: function () {
//If listening to an album, get its name from the header title
if (window.location.href.includes("/album/")) {
const albumName = window.document.querySelector(this.album_header_title);
if (albumName) {
return albumName.textContent;
}
//If listening to a playlist or a mix, get album name from the list
} else if (
window.location.href.includes("/playlist/") ||
window.location.href.includes("/mix/")
) {
if (currentPlayStatus === MediaStatus.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.
// 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) {
return row.querySelector(this.album_name_cell).textContent;
}
}
}
return "";
},
isMuted: function () {
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
* @param {*} key key in elements object to fetch
*/
getText: function (key: string) {
const element = this.get(key);
return element ? element.textContent : "";
},
/**
* Shorthand function to click a dom element
* @param {*} key key in elements object to fetch
*/
click: function (key: string) {
this.get(key).click();
return this;
},
/**
* Shorthand function to focus a dom element
* @param {*} key key in elements object to fetch
*/
focus: function (key: string) {
return this.get(key).focus();
},
};
/**
* Get the update frequency from the store
* make sure it returns a number, if not use the default
*/
function getUpdateFrequency() {
const storeValue = settingsStore.get<string, number>(settings.updateFrequency);
const defaultValue = 500;
if (!isNaN(storeValue)) {
return storeValue;
} else {
return defaultValue;
}
}
/**
* Play or pause the current song
*/
function playPause() {
const play = elements.get("play");
if (play) {
elements.click("play");
} else {
elements.click("pause");
}
}
/**
* Clears the old listenbrainz data on launch
*/
ListenBrainzStore.clear();
/**
* Add hotkeys for when tidal is focused
* Reflects the desktop hotkeys found on:
* https://defkey.com/tidal-desktop-shortcuts
*/
function addHotKeys() {
if (settingsStore.get(settings.enableCustomHotkeys)) {
addHotkey("Control+p", function () {
elements.click("account");
setTimeout(() => {
elements.click("settings");
}, 100);
});
addHotkey("Control+l", function () {
handleLogout();
});
addHotkey("Control+a", function () {
elements.click("favorite");
});
addHotkey("Control+h", function () {
elements.click("home");
});
addHotkey("backspace", function () {
elements.click("back");
});
addHotkey("shift+backspace", function () {
elements.click("forward");
});
addHotkey("control+u", function () {
// reloading window without cache should show the update bar if applicable
window.location.reload();
});
addHotkey("control+r", function () {
elements.click("repeat");
});
addHotkey("control+w", async function () {
const result = await ipcRenderer.invoke(globalEvents.whip, getTrackURL());
const url = Songwhip.getWhipUrl(result);
clipboard.writeText(url);
new Notification({
title: `Successfully whipped: `,
body: `URL copied to clipboard: ${url}`,
}).show();
});
}
// always add the hotkey for the settings window
addHotkey("control+=", function () {
ipcRenderer.send(globalEvents.showSettings);
});
addHotkey("control+0", function () {
ipcRenderer.send(globalEvents.showSettings);
});
}
/**
* This function will ask the user whether he/she wants to log out.
* It will log the user out if he/she selects "yes"
*/
function handleLogout() {
const logoutOptions = ["Cancel", "Yes, please", "No, thanks"];
dialog
.showMessageBox(null, {
type: "question",
title: "Logging out",
message: "Are you sure you want to log out?",
buttons: logoutOptions,
defaultId: 2,
})
.then((result: { response: number }) => {
if (logoutOptions.indexOf("Yes, please") === result.response) {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key.startsWith("_TIDAL_activeSession")) {
window.localStorage.removeItem(key);
break;
}
}
window.location.reload();
}
});
}
function addFullScreenListeners() {
window.document.addEventListener("fullscreenchange", () => {
ipcRenderer.send(globalEvents.refreshMenuBar);
});
}
/**
* Add ipc event listeners.
* Some actions triggered outside of the site need info from the site.
*/
function addIPCEventListeners() {
window.addEventListener("DOMContentLoaded", () => {
ipcRenderer.on("globalEvent", (_event, args) => {
switch (args) {
case globalEvents.playPause:
playPause();
break;
case globalEvents.next:
elements.click("next");
break;
case globalEvents.previous:
elements.click("previous");
break;
case globalEvents.play:
elements.click("play");
break;
case globalEvents.pause:
elements.click("pause");
break;
case globalEvents.toggleFavorite:
elements.click("favorite");
break;
default:
break;
}
});
});
}
/**
* Update the current status of tidal (e.g playing or paused)
*/
function getCurrentlyPlayingStatus() {
const pause = elements.get("pause");
let status = undefined;
// if pause button is visible tidal is playing
if (pause) {
status = MediaStatus.playing;
} else {
status = MediaStatus.paused;
}
return status;
}
/**
* Convert the duration from MM:SS to seconds
* @param {*} duration
*/
function convertDuration(duration: string) {
const parts = duration.split(":");
return parseInt(parts[1]) + 60 * parseInt(parts[0]);
}
/**
* Update Tidal-hifi's media info
*
* @param {*} options
*/
function updateMediaInfo(options: Options, notify: boolean) {
if (options) {
ipcRenderer.send(globalEvents.updateInfo, options);
if (settingsStore.get(settings.notifications) && notify) {
new Notification({ title: options.title, body: options.artists, icon: options.icon }).show();
}
updateMpris(options);
updateListenBrainz(options);
}
}
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 () {
app.quit();
});
} catch (exception) {
Logger.log("MPRIS player api not working", exception);
}
}
}
function updateMpris(options: Options) {
if (player) {
player.metadata = {
...player.metadata,
...{
"xesam:title": options.title,
"xesam:artist": [options.artists],
"xesam:album": options.album,
"mpris:artUrl": options.image,
"mpris:length": convertDuration(options.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
},
};
player.playbackStatus = options.status === MediaStatus.paused ? "Paused" : "Playing";
}
}
/**
* Update the listenbrainz service with new data based on a few conditions
*/
function updateListenBrainz(options: Options) {
if (settingsStore.get(settings.ListenBrainz.enabled)) {
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
if (
(!oldData && options.status === MediaStatus.playing) ||
(oldData && oldData.title !== options.title)
) {
if (!scrobbleWaitingForDelay) {
scrobbleWaitingForDelay = true;
clearTimeout(currentListenBrainzDelayId);
currentListenBrainzDelayId = setTimeout(
() => {
ListenBrainz.scrobble(
options.title,
options.artists,
options.status,
convertDuration(options.duration)
);
scrobbleWaitingForDelay = false;
},
settingsStore.get(settings.ListenBrainz.delay) ?? 0
);
}
}
}
}
/**
* Checks if Tidal is playing a video or song by grabbing the "a" element from the title.
* If it's a song it returns the track URL, if not it will return undefined
*/
function getTrackURL() {
const id = getTrackID();
return `https://tidal.com/browse/track/${id}`;
}
function getTrackID() {
const URLelement = elements.get("title").querySelector("a");
if (URLelement !== null) {
const id = URLelement.href.replace(/\D/g, "");
return id;
}
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
*/
setInterval(function () {
const title = elements.getText("title");
const artistsArray = elements.getArtistsArray();
const artistsString = elements.getArtistsString(artistsArray);
skipArtistsIfFoundInSkippedArtistsList(artistsArray);
const album = elements.getAlbumName();
const current = elements.getText("current");
const duration = elements.getText("duration");
const songDashArtistTitle = `${title} - ${artistsString}`;
const currentStatus = getCurrentlyPlayingStatus();
const options = {
title,
artists: artistsString,
album: album,
status: currentStatus,
url: getTrackURL(),
current,
duration,
"app-name": appName,
image: "",
icon: "",
favorite: elements.isFavorite(),
};
const titleOrArtistsChanged = currentSong !== songDashArtistTitle;
// update title, url and play info with new info
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);
if (titleOrArtistsChanged) {
updateMediaSession(options);
}
});
/**
* automatically skip a song if the artists are found in the list of artists to skip
* @param {*} artists array of artists
*/
function skipArtistsIfFoundInSkippedArtistsList(artists: string[]) {
if (settingsStore.get(settings.skipArtists)) {
const skippedArtists = settingsStore.get<string, string[]>(settings.skippedArtists);
if (skippedArtists.length > 0) {
const artistsToSkip = skippedArtists.map((artist) => artist);
const artistNames = Object.values(artists).map((artist) => artist);
const foundArtist = artistNames.some((artist) => artistsToSkip.includes(artist));
if (foundArtist) {
elements.click("next");
}
}
}
}
}, getUpdateFrequency());
addMPRIS();
addCustomCss(app);
addHotKeys();
addIPCEventListeners();
addFullScreenListeners();

15
src/preload/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import "./integrations/mpris";
import "./integrations/listenbrainz";
import "./integrations/hotkeys";
import "./integrations/ipc";
import "./integrations/notifications";
import "./integrations/skipArtists";
import { ipcRenderer } from "electron";
import { globalEvents } from "../constants/globalEvents";
import { addCustomCss } from "../features/theming/theming";
import { app } from "@electron/remote";
window.document.addEventListener("fullscreenchange", () => {
ipcRenderer.send(globalEvents.refreshMenuBar);
});
addCustomCss(app);

View File

@@ -0,0 +1,96 @@
import { ipcRenderer, clipboard } from "electron";
import { Notification, dialog } from "@electron/remote";
import { addHotkey } from "../../scripts/hotkeys";
import { globalEvents } from "../../constants/globalEvents";
import { settingsStore } from "../../scripts/settings";
import { settings } from "../../constants/settings";
import { $tidalState, favoriteCurrentTrack, reduxStore, toggleRepeat } from "../state";
import { Songwhip } from "../../features/songwhip/songwhip";
/**
* Add hotkeys for when tidal is focused
* Reflects the desktop hotkeys found on:
* https://defkey.com/tidal-desktop-shortcuts
*/
if (settingsStore.get(settings.enableCustomHotkeys)) {
addHotkey("Control+l", handleLogout);
addHotkey("Control+a", favoriteCurrentTrack);
addHotkey("Control+h", () => {
if (!reduxStore) return;
reduxStore.dispatch({
type: "ROUTER_PUSH",
payload: {
pathname: "/",
options: {},
hash: "",
},
});
});
addHotkey("backspace", () => {
if (!reduxStore) return;
reduxStore.dispatch({ type: "ROUTER_GO_BACK" });
});
addHotkey("shift+backspace", () => {
if (!reduxStore) return;
reduxStore.dispatch({ type: "ROUTER_GO_FORWARD" });
});
addHotkey("control+u", () => {
// reloading window without cache should show the update bar if applicable
window.location.reload();
});
addHotkey("control+r", toggleRepeat);
addHotkey("control+w", async () => {
const trackUrl = $tidalState.getState().currentTrack?.url;
if (!trackUrl) return;
const result = await ipcRenderer.invoke(globalEvents.whip, trackUrl);
const url = Songwhip.getWhipUrl(result);
clipboard.writeText(url);
new Notification({
title: `Successfully whipped: `,
body: `URL copied to clipboard: ${url}`,
}).show();
});
}
// always add the hotkey for the settings window
addHotkey("control+=", function () {
ipcRenderer.send(globalEvents.showSettings);
});
addHotkey("control+0", function () {
ipcRenderer.send(globalEvents.showSettings);
});
/**
* This function will ask the user whether he/she wants to log out.
* It will log the user out if he/she selects "yes"
*/
function handleLogout() {
const logoutOptions = ["Cancel", "Yes, please", "No, thanks"];
dialog
.showMessageBox(null, {
type: "question",
title: "Logging out",
message: "Are you sure you want to log out?",
buttons: logoutOptions,
defaultId: 2,
})
.then((result: { response: number }) => {
if (logoutOptions.indexOf("Yes, please") === result.response) {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key.startsWith("_TIDAL_activeSession")) {
window.localStorage.removeItem(key);
break;
}
}
window.location.reload();
}
});
}

View File

@@ -0,0 +1,35 @@
import { ipcRenderer } from "electron";
import { globalEvents } from "../../constants/globalEvents";
import {
$tidalState,
favoriteCurrentTrack,
next,
pause,
play,
playPause,
previous,
toggleRepeat,
toggleShuffle,
} from "../state";
/**
* Add ipc event listeners.
* Some actions triggered outside of the site need info from the site.
*/
const handlers: Partial<Record<keyof typeof globalEvents, () => void>> = {
[globalEvents.playPause]: playPause,
[globalEvents.play]: play,
[globalEvents.pause]: pause,
[globalEvents.next]: next,
[globalEvents.previous]: previous,
[globalEvents.toggleFavorite]: favoriteCurrentTrack,
[globalEvents.toggleShuffle]: toggleShuffle,
[globalEvents.toggleRepeat]: toggleRepeat,
};
ipcRenderer.on("globalEvent", (_, event) => {
handlers[event as keyof typeof globalEvents]?.();
});
$tidalState.subscribe((state) => {
ipcRenderer.send(globalEvents.updateInfo, state);
});

View File

@@ -0,0 +1,38 @@
import { settingsStore } from "../../scripts/settings";
import {
ListenBrainz,
ListenBrainzConstants,
ListenBrainzStore,
} from "../../features/listenbrainz/listenbrainz";
import { settings } from "../../constants/settings";
import { StoreData } from "../../features/listenbrainz/models/storeData";
import { $tidalState } from "../state";
ListenBrainzStore.clear();
let delayTimeout: ReturnType<typeof setTimeout> | null = null;
$tidalState.subscribe((state) => {
if (!settingsStore.get(settings.ListenBrainz.enabled)) return;
if (delayTimeout !== null) return;
const track = state.currentTrack;
if (!track) return;
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
if ((!oldData && state.status === "Playing") || (oldData && oldData.title !== track.title)) {
clearTimeout(delayTimeout);
delayTimeout = setTimeout(
async () => {
await ListenBrainz.scrobble(
track.title,
track.artists.join(),
state.status,
track.duration
);
delayTimeout = null;
},
settingsStore.get(settings.ListenBrainz.delay) ?? 0
);
}
});

View File

@@ -0,0 +1,77 @@
import Player from "mpris-service";
import { settings } from "../../constants/settings";
import { settingsStore } from "../../scripts/settings";
import { Logger } from "../../features/logger";
import {
$tidalState,
coverArtPaths,
next,
pause,
play,
playPause,
previous,
stop,
toggleRepeat,
toggleShuffle,
} from "../state";
import { app } from "@electron/remote";
function toMicroseconds(seconds: number) {
return BigInt(seconds) * 1000_000n;
}
if (settingsStore.get(settings.mpris) && process.platform === "linux") {
try {
const player = Player({
name: "tidal-hifi2",
identity: "tidal-hifi2",
supportedUriSchemes: ["file"],
supportedMimeTypes: [
"audio/mpeg",
"audio/flac",
"audio/x-flac",
"application/ogg",
"audio/wav",
],
supportedInterfaces: ["player"],
desktopEntry: "tidal-hifi2",
});
player.on("playPause", playPause);
player.on("next", next);
player.on("previous", previous);
player.on("pause", pause);
player.on("play", play);
player.on("stop", stop);
player.on("loopStatus", toggleRepeat);
player.on("shuffle", toggleShuffle);
player.on("quit", app.quit);
player.getPosition = function () {
return toMicroseconds($tidalState.getState().currentTrack?.current ?? 0);
};
$tidalState.subscribe(async (state) => {
if (!player) return;
if (state.currentTrack) {
const coverUrl = await coverArtPaths.get(state.currentTrack.image);
player.metadata = {
"xesam:title": state.currentTrack.title,
"xesam:artist": state.currentTrack.artists,
"xesam:album": state.currentTrack.album,
"mpris:artUrl": coverUrl,
"mpris:length": toMicroseconds(state.currentTrack.duration),
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + state.currentTrack.id,
};
} else {
player.metadata = {
"mpris:trackid": "/org/mpris/MediaPlayer2/TrackList/NoTrack",
};
}
player.playbackStatus = state.status;
});
} catch (exception) {
console.error(exception);
Logger.log("MPRIS player api not working", exception);
}
}

View File

@@ -0,0 +1,23 @@
import { settingsStore } from "../../scripts/settings";
import { $tidalState, coverArtPaths } from "../state";
import { settings } from "../../constants/settings";
import { Notification } from "@electron/remote";
let currentNotification: Electron.Notification | undefined;
$tidalState.subscribe(async (state, prevState) => {
if (!settingsStore.get(settings.notifications)) return;
if (!state.currentTrack) return;
if (state.currentTrack.id === prevState.currentTrack?.id) return;
currentNotification?.close();
if (state.status !== "Playing") return;
const icon = await coverArtPaths.get(state.currentTrack.image);
currentNotification = new Notification({
title: state.currentTrack.title,
body: state.currentTrack.artists.join(", "),
icon,
});
currentNotification.show();
});

View File

@@ -0,0 +1,15 @@
import { settingsStore } from "../../scripts/settings";
import { $tidalState, next } from "../state";
import { settings } from "../../constants/settings";
$tidalState.subscribe((state) => {
// don't skip when paused, as it can cause a loop
if (!state.currentTrack || state.status !== "Playing") return;
if (!settingsStore.get(settings.skipArtists)) return;
const artistsToSkip = settingsStore.get(settings.skippedArtists) as string[];
if (artistsToSkip.length === 0) return;
const shouldSkip = state.currentTrack?.artists.some((artist) => artistsToSkip.includes(artist));
if (shouldSkip) next();
});

188
src/preload/redux.ts Normal file
View File

@@ -0,0 +1,188 @@
export function getTidalReduxStore() {
// Find the react container
let reactContainer: Record<string, unknown> | null = null;
for (const child of document.body?.children ?? []) {
const container = Object.entries(child).find(([key]) => key.startsWith("__reactContainer$"));
// console.log(container);
if (!container) continue;
reactContainer = container[1];
break;
}
if (!reactContainer) {
throw new Error("Could not find React root");
}
// Traverse the react tree until we find the redux store
const seen = new Set();
const queue = [reactContainer];
let store;
const properties = ["children", "child", "pendingProps", "memoizedProps", "props"];
while (!store && queue.length) {
const node = queue.shift();
if (!node) break;
if (
"store" in node &&
typeof node.store === "object" &&
node.store !== null &&
"getState" in node.store &&
typeof node.store.getState === "function"
) {
store = node.store;
break;
}
for (const property of properties) {
const value = node[property];
if (typeof value === "object" && value !== null) {
if (seen.has(value)) continue;
seen.add(value);
queue.push(value as Record<string, unknown>);
}
}
}
if (!store) throw new Error("Could not find Redux store");
return store as TidalReduxStore;
}
export type TidalReduxStore = {
getState: () => ReduxState;
dispatch: (action: Action) => void;
subscribe: (listener: () => void) => () => void;
};
export type ReduxState = {
[key: string]: unknown;
content: {
mediaItems: Record<string, MediaItem>;
};
favorites: {
albums: number[];
artists: number[];
mixes: number[];
playlists: number[];
tracks: number[];
users: number[];
videos: number[];
};
playbackControls: {
desiredPlaybackState: "NOT_PLAYING" | "PLAYING" | "IDLE" | string;
latestCurrentTime: number;
latestCurrentTimeSyncTimestamp: number;
muted: boolean;
playbackState: "NOT_PLAYING" | "PLAYING" | "IDLE" | "STALLED";
startAt: number;
volume: number;
volumeUnmute: number;
mediaProduct: {
productId: string;
productType: "track" | string;
sourceId: string;
sourceType: "PLAYLIST" | string;
};
};
playQueue: {
shuffleModeEnabled: boolean;
repeatMode: RepeatMode;
};
};
export const enum RepeatMode {
REPEAT_OFF = 0,
REPEAT_ALL = 1,
REPEAT_SINGLE = 2,
}
type MediaItem =
| {
type: "track";
item: {
album: {
id: number;
title: string;
cover: string;
vibrantColor: string;
releaseDate: string;
};
artist: Artist;
artists: Array<Artist>;
audioModes: Array<"STEREO" | string>;
audioQuality: "LOSSLESS" | string;
bpm: number | null;
copyright: string;
dateAdded: string;
description: string | null;
duration: number;
explicit: boolean;
id: number;
isrc: string;
itemUuid: string;
peak: number;
popularity: number;
title: string;
trackNumber: number;
url: string;
};
}
| {
type: "video";
item: {
artists: Array<Artist>;
contentType: "video";
duration: number;
id: number;
imageId: string;
explicit: boolean;
title: string;
type: string;
url: string;
vibrantColor: string;
};
};
type Artist = {
id: number;
name: string;
type: "MAIN" | string;
picture: string;
};
type Action =
| {
type:
| "playbackControls/PAUSE"
| "playbackControls/PLAY"
| "playbackControls/STOP"
| "playbackControls/SKIP_PREVIOUS"
| "playbackControls/SKIP_NEXT"
| "playQueue/TOGGLE_SHUFFLE"
| "playQueue/TOGGLE_REPEAT_MODE"
| "ROUTER_GO_BACK"
| "ROUTER_GO_FORWARD";
}
| {
type: "playbackControls/SET_VOLUME";
payload: {
/** 0 - 100 */
volume: number;
};
}
| {
type: "playbackControls/SET_MUTE";
payload: {
mute: boolean;
};
}
| {
type: "ROUTER_PUSH";
payload: {
pathname: string;
options: Record<string, unknown>;
hash: string;
};
}
| {
type: "content/TOGGLE_FAVORITE_ITEMS";
payload: {
from: "heart";
items: Array<{ itemId: number; itemType: "track" }>;
moduleId?: string;
};
};

165
src/preload/state.ts Normal file
View File

@@ -0,0 +1,165 @@
import { getTidalReduxStore, ReduxState, RepeatMode, TidalReduxStore } from "./redux";
import { createStore } from "zustand/vanilla";
import { ipcRenderer } from "electron";
import { globalEvents } from "../constants/globalEvents";
import equal from "fast-deep-equal";
import { TidalState } from "../models/tidalState";
export const $tidalState = createStore<TidalState>(() => ({
status: "Stopped",
repeat: "Off",
shuffle: false,
}));
export let reduxStore: TidalReduxStore | undefined;
export function playPause() {
if (!reduxStore) return;
const state = $tidalState.getState();
if (state.status === "Playing") {
reduxStore.dispatch({ type: "playbackControls/PAUSE" });
} else {
reduxStore.dispatch({ type: "playbackControls/PLAY" });
}
}
export function next() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playbackControls/SKIP_NEXT" });
}
export function previous() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playbackControls/SKIP_PREVIOUS" });
}
export function pause() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playbackControls/PAUSE" });
}
export function play() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playbackControls/PLAY" });
}
export function stop() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playbackControls/STOP" });
}
export function toggleRepeat() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playQueue/TOGGLE_REPEAT_MODE" });
}
export function toggleShuffle() {
if (!reduxStore) return;
reduxStore.dispatch({ type: "playQueue/TOGGLE_SHUFFLE" });
}
export function favoriteCurrentTrack() {
if (!reduxStore) return;
const track = $tidalState.getState().currentTrack;
if (!track) return;
reduxStore.dispatch({
type: "content/TOGGLE_FAVORITE_ITEMS",
payload: {
from: "heart",
items: [{ itemId: track.id, itemType: "track" }],
moduleId: undefined,
},
});
}
export const coverArtPaths = new Map<string, Promise<string>>();
(async () => {
while (!reduxStore) {
try {
reduxStore = getTidalReduxStore();
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// Update currentTime
let rawCurrentTime: ReduxState["playbackControls"] = reduxStore.getState().playbackControls;
setInterval(() => {
const state = $tidalState.getState();
const track = state.currentTrack;
if (!track) return;
const oldCurrentTime = track.current;
let newCurrentTime: number;
if (state.status === "Playing") {
newCurrentTime = Math.trunc(
rawCurrentTime.latestCurrentTime +
Math.abs(rawCurrentTime.latestCurrentTimeSyncTimestamp - Date.now()) / 1000
);
} else {
newCurrentTime = rawCurrentTime.latestCurrentTime;
}
if (newCurrentTime !== oldCurrentTime) {
$tidalState.setState({
...state,
currentTrack: {
...track,
current: newCurrentTime,
},
});
}
}, 1000);
reduxStore.subscribe(async () => {
const state = reduxStore.getState();
rawCurrentTime = state.playbackControls;
const currentItem = getCurrentTrack(state);
let track: TidalState["currentTrack"];
if (currentItem) {
const imageId =
currentItem.type === "track" ? currentItem.item.album.cover : currentItem.item.imageId;
const coverUrl = `https://resources.tidal.com/images/${imageId.replace(
/-/g,
"/"
)}/640x640.jpg`;
if (!coverArtPaths.has(coverUrl)) {
coverArtPaths.set(
coverUrl,
ipcRenderer.invoke(globalEvents.downloadCover, imageId, coverUrl).catch(() => "") // ignore errors if the cover can't be downloaded
);
}
track = {
id: currentItem.item.id,
title: currentItem.item.title,
album: currentItem.type === "track" ? currentItem.item.album.title : undefined,
artists: currentItem.item.artists.map((artist) => artist.name),
current: state.playbackControls.latestCurrentTime,
duration: currentItem.item.duration,
url: currentItem.item.url,
image: coverUrl,
};
}
const oldState = $tidalState.getState();
const newState: TidalState = {
status: playbackStatusMap[state.playbackControls.playbackState] ?? "Stopped",
repeat: repeatModeMap[state.playQueue.repeatMode] ?? "Off",
shuffle: state.playQueue.shuffleModeEnabled,
currentTrack: track,
};
if (!equal(oldState, newState)) {
$tidalState.setState(newState);
}
});
})();
function getCurrentTrack(state: ReduxState) {
return state.content.mediaItems[state.playbackControls.mediaProduct?.productId];
}
const playbackStatusMap = {
PLAYING: "Playing",
NOT_PLAYING: "Paused",
IDLE: "Stopped",
STALLED: "Stopped",
} as const;
const repeatModeMap = {
[RepeatMode.REPEAT_OFF]: "Off",
[RepeatMode.REPEAT_ALL]: "All",
[RepeatMode.REPEAT_SINGLE]: "Single",
} as const;

View File

@@ -1,75 +1,85 @@
import { Client } from "discord-rpc";
import { Client, Presence } from "discord-rpc";
import { app, ipcMain } from "electron";
import { globalEvents } from "../constants/globalEvents";
import { settings } from "../constants/settings";
import { Logger } from "../features/logger";
import { MediaStatus } from "../models/mediaStatus";
import { mediaInfo } from "./mediaInfo";
import { settingsStore } from "./settings";
import { mainTidalState } from "../features/state";
const clientId = "833617820704440341";
function timeToSeconds(timeArray: string[]) {
const minutes = parseInt(timeArray[0]) * 1;
const seconds = minutes * 60 + parseInt(timeArray[1]) * 1;
return seconds;
}
export let rpc: Client;
const observer = () => {
if (mediaInfo.status === MediaStatus.paused && rpc) {
rpc.setActivity(idleStatus);
} else if (rpc) {
const currentSeconds = timeToSeconds(mediaInfo.current.split(":"));
const durationSeconds = timeToSeconds(mediaInfo.duration.split(":"));
const date = new Date();
const now = (date.getTime() / 1000) | 0;
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
if (rpc) {
rpc.setActivity(getActivity());
}
};
const defaultPresence = {
largeImageKey: "tidal-hifi-icon",
largeImageText: `TIDAL Hi-Fi ${app.getVersion()}`,
instance: false,
};
const getActivity = (): Presence => {
const presence: Presence = { ...defaultPresence };
if (mainTidalState.status === "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";
const includeTimestamps =
settingsStore.get<string, boolean>(settings.discord.includeTimestamps) ?? true;
let activity = {
...idleStatus,
...{
startTimestamp: includeTimestamps ? now : undefined,
endTimestamp: includeTimestamps ? remaining : undefined,
},
};
if (mediaInfo.url) {
activity = {
...activity,
...{
details: `${detailsPrefix}${mediaInfo.title}`,
state: mediaInfo.artists ? mediaInfo.artists : "unknown artist(s)",
largeImageKey: mediaInfo.image,
largeImageText: mediaInfo.album ? mediaInfo.album : `${idleStatus.largeImageText}`,
buttons: [{ label: buttonText, url: mediaInfo.url }],
},
};
} else {
activity = {
...activity,
...{
details: `Watching ${mediaInfo.title}`,
state: mediaInfo.artists,
},
};
}
rpc.setActivity(activity);
return { includeTimestamps, detailsPrefix, buttonText };
}
};
const idleStatus = {
details: `Browsing Tidal`,
largeImageKey: "tidal-hifi-icon",
largeImageText: `TIDAL Hi-Fi ${app.getVersion()}`,
instance: false,
function setPresenceFromMediaInfo(detailsPrefix: string, buttonText: string) {
const track = mainTidalState.currentTrack;
if (!track) return;
if (track.url) {
presence.details = `${detailsPrefix}${track.title}`;
presence.state = track.artists.join(", ");
presence.largeImageKey = track.image;
if (track.album) {
presence.largeImageText = track.album;
}
presence.buttons = [{ label: buttonText, url: track.url }];
} else {
presence.details = `Watching ${track.title}`;
presence.state = track.artists.join(", ");
}
}
function includeTimeStamps(includeTimestamps: boolean) {
if (includeTimestamps) {
const currentSeconds = mainTidalState.currentTrack?.current ?? 0;
const durationSeconds = mainTidalState.currentTrack?.duration ?? 0;
const date = new Date();
const now = (date.getTime() / 1000) | 0;
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
presence.startTimestamp = now;
presence.endTimestamp = remaining;
}
}
};
/**
@@ -80,7 +90,7 @@ export const initRPC = () => {
rpc.login({ clientId }).then(
() => {
rpc.on("ready", () => {
rpc.setActivity(idleStatus);
rpc.setActivity(getActivity());
});
ipcMain.on(globalEvents.updateInfo, observer);
},

View File

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

View File

@@ -1,69 +0,0 @@
import { BrowserWindow, dialog } from "electron";
import express, { Response } from "express";
import fs from "fs";
import { settings } from "../constants/settings";
import { MediaStatus } from "../models/mediaStatus";
import { globalEvents } from "./../constants/globalEvents";
import { mediaInfo } from "./mediaInfo";
import { settingsStore } from "./settings";
/**
* Function to enable TIDAL Hi-Fi'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.post("/favorite/toggle", (req, res) =>
handleGlobalEvent(res, globalEvents.toggleFavorite)
);
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 === MediaStatus.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,37 +1,8 @@
import { MediaInfo } from "../models/mediaInfo";
import { MediaStatus } from "../models/mediaStatus";
import { TidalState } from "../models/tidalState";
export const mediaInfo = {
title: "",
artists: "",
album: "",
icon: "",
status: MediaStatus.paused as string,
url: "",
current: "",
duration: "",
image: "tidal-hifi-icon",
favorite: false,
// This object is globally mutated
export const tidalState: TidalState = {
status: "Stopped",
repeat: "Off",
shuffle: false,
};
export const updateMediaInfo = (arg: MediaInfo) => {
mediaInfo.title = propOrDefault(arg.title);
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);
mediaInfo.favorite = arg.favorite;
};
/**
* Return the property or a default value
* @param {*} prop property to check
* @param {*} defaultValue defaults to ""
*/
function propOrDefault(prop: string, defaultValue = "") {
return prop ? prop : defaultValue;
}

View File

@@ -1,7 +1,7 @@
import { BrowserWindow, Menu, app } from "electron";
import { showSettingsWindow } from "./settings";
const isMac = process.platform === "darwin";
import name from "./../constants/values";
const settingsMenuEntry = {
label: "Settings",
@@ -31,7 +31,7 @@ export const getMenu = function (mainWindow: BrowserWindow) {
...(isMac
? [
{
label: name,
label: "TIDAL Hi-Fi",
submenu: [
settingsMenuEntry,
{ type: "separator" },

View File

@@ -1,10 +1,29 @@
import Store from "electron-store";
import { BrowserWindow } from "electron";
import { BrowserWindow, shell } from "electron";
import path from "path";
import { settings } from "../constants/settings";
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({
defaults: {
@@ -19,9 +38,12 @@ export const settingsStore = new Store({
enableCustomHotkeys: false,
enableDiscord: false,
discord: {
showSong: true,
idleText: "Browsing Tidal",
usingText: "Playing media on TIDAL",
includeTimestamps: true,
detailsPrefix: "Listening to ",
buttonText: "Play on Tidal",
includeTimestamps: true,
},
ListenBrainz: {
enabled: false,
@@ -69,6 +91,16 @@ export const settingsStore = new Store({
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",
},
]);
},
},
});
@@ -85,6 +117,7 @@ export const createSettingsWindow = function () {
show: false,
transparent: true,
frame: false,
type: "dialog",
title: "TIDAL Hi-Fi settings",
webPreferences: {
preload: path.join(__dirname, "../pages/settings/preload.js"),
@@ -102,10 +135,18 @@ export const createSettingsWindow = function () {
settingsWindow.loadURL(`file://${__dirname}/../pages/settings/settings.html`);
settingsWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
settingsModule.settingsWindow = settingsWindow;
};
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);
// refresh data just before showing the window

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"typeRoots": ["src/types", "node_modules/@types"],
"module": "commonjs",
"target": "ES6",
"lib": ["ES2020", "DOM"],
"target": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noImplicitAny": true,
"sourceMap": true,
"allowJs": true,