Compare commits

..

4 Commits

Author SHA1 Message Date
79bbab6a82 Merge pull request #584 from Mastermindzh/next
albumInfo fix, updated dependencies
2025-04-22 11:40:41 +02:00
7376df7f30 albumInfo fix, updated dependencies 2025-04-22 11:14:19 +02:00
7f8760c4e9 Merge pull request #583 from Mastermindzh/snyk-upgrade-36ee0aede4ca42df3db4bef87530e654
[Snyk] Upgrade sass from 1.85.1 to 1.86.0
2025-04-20 13:24:05 +02:00
snyk-bot
1cf5a01ad7 fix: upgrade sass from 1.85.1 to 1.86.0
Snyk has created this PR to upgrade sass from 1.85.1 to 1.86.0.

See this package in npm:
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
2025-04-19 09:04:54 +00:00
25 changed files with 636 additions and 882 deletions

View File

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

36
.vscode/launch.json vendored
View File

@@ -1,36 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
// we can use this to debug the main process
"name": "Electron: Main",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/node_modules/electron/dist/electron",
"args": [
"${workspaceFolder}/ts-dist/main.js",
"--no-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
"--in-process-gpu",
"--inspect=0.0.0.0:5858",
"--remote-debugging-port=8315"
],
"protocol": "inspector",
"env": {
"ELECTRON_DISABLE_SECURITY_WARNINGS": "false"
},
"outputCapture": "std"
},
// first run, then connect this to make sure we debug the UI
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 8315,
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"]
}
]
}

View File

@@ -4,6 +4,12 @@ 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.19.0]
- Fixed the issue where media updates would cease to work after album names can't be found
- Will simply report an empty string when it can't find the album
- Updated various dependencies
## [5.18.2]
- Reverted to sass 1.79.4 to fix `Nix` builds
@@ -40,7 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added all missing swagger/openApi info with the help of [Times-Z](https://github.com/Times-Z)
- Updated most dependency versions
- This includes Electron 31!
- Added a channel selector so we can now use Tidal's staging environment directly from the app
@@ -132,12 +137,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated Electron to 28.1.1 (fixes [325](https://github.com/Mastermindzh/tidal-hifi/issues/325))
- Updated dependencies to latest
- added theme files to stylelint ignore
- fixed other stylelint errors
- Added functionality to favorite a song (fixes [#323](https://github.com/Mastermindzh/tidal-hifi/issues/323))
- Added a hotkey to favorite ("Add to collection") songs: Control+a
- Added the "favorite" field in the `mediaInfo` and the API `/current` endpoint
- Added an endpoint to toggle favoriting a song: `http://localhost:47836/favorite/toggle`

View File

@@ -8,3 +8,4 @@ Only the very latest 😄.
If you find a vulnerability just add it as an issue.
If there's an especially bad vulnerability that you don't want to make public just send me a private message (email, discord, wherever).

228
package-lock.json generated
View File

@@ -15,38 +15,38 @@
"axios": "^1.8.4",
"cors": "^2.8.5",
"electron-store": "^8.2.0",
"express": "^5.0.1",
"express": "^5.1.0",
"hotkeys-js": "^3.13.9",
"mpris-service": "^2.1.2",
"request": "^2.88.2",
"sass": "1.85.1",
"sass": "1.86.3",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.10",
"@types/express": "^5.0.1",
"@types/node": "^20.17.30",
"@types/request": "^2.48.12",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.15.0",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"copyfiles": "^2.4.1",
"electron": "github:castlabs/electron-releases#v35.1.1+wvcus",
"electron-builder": "~26.0.12",
"eslint": "^8.57.0",
"js-yaml": "^4.1.0",
"markdown-toc": "^1.2.0",
"nodemon": "^3.1.4",
"prettier": "^3.3.2",
"stylelint": "^16.6.1",
"nodemon": "^3.1.9",
"prettier": "^3.5.3",
"stylelint": "^16.18.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-prettier": "^5.0.0",
"stylelint-prettier": "^5.0.3",
"swagger-jsdoc": "^6.2.8",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.5.3"
"tsc-watch": "^6.2.1",
"typescript": "^5.8.3"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -1655,6 +1655,13 @@
"node": ">=0.10"
}
},
"node_modules/@parcel/watcher/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1816,22 +1823,21 @@
}
},
"node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz",
"integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz",
"integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1895,14 +1901,26 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.28",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.28.tgz",
"integrity": "sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==",
"version": "20.17.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz",
"integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@types/plist": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
"integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*",
"xmlbuilder": ">=11.0.1"
}
},
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
@@ -1986,6 +2004,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
"integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -3357,6 +3383,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -3716,6 +3760,17 @@
}
}
},
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"buffer": "^5.1.0"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -4121,6 +4176,33 @@
"node": ">= 10.0.0"
}
},
"node_modules/dmg-license": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz",
"integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"@types/plist": "^3.0.1",
"@types/verror": "^1.10.3",
"ajv": "^6.10.0",
"crc": "^3.8.0",
"iconv-corefoundation": "^1.1.7",
"plist": "^3.0.4",
"smart-buffer": "^4.0.2",
"verror": "^1.10.0"
},
"bin": {
"dmg-license": "bin/dmg-license.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -5366,6 +5448,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5991,6 +6088,24 @@
"ms": "^2.0.0"
}
},
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
"integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"cli-truncate": "^2.1.0",
"node-addon-api": "^1.6.3"
},
"engines": {
"node": "^8.11.2 || >=10"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -7366,9 +7481,10 @@
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
"integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
"dev": true,
"license": "MIT",
"optional": true
},
@@ -8862,9 +8978,9 @@
}
},
"node_modules/sass": {
"version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
"version": "1.86.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
@@ -9197,6 +9313,22 @@
"node": ">=8"
}
},
"node_modules/slice-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -9479,9 +9611,9 @@
}
},
"node_modules/stylelint": {
"version": "16.17.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.17.0.tgz",
"integrity": "sha512-I9OwVIWRMqVm2Br5iTbrfSqGRPWQUlvm6oXO1xZuYYu0Gpduy67N8wXOZv15p6E/JdlZiAtQaIoLKZEWk5hrjw==",
"version": "16.18.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.18.0.tgz",
"integrity": "sha512-OXb68qzesv7J70BSbFwfK3yTVLEVXiQ/ro6wUE4UrSbKCMjLLA02S8Qq3LC01DxKyVjk7z8xh35aB4JzO3/sNA==",
"dev": true,
"funding": [
{
@@ -10395,9 +10527,9 @@
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -10543,6 +10675,30 @@
"node": ">= 0.8"
}
},
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/verror/node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",

View File

@@ -23,7 +23,6 @@
"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",
"prettier": "prettier . --write",
"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",
@@ -46,10 +45,10 @@
"@xhayper/discord-rpc": "^1.2.1",
"axios": "^1.8.4",
"electron-store": "^8.2.0",
"express": "^5.0.1",
"express": "^5.1.0",
"hotkeys-js": "^3.13.9",
"mpris-service": "^2.1.2",
"sass": "1.85.1",
"sass": "1.86.3",
"swagger-ui-express": "^5.0.1",
"cors": "^2.8.5",
"request": "^2.88.2"
@@ -57,28 +56,28 @@
"devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.10",
"@types/express": "^5.0.1",
"@types/node": "^20.17.30",
"@types/request": "^2.48.12",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.15.0",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"copyfiles": "^2.4.1",
"electron": "github:castlabs/electron-releases#v35.1.1+wvcus",
"electron-builder": "~26.0.12",
"eslint": "^8.57.0",
"js-yaml": "^4.1.0",
"markdown-toc": "^1.2.0",
"nodemon": "^3.1.4",
"prettier": "^3.3.2",
"stylelint": "^16.6.1",
"nodemon": "^3.1.9",
"prettier": "^3.5.3",
"stylelint": "^16.18.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-prettier": "^5.0.0",
"stylelint-prettier": "^5.0.3",
"swagger-jsdoc": "^6.2.8",
"ts-node": "^10.9.2",
"tsc-watch": "^6.2.0",
"typescript": "^5.5.3"
"tsc-watch": "^6.2.1",
"typescript": "^5.8.3"
},
"prettier": "@mastermindzh/prettier-config"
}

View File

@@ -1,6 +0,0 @@
export type DomControllerOptions = {
/**
* Interval that tidal-hifi scrapes the dom for information
*/
refreshInterval: number;
};

View File

@@ -1,354 +0,0 @@
import { convertDurationToSeconds } from "../../features/time/parse";
import { MediaInfo } from "../../models/mediaInfo";
import { MediaStatus } from "../../models/mediaStatus";
import { RepeatState } from "../../models/repeatState";
import { TidalController } from "../TidalController";
import { DomControllerOptions } from "./DomControllerOptions";
export class DomTidalController implements TidalController<DomControllerOptions> {
private updateSubscriber: (state: Partial<MediaInfo>) => void;
private currentlyPlaying = MediaStatus.paused;
private currentRepeatState: RepeatState = RepeatState.off;
private currentShuffleState = false;
private readonly elements = {
play: '*[data-test="play"]',
pause: '*[data-test="pause"]',
next: '*[data-test="next"]',
previous: 'button[data-test="previous"]',
title: '*[data-test^="footer-track-title"]',
artists: '*[data-test^="grid-item-detail-text-title-artist"]',
home: '*[data-test="menu--home"]',
back: '[title^="Back"]',
forward: '[title^="Next"]',
search: '[class^="searchField"]',
shuffle: '*[data-test="shuffle"]',
repeat: '*[data-test="repeat"]',
account: '*[data-test^="profile-image-button"]',
settings: '*[data-test^="sidebar-menu-button"]',
openSettings: '*[data-test^="open-settings"]',
media: '*[data-test="current-media-imagery"]',
image: "img",
current: '*[data-test="current-time"]',
duration: '*[class^=_playbackControlsContainer] *[data-test="duration"]',
bar: '*[data-test="progress-bar"]',
footer: "#footerPlayer",
mediaItem: "[data-type='mediaItem']",
album_header_title: '*[class^="playingFrom"] span:nth-child(2)',
playing_from: '*[class^="playingFrom"] span:nth-child(2)',
queue_album: "*[class^=playQueueItemsContainer] *[class^=groupTitle] span:nth-child(2)",
currentlyPlaying: "[class^='isPlayingIcon'], [data-test-is-playing='true']",
album_name_cell: '[class^="album"]',
tracklist_row: '[data-test="tracklist-row"]',
volume: '*[data-test="volume"]',
favorite: '*[data-test="footer-favorite-button"]',
/**
* Get an element from the dom
* @param {*} key key in elements object to fetch
*/
get: function (key: string) {
return globalThis.document.querySelector(this[key.toLowerCase()]) ?? "";
},
/**
* Get the icon of the current media
*/
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 media
* @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 () {
try {
//If listening to an album, get its name from the header title
if (globalThis.location.href.includes("/album/")) {
const albumName = globalThis.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 (
globalThis.location.href.includes("/playlist/") ||
globalThis.location.href.includes("/mix/")
) {
if (this.currentlyPlaying === 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;
}
}
}
// see whether we're on the queue page and get it from there
const queueAlbumName = this.getText("queue_album");
if (queueAlbumName) {
return queueAlbumName;
}
return "";
} catch {
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();
},
};
onMediaInfoUpdate(callback: (state: Partial<MediaInfo>) => void): void {
this.updateSubscriber = callback;
}
bootstrap(options: DomControllerOptions): void {
/**
* 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
*/
const getTrackURL = () => {
const id = this.getTrackId();
return `https://tidal.com/browse/track/${id}`;
};
setInterval(async () => {
const title = this.getTitle();
const artistsArray = this.getArtists();
const artistsString = this.getArtistsString();
const current = this.getCurrentTime();
const currentStatus = this.getCurrentlyPlayingStatus();
const shuffleState = this.getCurrentShuffleState();
const repeatState = this.getCurrentRepeatState();
const playStateChanged = currentStatus != this.currentlyPlaying;
const shuffleStateChanged = shuffleState != this.currentShuffleState;
const repeatStateChanged = repeatState != this.currentRepeatState;
if (playStateChanged) this.currentlyPlaying = currentStatus;
if (shuffleStateChanged) this.currentShuffleState = shuffleState;
if (repeatStateChanged) this.currentRepeatState = repeatState;
const album = this.getAlbumName();
const duration = this.getDuration();
const updatedInfo = {
title,
artists: artistsString,
artistsArray,
album: album,
playingFrom: this.getPlayingFrom(),
status: currentStatus,
url: getTrackURL(),
current,
currentInSeconds: convertDurationToSeconds(current),
duration,
durationInSeconds: convertDurationToSeconds(duration),
image: this.getSongIcon(),
favorite: this.isFavorite(),
player: {
status: currentStatus,
shuffle: shuffleState,
repeat: repeatState,
},
};
this.updateSubscriber(updatedInfo);
}, options.refreshInterval);
}
playPause = (): void => {
const play = this.elements.get("play");
if (play) {
this.elements.click("play");
} else {
this.elements.click("pause");
}
};
goToHome(): void {
this.elements.click("home");
}
openSettings(): void {
this.elements.click("settings");
setTimeout(() => {
this.elements.click("openSettings");
}, 100);
}
toggleFavorite(): void {
this.elements.click("favorite");
}
back(): void {
this.elements.click("back");
}
forward(): void {
this.elements.click("forward");
}
repeat(): void {
this.elements.click("repeat");
}
next(): void {
this.elements.click("next");
}
previous(): void {
this.elements.click("previous");
}
toggleShuffle(): void {
this.elements.click("shuffle");
}
getCurrentlyPlayingStatus() {
const pause = this.elements.get("pause");
// if pause button is visible tidal is playing
if (pause) {
return MediaStatus.playing;
} else {
return MediaStatus.paused;
}
}
getCurrentShuffleState() {
const shuffle = this.elements.get("shuffle");
return shuffle?.getAttribute("aria-checked") === "true";
}
getCurrentRepeatState() {
const repeat = this.elements.get("repeat");
switch (repeat?.getAttribute("data-type")) {
case "button__repeatAll":
return RepeatState.all;
case "button__repeatSingle":
return RepeatState.single;
default:
return RepeatState.off;
}
}
play(): void {
this.playPause();
}
pause(): void {
this.playPause();
}
stop(): void {
this.playPause();
}
getCurrentPosition() {
return this.elements.getText("current");
}
getCurrentPositionInSeconds(): number {
return convertDurationToSeconds(this.getCurrentPosition());
}
getTrackId(): string {
const URLelement = this.elements.get("title").querySelector("a");
if (URLelement !== null) {
const id = URLelement.href.replace(/\D/g, "");
return id;
}
return window.location.toString();
}
getCurrentTime(): string {
return this.elements.getText("current");
}
getDuration(): string {
return this.elements.getText("duration");
}
getAlbumName(): string {
return this.elements.getAlbumName();
}
getTitle(): string {
return this.elements.getText("title");
}
getArtists(): string[] {
return this.elements.getArtistsArray();
}
getArtistsString(): string {
return this.elements.getArtistsString(this.getArtists());
}
getPlayingFrom(): string {
return this.elements.getText("playing_from");
}
isFavorite(): boolean {
return this.elements.isFavorite();
}
getSongIcon(): string {
return this.elements.getSongIcon();
}
}

View File

@@ -1,53 +0,0 @@
import { MediaInfo } from "../models/mediaInfo";
import { MediaStatus } from "../models/mediaStatus";
import { RepeatState } from "../models/repeatState";
export interface TidalController<TBootstrapOptions = object> {
goToHome(): void;
openSettings(): void;
/**
* Play or pause the current media
*/
playPause(): void;
play(): void;
pause(): void;
stop(): void;
toggleFavorite(): void;
back(): void;
forward(): void;
repeat(): void;
next(): void;
previous(): void;
toggleShuffle(): void;
/**
* Optional setup/startup/bootstrap for this controller
*/
bootstrap(options: TBootstrapOptions): void;
/**
* Method that triggers every time the MediaInfo updates
* @param callback function that receives the updated media info
*/
onMediaInfoUpdate(callback: (state: Partial<MediaInfo>) => void): void;
/**
* Update the current status of tidal (e.g playing or paused)
*/
getCurrentlyPlayingStatus(): MediaStatus;
getCurrentShuffleState(): boolean;
getCurrentRepeatState(): RepeatState;
getCurrentPosition(): string;
getCurrentPositionInSeconds(): number;
getTrackId(): string;
getCurrentTime(): string;
getDuration(): string;
getAlbumName(): string;
getTitle(): string;
getArtists(): string[];
getArtistsString(): string;
getPlayingFrom(): string;
getSongIcon(): string;
isFavorite(): boolean;
}

View File

@@ -1,138 +0,0 @@
import { Logger } from "../../features/logger";
import { MediaInfo } from "../../models/mediaInfo";
import { MediaStatus } from "../../models/mediaStatus";
import { RepeatState } from "../../models/repeatState";
import { DomTidalController } from "../DomController/DomTidalController";
import { TidalController } from "../TidalController";
export class MediaSessionController implements TidalController {
public domMediaController: TidalController;
constructor() {
this.domMediaController = new DomTidalController();
Logger.log("Initialized domController as a backup controller");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onMediaInfoUpdate(callback: (state: Partial<MediaInfo>) => void): void {
globalThis.alert("method not implemented");
throw new Error("Method not implemented.");
}
bootstrap(): void {
globalThis.alert("Method not implemented: ");
throw new Error("Method not implemented.");
}
// example of using the original domMediaController as a fallback
goToHome(): void {
this.domMediaController.goToHome();
}
getDuration(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getAlbumName(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getTitle(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getArtists(): string[] {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getArtistsString(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getPlayingFrom(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
isFavorite(): boolean {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getSongIcon(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentTime(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentPosition(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentPositionInSeconds(): number {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getTrackId(): string {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
play(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
pause(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
stop(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentShuffleState(): boolean {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentRepeatState(): RepeatState {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
getCurrentlyPlayingStatus(): MediaStatus {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
back(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
forward(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
repeat(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
next(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
previous(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
toggleShuffle(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
openSettings(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
toggleFavorite(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
playPause(): void {
globalThis.alert("Method not implemented");
throw new Error("Method not implemented.");
}
}

View File

@@ -1,4 +0,0 @@
export const tidalControllers = {
domController: "domController",
mediaSessionController: "mediaSessionController",
};

View File

@@ -13,7 +13,6 @@ export const settings = {
advanced: {
root: "advanced",
tidalUrl: "advanced.tidalUrl",
controllerType: "advanced.controllerType",
},
api: "api",
apiSettings: {

View File

@@ -88,8 +88,9 @@ export const addCurrentInfo = (expressApp: Router) => {
* schema:
* $ref: '#/components/schemas/MediaInfo'
*/
expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists }));
expressApp.get("/current", (_req, res) => {
res.json({ ...mediaInfo, artist: mediaInfo.artists });
});
/**
* @swagger
* /current/image:

View File

@@ -21,8 +21,12 @@ export const startApi = (mainWindow: BrowserWindow) => {
expressApp.use(cors());
expressApp.use(express.json());
expressApp.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
expressApp.get("/", (req, res) => res.send("Hello World!"));
expressApp.get("/swagger.json", (req, res) => res.json(swaggerSpec));
expressApp.get("/", (req, res) => {
res.send("Hello World!");
});
expressApp.get("/swagger.json", (req, res) => {
res.json(swaggerSpec);
});
// add features
addLegacyApi(expressApp, mainWindow);

View File

@@ -1,15 +0,0 @@
import { downloadFile } from "../../scripts/download";
import { Logger } from "../logger";
export const downloadIcon = async (imagePath: string, destination: string): Promise<string> => {
if (imagePath.startsWith("http")) {
try {
return await downloadFile(imagePath, destination);
} catch (error) {
Logger.log("Downloading file failed", { error });
return "";
}
}
return "";
};

View File

@@ -1,15 +0,0 @@
/**
* Build a track url given the id
*/
export const getTrackURL = (trackId: string) => {
return `https://tidal.com/browse/track/${trackId}`;
};
/**
* Retrieve the universal link given a regular track link
* @param trackLink
* @returns
*/
export const getUniversalLink = (trackLink: string) => {
return `${trackLink}?u`;
};

View File

@@ -32,8 +32,7 @@ const PROTOCOL_PREFIX = "tidal";
const windowPreferences = {
sandbox: false,
plugins: true,
devTools: true, // Ensure devTools is enabled for debugging
contextIsolation: false, // Disable context isolation for debugging
devTools: true, // I like tinkering, others might too
};
setDefaultFlags(app);

View File

@@ -1,11 +1,9 @@
import { MediaPlayerInfo } from "./mediaPlayerInfo";
import { MediaStatus } from "./mediaStatus";
import { RepeatState } from "./repeatState";
export interface MediaInfo {
title: string;
artists: string;
artistsArray?: string[];
album: string;
icon: string;
status: MediaStatus;
@@ -19,30 +17,3 @@ export interface MediaInfo {
favorite: boolean;
player?: MediaPlayerInfo;
}
export const getEmptyMediaInfo = () => {
const emptyState: MediaInfo = {
title: "",
artists: "",
artistsArray: [],
album: "",
playingFrom: "",
status: MediaStatus.playing,
url: "",
current: "00:00",
currentInSeconds: 100,
duration: "00:00",
durationInSeconds: 100,
image: "",
icon: "",
favorite: true,
player: {
status: MediaStatus.playing,
shuffle: true,
repeat: RepeatState.all,
},
};
return emptyState;
};

View File

@@ -61,8 +61,7 @@ let adBlock: HTMLInputElement,
discord_show_song: HTMLInputElement,
discord_show_idle: HTMLInputElement,
discord_idle_text: HTMLInputElement,
discord_using_text: HTMLInputElement,
controllerType: HTMLSelectElement;
discord_using_text: HTMLInputElement;
addCustomCss(app);
@@ -158,7 +157,6 @@ function refreshSettings() {
discord_show_idle.checked = settingsStore.get(settings.discord.showIdle);
discord_idle_text.value = settingsStore.get(settings.discord.idleText);
discord_using_text.value = settingsStore.get(settings.discord.usingText);
controllerType.value = settingsStore.get(settings.advanced.controllerType);
// set state of all switches with additional settings
Object.values(switchesWithSettings).forEach((settingSwitch) => {
@@ -279,7 +277,6 @@ window.addEventListener("DOMContentLoaded", () => {
discord_show_idle = get("discord_show_idle");
discord_using_text = get("discord_using_text");
discord_idle_text = get("discord_idle_text");
controllerType = get<HTMLSelectElement>("controllerType");
refreshSettings();
addInputListener(adBlock, settings.adBlock);
@@ -325,5 +322,4 @@ window.addEventListener("DOMContentLoaded", () => {
addInputListener(discord_show_idle, settings.discord.showIdle);
addInputListener(discord_idle_text, settings.discord.idleText);
addInputListener(discord_using_text, settings.discord.usingText);
addSelectListener(controllerType, settings.advanced.controllerType);
});

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="./settings.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
</head>
<body class="settings-window">
@@ -109,10 +109,7 @@
<div class="group__option">
<div class="group__description">
<h4>Static Window Title</h4>
<p>
Makes the window title "TIDAL Hi-Fi" instead of changing to the currently
playing song.
</p>
<p>Makes the window title "TIDAL Hi-Fi" instead of changing to the currently playing song.</p>
</div>
<label class="switch">
<input id="staticWindowTitle" type="checkbox" />
@@ -160,8 +157,8 @@
<p class="group__title">API</p>
<div class="group__description">
<p>
TIDAL Hi-Fi has a built-in web API to allow users to get current media
information. You can optionally enable playback control as well.
TIDAL Hi-Fi has a built-in web API to allow users to get current media information.
You can optionally enable playback control as well.
</p>
</div>
<div class="group__option">
@@ -183,8 +180,7 @@
<div class="group__option">
<div class="group__description">
<h4>API hostname</h4>
<p>
By default (127.0.0.1) only local apps can interface with the API. <br />
<p>By default (127.0.0.1) only local apps can interface with the API. <br />
Change to 0.0.0.0 to allow <strong>anyone</strong> to interact with it. <br />
Other options are available
</p>
@@ -240,6 +236,7 @@
</label>
</div>
<div id="discord_options">
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Show Idle Text</h4>
@@ -262,9 +259,7 @@
<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>
<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>
@@ -281,6 +276,7 @@
</div>
<div id="discord_show_song_options" class="hidden">
<div class="group__option" class="hidden">
<div class="group__description">
<h4>Include timestamps</h4>
@@ -308,6 +304,7 @@
</div>
</div>
</div>
</div>
</div>
<div class="group">
@@ -326,10 +323,7 @@
<div class="group__option">
<div class="group__description">
<h4>ListenBrainz API Url</h4>
<p>
There are multiple instances for ListenBrainz you can set the corresponding
API url below.
</p>
<p>There are multiple instances for ListenBrainz you can set the corresponding API url below.</p>
<input id="ListenBrainzAPI" type="text" class="text-input" name="ListenBrainzAPI" />
</div>
</div>
@@ -343,10 +337,8 @@
</div>
<div class="group__description">
<h4>ScrobbleDelay</h4>
<p>
The delay (in ms) to send a listen to ListenBrainz. Prevents spamming the API when
you fast forward immediately
</p>
<p>The delay (in ms) to send a listen to ListenBrainz. Prevents spamming the API when you fast forward
immediately</p>
<input id="listenbrainz_delay" type="number" class="text-input" name="listenbrainz_delay" />
</div>
</div>
@@ -359,9 +351,10 @@
<div class="group__description">
<h4>Update frequency</h4>
<p>
The amount of time, in milliseconds, that TIDAL Hi-Fi will refresh its playback
info by scraping the website. The default of 500 seems to work in more cases but
if you are fine with a bit more resource usage you can decrease it as well.
The amount of time, in milliseconds, that TIDAL Hi-Fi will refresh its playback info by scraping the
website.
The default of 500 seems to work in more cases but if you are fine with a bit more resource usage you
can decrease it as well.
</p>
<input id="updateFrequency" type="number" class="text-input" name="updateFrequency" />
</div>
@@ -376,19 +369,7 @@
</p>
<select class="select-input" id="channel" name="channel">
<option value="https://listen.tidal.com">Stable (listen.tidal.com)</option>
<option value="https://listen.stage.tidal.com">
Staging (listen.stage.tidal.com)
</option>
</select>
</div>
</div>
<div class="group__option">
<div class="group__description">
<h4>Controller Type</h4>
<p>Select the type of controller to use.</p>
<select id="controllerType" class="select-input">
<option value="domController">DOM Controller</option>
<option value="mediaSessionController">Media Session Controller</option>
<option value="https://listen.stage.tidal.com">Staging (listen.stage.tidal.com)</option>
</select>
</div>
</div>
@@ -434,8 +415,7 @@
<div class="group__description">
<h4>Wayland support</h4>
<p>
Adds a couple of Electron flags to help TIDAL Hi-Fi run smoothly on the Wayland
window system.
Adds a couple of Electron flags to help TIDAL Hi-Fi run smoothly on the Wayland window system.
</p>
</div>
<label class="switch">
@@ -453,8 +433,7 @@
<div class="group__description">
<h4>Custom CSS</h4>
<p>
The css that you put in here will be injected into a style tag in the head of
the document.
The css that you put in here will be injected into a style tag in the head of the document.
</p>
</div>
</div>
@@ -469,7 +448,9 @@
<p>
Select a theme below or "Tidal - Default" to return to the original Tidal look.
</p>
<select class="select-input" id="themesList" name="themesList"></select>
<select class="select-input" id="themesList" name="themesList">
</select>
</div>
</div>
@@ -477,14 +458,14 @@
<div class="group__description">
<h4>Upload new themes</h4>
<p>
Click the button and select the css files to import. They will be added to the
theme list automatically.
Click the button and select the css files to import. They will be added to the theme list
automatically.
</p>
<div class="file-drop-area">
<div>
<span class="file-btn">Choose files</span>
<span id="file-message" class="file-msg">or drag and drop files here</span>
<input id="theme-files" class="file-input" type="file" accept=".css" multiple />
<input id="theme-files" class="file-input" type="file" accept=".css" multiple>
</div>
</div>
</div>
@@ -497,7 +478,7 @@
<h4>TIDAL Hi-Fi</h4>
<div class="about-section__version">
<a target="_blank" rel="noopener"
href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.18.2">5.18.2</a>
href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.19.0">5.19.0</a>
</div>
<div class="about-section__links">
<a target="_blank" rel="noopener" href="https://github.com/mastermindzh/tidal-hifi/"

View File

@@ -3,7 +3,6 @@ import { clipboard, ipcRenderer } from "electron";
import Player from "mpris-service";
import { globalEvents } from "./constants/globalEvents";
import { settings } from "./constants/settings";
import { downloadIcon } from "./features/icon/downloadIcon";
import {
ListenBrainz,
ListenBrainzConstants,
@@ -11,56 +10,185 @@ import {
} from "./features/listenbrainz/listenbrainz";
import { StoreData } from "./features/listenbrainz/models/storeData";
import { Logger } from "./features/logger";
import { SharingService } from "./features/sharingService/sharingService";
import { addCustomCss } from "./features/theming/theming";
import { getTrackURL, getUniversalLink } from "./features/tidal/url";
import { convertDurationToSeconds } from "./features/time/parse";
import { getEmptyMediaInfo, MediaInfo } from "./models/mediaInfo";
import { MediaInfo } from "./models/mediaInfo";
import { MediaStatus } from "./models/mediaStatus";
import { RepeatState } from "./models/repeatState";
import { downloadFile } from "./scripts/download";
import { addHotkey } from "./scripts/hotkeys";
import { ObjectToDotNotation } from "./scripts/objectUtilities";
import { settingsStore } from "./scripts/settings";
import { setTitle } from "./scripts/window-functions";
import { DomControllerOptions } from "./TidalControllers/DomController/DomControllerOptions";
import { DomTidalController } from "./TidalControllers/DomController/DomTidalController";
import { TidalController } from "./TidalControllers/TidalController";
import { tidalControllers } from "./constants/controller";
const notificationPath = `${app.getPath("userData")}/notification.jpg`;
const staticTitle = "TIDAL Hi-Fi";
let currentSong = "";
let player: Player;
let currentPlayStatus = MediaStatus.paused;
let currentListenBrainzDelayId: ReturnType<typeof setTimeout>;
let scrobbleWaitingForDelay = false;
let currentlyPlaying = MediaStatus.paused;
let currentRepeatState: RepeatState = RepeatState.off;
let currentShuffleState = false;
let currentMediaInfo: MediaInfo;
let currentNotification: Electron.Notification;
let tidalController: TidalController;
let controllerOptions = {};
let currentMediaInfo = getEmptyMediaInfo();
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: '*[data-test^="profile-image-button"]',
settings: '*[data-test^="sidebar-menu-button"]',
openSettings: '*[data-test^="open-settings"]',
media: '*[data-test="current-media-imagery"]',
image: "img",
current: '*[data-test="current-time"]',
duration: '*[class^=_playbackControlsContainer] *[data-test="duration"]',
bar: '*[data-test="progress-bar"]',
footer: "#footerPlayer",
mediaItem: "[data-type='mediaItem']",
album_header_title: '*[class^="_playingFrom"] span:nth-child(2)',
playing_from: '*[class^="_playingFrom"] span:nth-child(2)',
queue_album: "*[class^=playQueueItemsContainer] *[class^=groupTitle] span:nth-child(2)",
currentlyPlaying: "[class^='isPlayingIcon'], [data-test-is-playing='true']",
album_name_cell: '[class^="album"]',
tracklist_row: '[data-test="tracklist-row"]',
volume: '*[data-test="volume"]',
favorite: '*[data-test="footer-favorite-button"]',
/**
* 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()]);
},
switch (settingsStore.get(settings.advanced.controllerType)) {
case tidalControllers.mediaSessionController: {
Logger.log("mediaSessionController initialized");
break;
/**
* Get the icon of the current media
*/
getSongIcon: function () {
const figure = this.get("media");
if (figure) {
const mediaElement = figure.querySelector(this["image"]);
if (mediaElement) {
return mediaElement.src.replace("80x80", "640x640");
}
}
default: {
tidalController = new DomTidalController();
const domControllerOptions: DomControllerOptions = {
refreshInterval: getDomUpdateFrequency(),
};
controllerOptions = domControllerOptions;
Logger.log("domController initialized");
break;
return "";
},
/**
* returns an array of all artists in the current media
* @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 () {
try {
//If listening to an album, get its name from the header title
if (globalThis.location.href.includes("/album/")) {
const albumName = globalThis.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 (
globalThis.location.href.includes("/playlist/") ||
globalThis.location.href.includes("/mix/")
) {
if (this.currentlyPlaying === 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;
}
}
}
// see whether we're on the queue page and get it from there
const queueAlbumName = this.getText("queue_album");
if (queueAlbumName) {
return queueAlbumName;
}
return "";
} catch {
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 getDomUpdateFrequency() {
function getUpdateFrequency() {
const storeValue = settingsStore.get<string, number>(settings.updateFrequency);
const defaultValue = 500;
@@ -71,6 +199,19 @@ function getDomUpdateFrequency() {
}
}
/**
* Play or pause the current media
*/
function playPause() {
const play = elements.get("play");
if (play) {
elements.click("play");
} else {
elements.click("pause");
}
}
/**
* Clears the old listenbrainz data on launch
*/
@@ -83,26 +224,30 @@ ListenBrainzStore.clear();
*/
function addHotKeys() {
if (settingsStore.get(settings.enableCustomHotkeys)) {
addHotkey("Control+p", () => {
tidalController.openSettings();
addHotkey("Control+p", function () {
elements.click("settings");
setTimeout(() => {
elements.click("openSettings");
}, 100);
});
addHotkey("Control+l", () => {
addHotkey("Control+l", function () {
handleLogout();
});
addHotkey("Control+a", () => {
tidalController.toggleFavorite();
addHotkey("Control+a", function () {
elements.click("favorite");
});
addHotkey("Control+h", () => {
tidalController.goToHome();
addHotkey("Control+h", function () {
elements.click("home");
});
addHotkey("backspace", function () {
tidalController.back();
elements.click("back");
});
addHotkey("shift+backspace", function () {
tidalController.forward();
elements.click("forward");
});
addHotkey("control+u", function () {
@@ -111,10 +256,10 @@ function addHotKeys() {
});
addHotkey("control+r", function () {
tidalController.repeat();
elements.click("repeat");
});
addHotkey("control+w", async function () {
const url = getUniversalLink(getTrackURL(tidalController.getTrackId()));
const url = SharingService.getUniversalLink(getTrackURL());
clipboard.writeText(url);
new Notification({
title: `Universal link generated: `,
@@ -178,22 +323,22 @@ function addIPCEventListeners() {
case globalEvents.playPause:
case globalEvents.play:
case globalEvents.pause:
tidalController.playPause();
playPause();
break;
case globalEvents.next:
tidalController.next();
elements.click("next");
break;
case globalEvents.previous:
tidalController.previous();
elements.click("previous");
break;
case globalEvents.toggleFavorite:
tidalController.toggleFavorite();
elements.click("favorite");
break;
case globalEvents.toggleShuffle:
tidalController.toggleShuffle();
elements.click("shuffle");
break;
case globalEvents.toggleRepeat:
tidalController.repeat();
elements.click("repeat");
break;
default:
break;
@@ -202,6 +347,48 @@ function addIPCEventListeners() {
});
}
/**
* 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;
}
function getCurrentShuffleState() {
const shuffle = elements.get("shuffle");
return shuffle?.getAttribute("aria-checked") === "true";
}
function getCurrentRepeatState() {
const repeat = elements.get("repeat");
switch (repeat?.getAttribute("data-type")) {
case "button__repeatAll":
return RepeatState.all;
case "button__repeatSingle":
return RepeatState.single;
default:
return RepeatState.off;
}
}
/**
* Convert the duration from MM:SS to seconds
* @param {*} duration
*/
function convertDuration(duration: string) {
const parts = duration.split(":");
return parseInt(parts[1]) + 60 * parseInt(parts[0]);
}
/**
* Update Tidal-hifi's media info
*
@@ -209,6 +396,7 @@ function addIPCEventListeners() {
*/
function updateMediaInfo(mediaInfo: MediaInfo, notify: boolean) {
if (mediaInfo) {
currentMediaInfo = mediaInfo;
ipcRenderer.send(globalEvents.updateInfo, mediaInfo);
updateMpris(mediaInfo);
updateListenBrainz(mediaInfo);
@@ -271,35 +459,16 @@ function addMPRIS() {
const eventValue = events[eventName];
switch (events[eventValue]) {
case events.playpause:
tidalController.playPause();
break;
case events.next:
tidalController.next();
break;
case events.previous:
tidalController.previous();
break;
case events.pause:
tidalController.pause();
break;
case events.stop:
tidalController.stop();
break;
case events.play:
tidalController.play();
break;
case events.loopStatus:
tidalController.repeat();
break;
case events.shuffle:
tidalController.toggleShuffle();
playPause();
break;
default:
elements.click(eventValue);
}
});
});
// Override get position function
player.getPosition = function () {
return tidalController.getCurrentPositionInSeconds();
return convertDuration(elements.getText("current")) * 1000 * 1000;
};
player.on("quit", function () {
app.quit();
@@ -320,8 +489,8 @@ function updateMpris(mediaInfo: MediaInfo) {
"xesam:album": mediaInfo.album,
"xesam:url": mediaInfo.url,
"mpris:artUrl": mediaInfo.image,
"mpris:length": convertDurationToSeconds(mediaInfo.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + tidalController.getTrackId(),
"mpris:length": convertDuration(mediaInfo.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
},
...ObjectToDotNotation(mediaInfo, "custom:"),
};
@@ -348,7 +517,7 @@ function updateListenBrainz(mediaInfo: MediaInfo) {
mediaInfo.title,
mediaInfo.artists,
mediaInfo.status,
convertDurationToSeconds(mediaInfo.duration)
convertDuration(mediaInfo.duration)
);
scrobbleWaitingForDelay = false;
},
@@ -359,36 +528,113 @@ function updateListenBrainz(mediaInfo: MediaInfo) {
}
}
tidalController.bootstrap(controllerOptions);
tidalController.onMediaInfoUpdate(async (newState) => {
currentMediaInfo = { ...currentMediaInfo, ...newState };
/**
* 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}`;
}
const songDashArtistTitle = `${currentMediaInfo.title} - ${currentMediaInfo.artists}`;
const isNewSong = currentSong !== songDashArtistTitle;
if (isNewSong) {
// check whether one of the artists is in the "skip artist" array, if so, skip...
skipArtistsIfFoundInSkippedArtistsList(currentMediaInfo.artistsArray ?? []);
// update the currently playing song
currentSong = songDashArtistTitle;
// update the window title with the new info
settingsStore.get(settings.staticWindowTitle)
? setTitle(staticTitle)
: setTitle(`${currentMediaInfo.title} - ${currentMediaInfo.artists}`);
// download a new icon and use it for the media info
if (!newState.icon && newState.image) {
currentMediaInfo.icon = await downloadIcon(currentMediaInfo.image, notificationPath);
} else {
currentMediaInfo.icon = "";
function getTrackID() {
const URLelement = elements.get("title").querySelector("a");
if (URLelement !== null) {
const id = URLelement.href.replace(/\D/g, "");
return id;
}
updateMediaInfo(currentMediaInfo, true);
return window.location;
}
/**
* Watch for song changes and update title + notify
*/
setInterval(function () {
const title = elements.getText("title");
const artistsArray = elements.getArtistsArray();
const artistsString = elements.getArtistsString(artistsArray);
const songDashArtistTitle = `${title} - ${artistsString}`;
const staticTitle = "TIDAL Hi-Fi";
const titleOrArtistsChanged = currentSong !== songDashArtistTitle;
const current = elements.getText("current");
const currentStatus = getCurrentlyPlayingStatus();
const shuffleState = getCurrentShuffleState();
const repeatState = getCurrentRepeatState();
const playStateChanged = currentStatus != currentlyPlaying;
const shuffleStateChanged = shuffleState != currentShuffleState;
const repeatStateChanged = repeatState != currentRepeatState;
const hasStateChanged = playStateChanged || shuffleStateChanged || repeatStateChanged;
// update info if song changed or was just paused/resumed
if (titleOrArtistsChanged || hasStateChanged) {
if (playStateChanged) currentlyPlaying = currentStatus;
if (shuffleStateChanged) currentShuffleState = shuffleState;
if (repeatStateChanged) currentRepeatState = repeatState;
skipArtistsIfFoundInSkippedArtistsList(artistsArray);
const album = elements.getAlbumName();
const duration = elements.getText("duration");
const options: MediaInfo = {
title,
artists: artistsString,
album: album,
playingFrom: elements.getText("playing_from"),
status: currentStatus,
url: getTrackURL(),
current,
currentInSeconds: convertDurationToSeconds(current),
duration,
durationInSeconds: convertDurationToSeconds(duration),
image: "",
icon: "",
favorite: elements.isFavorite(),
player: {
status: currentStatus,
shuffle: shuffleState,
repeat: repeatState,
},
};
// update title, url and play info with new info
settingsStore.get(settings.staticWindowTitle)
? setTitle(staticTitle)
: setTitle(songDashArtistTitle);
getTrackURL();
currentSong = songDashArtistTitle;
currentPlayStatus = currentStatus;
const image = elements.getSongIcon();
new Promise<void>((resolve) => {
if (image.startsWith("http")) {
options.image = image;
downloadFile(image, notificationPath).then(
() => {
options.icon = notificationPath;
resolve();
},
() => {
// if the image can't be downloaded then continue without it
resolve();
}
);
} else {
// if titleOrArtists didn't change then only minor mediaInfo (like timings) changed, so don't bother the user with notifications
updateMediaInfo(currentMediaInfo, false);
// if the image can't be found on the page continue without it
resolve();
}
}).then(() => {
updateMediaInfo(options, titleOrArtistsChanged);
});
} else {
// just update the time
updateMediaInfo(
{ ...currentMediaInfo, ...{ current, currentInSeconds: convertDurationToSeconds(current) } },
false
);
}
/**
@@ -403,12 +649,13 @@ tidalController.onMediaInfoUpdate(async (newState) => {
const artistNames = Object.values(artists).map((artist) => artist);
const foundArtist = artistNames.some((artist) => artistsToSkip.includes(artist));
if (foundArtist) {
tidalController.next();
elements.click("next");
}
}
}
}
});
}, getUpdateFrequency());
addMPRIS();
addCustomCss(app);
addHotKeys();

View File

@@ -26,7 +26,7 @@ const defaultPresence = {
largeImageKey: "tidal-hifi-icon",
largeImageText: `TIDAL Hi-Fi ${app.getVersion()}`,
instance: false,
type: ACTIVITY_LISTENING,
type: ACTIVITY_LISTENING
};
const updateActivity = () => {
@@ -117,17 +117,15 @@ const getActivity = (): SetActivity => {
const connectWithRetry = async (retryCount = 0) => {
try {
await rpc.login();
Logger.log("Connected to Discord");
Logger.log('Connected to Discord');
rpc.on("ready", updateActivity);
Object.values(globalEvents).forEach((event) => ipcMain.on(event, observer));
Object.values(globalEvents).forEach(event => ipcMain.on(event, observer));
} catch (error) {
if (retryCount < MAX_RETRIES) {
Logger.log(
`Failed to connect to Discord, retrying in ${RETRY_DELAY / 1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`
);
Logger.log(`Failed to connect to Discord, retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => connectWithRetry(retryCount + 1), RETRY_DELAY);
} else {
Logger.log("Failed to connect to Discord after maximum retry attempts");
Logger.log('Failed to connect to Discord after maximum retry attempts');
}
}
};
@@ -136,7 +134,7 @@ const connectWithRetry = async (retryCount = 0) => {
* Set up the discord rpc and listen on globalEvents.updateInfo
*/
export const initRPC = () => {
rpc = new Client({ transport: { type: "ipc" }, clientId });
rpc = new Client({ transport: {type: "ipc"}, clientId });
connectWithRetry();
};

View File

@@ -6,8 +6,8 @@ import request from "request";
* @param {string} fileUrl url to download
* @param {string} targetPath path to save it at
*/
export const downloadFile = function (fileUrl: string, targetPath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
export const downloadFile = function (fileUrl: string, targetPath: string) {
return new Promise((resolve, reject) => {
const req = request({
method: "GET",
uri: fileUrl,
@@ -16,9 +16,7 @@ export const downloadFile = function (fileUrl: string, targetPath: string): Prom
const out = fs.createWriteStream(targetPath);
req.pipe(out);
req.on("end", () => {
resolve(targetPath);
});
req.on("end", resolve);
req.on("error", reject);
});

View File

@@ -1,6 +1,28 @@
import { getEmptyMediaInfo, MediaInfo } from "../models/mediaInfo";
import { MediaInfo } from "../models/mediaInfo";
import { MediaStatus } from "../models/mediaStatus";
import { RepeatState } from "../models/repeatState";
const defaultInfo: MediaInfo = getEmptyMediaInfo();
const defaultInfo: MediaInfo = {
title: "",
artists: "",
album: "",
icon: "",
playingFrom: "",
status: MediaStatus.paused,
url: "",
current: "",
currentInSeconds: 0,
duration: "",
durationInSeconds: 0,
image: "tidal-hifi-icon",
favorite: false,
player: {
status: MediaStatus.paused,
shuffle: false,
repeat: RepeatState.off,
},
};
export let mediaInfo: MediaInfo = { ...defaultInfo };

View File

@@ -30,7 +30,6 @@ export const settingsStore = new Store({
adBlock: false,
advanced: {
tidalUrl: "https://listen.tidal.com",
controllerType: "domController",
},
api: true,
apiSettings: {
@@ -121,11 +120,6 @@ export const settingsStore = new Store({
"5.16.0": (migrationStore) => {
buildMigration("5.16.0", migrationStore, [{ key: settings.discord.showIdle, value: "true" }]);
},
"5.19.0": (migrationStore) => {
buildMigration("5.19.0", migrationStore, [
{ key: settings.advanced.controllerType, value: "domController" },
]);
},
},
});