mirror of
https://github.com/Mastermindzh/tidal-hifi.git
synced 2025-05-15 15:22:58 +02:00
Merge 99665610795dde7cf9f2874c8ff982c62918014e into 3740ce5a1262198637138fda874f0f64fd2e4350
This commit is contained in:
commit
3f87ca82f1
16656
package-lock.json
generated
16656
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
148
package.json
148
package.json
@ -1,76 +1,76 @@
|
|||||||
{
|
{
|
||||||
"name": "tidal-hifi",
|
"name": "tidal-hifi",
|
||||||
"version": "5.12.0",
|
"version": "5.13.0",
|
||||||
"description": "Tidal on Electron with widevine(hifi) support",
|
"description": "Tidal on Electron with widevine(hifi) support",
|
||||||
"main": "ts-dist/main.js",
|
"main": "ts-dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron --inspect=0.0.0.0:5858 .",
|
"start": "electron --inspect=0.0.0.0:5858 .",
|
||||||
"watchStart": "nodemon dist -x \"npm run start\"",
|
"watchStart": "nodemon dist -x \"npm run start\"",
|
||||||
"compile": "tsc && npm run sass-and-copy",
|
"compile": "tsc && npm run sass-and-copy",
|
||||||
"deps": "npm run watch",
|
"deps": "npm run watch",
|
||||||
"watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
|
"watch": "tsc-watch --onSuccess \"npm run sass-and-copy\"",
|
||||||
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist",
|
"copy-files": "copyfiles -u 1 --exclude './src/**/*.ts' --exclude './src/**/*.scss' \"./src/**/*\" ts-dist",
|
||||||
"copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
|
"copy-themes-dev": "copyfiles -u 1 \"./themes/*\" node_modules/electron/dist/resources",
|
||||||
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev",
|
"sass-and-copy": "npm run sass && npm run copy-files && npm run copy-themes-dev",
|
||||||
"build": "npm run builder -- -c ./build/electron-builder.yml",
|
"build": "npm run builder -- -c ./build/electron-builder.yml",
|
||||||
"build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml",
|
"build-deb": "npm run builder -- -c ./build/electron-builder.deb.yml",
|
||||||
"build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml",
|
"build-unpacked": "npm run builder -- -c ./build/electron-builder.unpacked.yml",
|
||||||
"build-rpm": "npm run builder -- -c ./build/electron-builder.rpm.yml",
|
"build-rpm": "npm run builder -- -c ./build/electron-builder.rpm.yml",
|
||||||
"build-snap": "npm run builder -- -c ./build/electron-builder.snap.yml",
|
"build-snap": "npm run builder -- -c ./build/electron-builder.snap.yml",
|
||||||
"build-arch": "npm run builder -- -c ./build/electron-builder.pacman.yml",
|
"build-arch": "npm run builder -- -c ./build/electron-builder.pacman.yml",
|
||||||
"build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl",
|
"build-wl": "npm run builder -- -c ./build/electron-builder.yml -wl",
|
||||||
"build-mac": "npm run builder -- -c ./build/electron-builder.yml -m",
|
"build-mac": "npm run builder -- -c ./build/electron-builder.yml -m",
|
||||||
"build-base": "npm run builder -- -c ./build/electron-builder.base.yml",
|
"build-base": "npm run builder -- -c ./build/electron-builder.base.yml",
|
||||||
"prebuilder": "npm run compile",
|
"prebuilder": "npm run compile",
|
||||||
"builder": "electron-builder --publish=never",
|
"builder": "electron-builder --publish=never",
|
||||||
"sass": "sass ./src/pages/settings/settings.scss ./src/pages/settings/settings.css && sass --no-source-map src/themes:themes",
|
"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": "npx stylelint **/*.scss",
|
||||||
"style-lint-fix": "npx stylelint --fix **/*.scss"
|
"style-lint-fix": "npx stylelint --fix **/*.scss"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"electron",
|
"electron",
|
||||||
"hifi",
|
"hifi",
|
||||||
"widevine",
|
"widevine",
|
||||||
"linux",
|
"linux",
|
||||||
"drm",
|
"drm",
|
||||||
"castlabs"
|
"castlabs"
|
||||||
],
|
],
|
||||||
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
|
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
|
||||||
"homepage": "https://github.com/Mastermindzh/tidal-hifi",
|
"homepage": "https://github.com/Mastermindzh/tidal-hifi",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/remote": "^2.1.2",
|
"@electron/remote": "^2.1.2",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"hotkeys-js": "^3.13.7",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"mpris-service": "^2.1.2",
|
"hotkeys-js": "^3.13.7",
|
||||||
"request": "^2.88.2",
|
"mpris-service": "^2.1.2",
|
||||||
"sass": "^1.75.0"
|
"request": "^2.88.2",
|
||||||
},
|
"sass": "^1.75.0",
|
||||||
"devDependencies": {
|
"zustand": "^4.5.2"
|
||||||
"@mastermindzh/prettier-config": "^1.0.0",
|
},
|
||||||
"@types/discord-rpc": "^4.0.8",
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@mastermindzh/prettier-config": "^1.0.0",
|
||||||
"@types/node": "^20.10.6",
|
"@types/discord-rpc": "^4.0.8",
|
||||||
"@types/request": "^2.48.12",
|
"@types/express": "^4.17.21",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
"@types/node": "^20.10.6",
|
||||||
"@typescript-eslint/parser": "^6.18.0",
|
"@types/request": "^2.48.12",
|
||||||
"copyfiles": "^2.4.1",
|
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
||||||
"electron": "git+https://github.com/castlabs/electron-releases#v28.1.1+wvcus",
|
"@typescript-eslint/parser": "^6.18.0",
|
||||||
"electron-builder": "^24.9.1",
|
"copyfiles": "^2.4.1",
|
||||||
"eslint": "^8.56.0",
|
"electron": "git+https://github.com/castlabs/electron-releases#v28.1.1+wvcus",
|
||||||
"js-yaml": "^4.1.0",
|
"electron-builder": "^24.9.1",
|
||||||
"markdown-toc": "^1.2.0",
|
"eslint": "^8.56.0",
|
||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"stylelint": "^16.1.0",
|
"stylelint": "^16.1.0",
|
||||||
"stylelint-config-standard": "^36.0.0",
|
"stylelint-config-standard": "^36.0.0",
|
||||||
"stylelint-config-standard-scss": "^13.0.0",
|
"stylelint-config-standard-scss": "^13.0.0",
|
||||||
"stylelint-prettier": "^5.0.0",
|
"stylelint-prettier": "^5.0.0",
|
||||||
"tsc-watch": "^6.0.4",
|
"tsc-watch": "^6.0.4",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"prettier": "@mastermindzh/prettier-config"
|
"prettier": "@mastermindzh/prettier-config"
|
||||||
}
|
}
|
@ -11,8 +11,9 @@ export const globalEvents = {
|
|||||||
storeChanged: "storeChanged",
|
storeChanged: "storeChanged",
|
||||||
error: "error",
|
error: "error",
|
||||||
whip: "whip",
|
whip: "whip",
|
||||||
|
downloadCover: "downloadCover",
|
||||||
log: "log",
|
log: "log",
|
||||||
toggleFavorite: "toggleFavorite",
|
toggleFavorite: "toggleFavorite",
|
||||||
toggleShuffle: "toggleShuffle",
|
toggleShuffle: "toggleShuffle",
|
||||||
toggleRepeat: "toggleRepeat",
|
toggleRepeat: "toggleRepeat",
|
||||||
};
|
} as const;
|
||||||
|
@ -57,4 +57,4 @@ export const settings = {
|
|||||||
width: "windowBounds.width",
|
width: "windowBounds.width",
|
||||||
height: "windowBounds.height",
|
height: "windowBounds.height",
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export default {
|
|
||||||
name: "TIDAL Hi-Fi",
|
|
||||||
};
|
|
@ -1,20 +1,15 @@
|
|||||||
import { Request, Response, Router } from "express";
|
import { Request, Response, Router } from "express";
|
||||||
import fs from "fs";
|
import { getLegacyMediaInfo, mainTidalState } from "../../state";
|
||||||
import { mediaInfo } from "../../../scripts/mediaInfo";
|
|
||||||
|
|
||||||
export const addCurrentInfo = (expressApp: Router) => {
|
export const addCurrentInfo = (expressApp: Router) => {
|
||||||
expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists }));
|
expressApp.get("/current", (_, res) => res.json(getLegacyMediaInfo()));
|
||||||
expressApp.get("/current/image", getCurrentImage);
|
expressApp.get("/current/image", getCurrentImage);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCurrentImage = (req: Request, res: Response) => {
|
export const getCurrentImage = (_: Request, res: Response) => {
|
||||||
const stream = fs.createReadStream(mediaInfo.icon);
|
if (!mainTidalState.currentTrack) {
|
||||||
stream.on("open", function () {
|
res.sendStatus(404).end("No song is playing");
|
||||||
res.set("Content-Type", "image/png");
|
return;
|
||||||
stream.pipe(res);
|
}
|
||||||
});
|
res.redirect(mainTidalState.currentTrack.image);
|
||||||
stream.on("error", function () {
|
|
||||||
res.set("Content-Type", "text/plain");
|
|
||||||
res.status(404).end("Not found");
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
@ -3,17 +3,14 @@ import { BrowserWindow } from "electron";
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { globalEvents } from "../../../constants/globalEvents";
|
import { globalEvents } from "../../../constants/globalEvents";
|
||||||
import { settings } from "../../../constants/settings";
|
import { settings } from "../../../constants/settings";
|
||||||
import { MediaStatus } from "../../../models/mediaStatus";
|
|
||||||
import { mediaInfo } from "../../../scripts/mediaInfo";
|
|
||||||
import { settingsStore } from "../../../scripts/settings";
|
import { settingsStore } from "../../../scripts/settings";
|
||||||
import { handleWindowEvent } from "../helpers/handleWindowEvent";
|
|
||||||
|
|
||||||
export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow) => {
|
export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow) => {
|
||||||
const windowEvent = handleWindowEvent(mainWindow);
|
|
||||||
const createRoute = (route: string) => `/player${route}`;
|
|
||||||
|
|
||||||
const createPlayerAction = (route: string, action: string) => {
|
const createPlayerAction = (route: string, action: string) => {
|
||||||
expressApp.post(createRoute(route), (req, res) => windowEvent(res, action));
|
expressApp.post(`/player${route}`, (_, res) => {
|
||||||
|
mainWindow.webContents.send("globalEvent", action);
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (settingsStore.get(settings.playBackControl)) {
|
if (settingsStore.get(settings.playBackControl)) {
|
||||||
@ -25,12 +22,6 @@ export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow
|
|||||||
createPlayerAction("/shuffle/toggle", globalEvents.toggleShuffle);
|
createPlayerAction("/shuffle/toggle", globalEvents.toggleShuffle);
|
||||||
createPlayerAction("/repeat/toggle", globalEvents.toggleRepeat);
|
createPlayerAction("/repeat/toggle", globalEvents.toggleRepeat);
|
||||||
|
|
||||||
expressApp.post(createRoute("/playpause"), (req, res) => {
|
createPlayerAction("/playpause", globalEvents.playPause);
|
||||||
if (mediaInfo.status === MediaStatus.playing) {
|
|
||||||
windowEvent(res, globalEvents.pause);
|
|
||||||
} else {
|
|
||||||
windowEvent(res, globalEvents.play);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
import { BrowserWindow } from "electron";
|
|
||||||
import { Response } from "express";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorthand to handle a fire and forget global event
|
|
||||||
* @param {*} res
|
|
||||||
* @param {*} action
|
|
||||||
*/
|
|
||||||
export const handleWindowEvent = (mainWindow: BrowserWindow) => (res: Response, action: string) => {
|
|
||||||
mainWindow.webContents.send("globalEvent", action);
|
|
||||||
res.sendStatus(200);
|
|
||||||
};
|
|
@ -2,8 +2,6 @@ import { BrowserWindow } from "electron";
|
|||||||
import { Response, Router } from "express";
|
import { Response, Router } from "express";
|
||||||
import { globalEvents } from "../../constants/globalEvents";
|
import { globalEvents } from "../../constants/globalEvents";
|
||||||
import { settings } from "../../constants/settings";
|
import { settings } from "../../constants/settings";
|
||||||
import { MediaStatus } from "../../models/mediaStatus";
|
|
||||||
import { mediaInfo } from "../../scripts/mediaInfo";
|
|
||||||
import { settingsStore } from "../../scripts/settings";
|
import { settingsStore } from "../../scripts/settings";
|
||||||
import { getCurrentImage } from "./features/current";
|
import { getCurrentImage } from "./features/current";
|
||||||
|
|
||||||
@ -26,13 +24,7 @@ export const addLegacyApi = (expressApp: Router, mainWindow: BrowserWindow) => {
|
|||||||
expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause));
|
expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause));
|
||||||
expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next));
|
expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next));
|
||||||
expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous));
|
expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous));
|
||||||
expressApp.get("/playpause", (req, res) => {
|
expressApp.get("/playpause", (req, res) => handleGlobalEvent(res, globalEvents.playPause));
|
||||||
if (mediaInfo.status === MediaStatus.playing) {
|
|
||||||
handleGlobalEvent(res, globalEvents.pause);
|
|
||||||
} else {
|
|
||||||
handleGlobalEvent(res, globalEvents.play);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Store from "electron-store";
|
import Store from "electron-store";
|
||||||
import { settings } from "../../constants/settings";
|
import { settings } from "../../constants/settings";
|
||||||
import { MediaStatus } from "../../models/mediaStatus";
|
|
||||||
import { settingsStore } from "../../scripts/settings";
|
import { settingsStore } from "../../scripts/settings";
|
||||||
import { Logger } from "../logger";
|
import { Logger } from "../logger";
|
||||||
import { StoreData } from "./models/storeData";
|
import { StoreData } from "./models/storeData";
|
||||||
@ -43,84 +42,81 @@ export class ListenBrainz {
|
|||||||
duration: number
|
duration: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (status === MediaStatus.paused) {
|
if (status === "Paused") return;
|
||||||
return;
|
// Fetches the oldData required for scrobbling and proceeds to construct a playing_now data payload for the Playing Now area
|
||||||
} else {
|
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
|
||||||
// Fetches the oldData required for scrobbling and proceeds to construct a playing_now data payload for the Playing Now area
|
const playing_data = {
|
||||||
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
|
listen_type: "playing_now",
|
||||||
const playing_data = {
|
payload: [
|
||||||
listen_type: "playing_now",
|
|
||||||
payload: [
|
|
||||||
{
|
|
||||||
track_metadata: {
|
|
||||||
additional_info: {
|
|
||||||
media_player: "Tidal Hi-Fi",
|
|
||||||
submission_client: "Tidal Hi-Fi",
|
|
||||||
music_service: "tidal.com",
|
|
||||||
duration: duration,
|
|
||||||
},
|
|
||||||
artist_name: artists,
|
|
||||||
track_name: title,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await axios.post(
|
|
||||||
`${settingsStore.get<string, string>(settings.ListenBrainz.api)}/1/submit-listens`,
|
|
||||||
playing_data,
|
|
||||||
{
|
{
|
||||||
headers: {
|
track_metadata: {
|
||||||
"Content-Type": "application/json",
|
additional_info: {
|
||||||
Authorization: `Token ${settingsStore.get<string, string>(
|
media_player: "Tidal Hi-Fi",
|
||||||
settings.ListenBrainz.token
|
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(
|
ListenBrainzStore.set(
|
||||||
ListenBrainzConstants.oldData,
|
ListenBrainzConstants.oldData,
|
||||||
this.constructStoreData(title, artists, duration)
|
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) {
|
} catch (error) {
|
||||||
|
36
src/features/state.ts
Normal file
36
src/features/state.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert a HH:MM:SS string (or variants such as MM:SS or SS) to plain seconds
|
|
||||||
* @param duration in HH:MM:SS format
|
|
||||||
* @returns number of seconds in duration
|
|
||||||
*/
|
|
||||||
export const convertDurationToSeconds = (duration: string) => {
|
|
||||||
return duration
|
|
||||||
.split(":")
|
|
||||||
.reverse()
|
|
||||||
.map((val) => Number(val))
|
|
||||||
.reduce((previous, current, index) => {
|
|
||||||
return index === 0 ? current : previous + current * Math.pow(60, index);
|
|
||||||
}, 0);
|
|
||||||
};
|
|
33
src/main.ts
33
src/main.ts
@ -11,10 +11,7 @@ import {
|
|||||||
} from "./features/idleInhibitor/idleInhibitor";
|
} from "./features/idleInhibitor/idleInhibitor";
|
||||||
import { Logger } from "./features/logger";
|
import { Logger } from "./features/logger";
|
||||||
import { Songwhip } from "./features/songwhip/songwhip";
|
import { Songwhip } from "./features/songwhip/songwhip";
|
||||||
import { MediaInfo } from "./models/mediaInfo";
|
|
||||||
import { MediaStatus } from "./models/mediaStatus";
|
|
||||||
import { initRPC, rpc, unRPC } from "./scripts/discord";
|
import { initRPC, rpc, unRPC } from "./scripts/discord";
|
||||||
import { updateMediaInfo } from "./scripts/mediaInfo";
|
|
||||||
import { addMenu } from "./scripts/menu";
|
import { addMenu } from "./scripts/menu";
|
||||||
import {
|
import {
|
||||||
closeSettingsWindow,
|
closeSettingsWindow,
|
||||||
@ -24,6 +21,10 @@ import {
|
|||||||
showSettingsWindow,
|
showSettingsWindow,
|
||||||
} from "./scripts/settings";
|
} from "./scripts/settings";
|
||||||
import { addTray, refreshTray } from "./scripts/tray";
|
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";
|
const tidalUrl = "https://listen.tidal.com";
|
||||||
let mainInhibitorId = -1;
|
let mainInhibitorId = -1;
|
||||||
|
|
||||||
@ -91,9 +92,8 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) {
|
|||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
...windowPreferences,
|
...windowPreferences,
|
||||||
...{
|
preload: path.join(__dirname, "preload/index.js"),
|
||||||
preload: path.join(__dirname, "preload.js"),
|
contextIsolation: false,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
enable(mainWindow.webContents);
|
enable(mainWindow.webContents);
|
||||||
@ -213,9 +213,9 @@ app.on("browser-window-created", (_, window) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// IPC
|
// IPC
|
||||||
ipcMain.on(globalEvents.updateInfo, (_event, arg: MediaInfo) => {
|
ipcMain.on(globalEvents.updateInfo, (_event, arg: TidalState) => {
|
||||||
updateMediaInfo(arg);
|
Object.assign(mainTidalState, arg);
|
||||||
if (arg.status === MediaStatus.playing) {
|
if (arg.status === "Playing") {
|
||||||
mainInhibitorId = acquireInhibitorIfInactive(mainInhibitorId);
|
mainInhibitorId = acquireInhibitorIfInactive(mainInhibitorId);
|
||||||
} else {
|
} else {
|
||||||
releaseInhibitorIfActive(mainInhibitorId);
|
releaseInhibitorIfActive(mainInhibitorId);
|
||||||
@ -248,8 +248,21 @@ ipcMain.on(globalEvents.error, (event) => {
|
|||||||
console.log(event);
|
console.log(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(globalEvents.whip, async (event, url) => {
|
ipcMain.handle(globalEvents.whip, async (_, url) => {
|
||||||
return Songwhip.whip(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);
|
Logger.watch(ipcMain);
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import { MediaStatus } from "./mediaStatus";
|
|
||||||
import { MediaPlayerInfo } from "./mediaPlayerInfo";
|
|
||||||
|
|
||||||
export interface MediaInfo {
|
|
||||||
title: string;
|
|
||||||
artists: string;
|
|
||||||
album: string;
|
|
||||||
icon: string;
|
|
||||||
status: MediaStatus;
|
|
||||||
url: string;
|
|
||||||
current: string;
|
|
||||||
currentInSeconds?: number;
|
|
||||||
duration: string;
|
|
||||||
durationInSeconds?: number;
|
|
||||||
image: string;
|
|
||||||
favorite: boolean;
|
|
||||||
player: MediaPlayerInfo;
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import { RepeatState } from "./repeatState";
|
|
||||||
import { MediaStatus } from "./mediaStatus";
|
|
||||||
|
|
||||||
export interface MediaPlayerInfo {
|
|
||||||
status: MediaStatus;
|
|
||||||
shuffle: boolean;
|
|
||||||
repeat: RepeatState;
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export enum MediaStatus {
|
|
||||||
playing = "playing",
|
|
||||||
paused = "paused",
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
export interface Options {
|
|
||||||
title: string;
|
|
||||||
artists: string;
|
|
||||||
album: string;
|
|
||||||
status: string;
|
|
||||||
url: string;
|
|
||||||
current: string;
|
|
||||||
currentInSeconds: number;
|
|
||||||
duration: string;
|
|
||||||
durationInSeconds: number;
|
|
||||||
"app-name": string;
|
|
||||||
image: string;
|
|
||||||
icon: string;
|
|
||||||
favorite: boolean;
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export enum RepeatState {
|
|
||||||
off = "off",
|
|
||||||
all = "all",
|
|
||||||
single = "single",
|
|
||||||
}
|
|
16
src/models/tidalState.ts
Normal file
16
src/models/tidalState.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
@ -24,8 +24,8 @@ const switchesWithSettings = {
|
|||||||
switch: "discord_show_song",
|
switch: "discord_show_song",
|
||||||
classToHide: "discord_show_song_options",
|
classToHide: "discord_show_song_options",
|
||||||
settingsKey: settings.discord.showSong,
|
settingsKey: settings.discord.showSong,
|
||||||
}
|
},
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
let adBlock: HTMLInputElement,
|
let adBlock: HTMLInputElement,
|
||||||
api: HTMLInputElement,
|
api: HTMLInputElement,
|
||||||
@ -138,7 +138,7 @@ function refreshSettings() {
|
|||||||
theme.value = settingsStore.get(settings.theme);
|
theme.value = settingsStore.get(settings.theme);
|
||||||
skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
|
skippedArtists.value = settingsStore.get<string, string[]>(settings.skippedArtists).join("\n");
|
||||||
trayIcon.checked = settingsStore.get(settings.trayIcon);
|
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);
|
enableListenBrainz.checked = settingsStore.get(settings.ListenBrainz.enabled);
|
||||||
ListenBrainzAPI.value = settingsStore.get(settings.ListenBrainz.api);
|
ListenBrainzAPI.value = settingsStore.get(settings.ListenBrainz.api);
|
||||||
ListenBrainzToken.value = settingsStore.get(settings.ListenBrainz.token);
|
ListenBrainzToken.value = settingsStore.get(settings.ListenBrainz.token);
|
||||||
@ -151,9 +151,12 @@ function refreshSettings() {
|
|||||||
discord_using_text.value = settingsStore.get(settings.discord.usingText);
|
discord_using_text.value = settingsStore.get(settings.discord.usingText);
|
||||||
|
|
||||||
// set state of all switches with additional settings
|
// set state of all switches with additional settings
|
||||||
Object.values(switchesWithSettings).forEach((settingSwitch) => {
|
for (const settingSwitch of Object.values(switchesWithSettings)) {
|
||||||
setElementHidden(settingsStore.get(settingSwitch.settingsKey), settingSwitch);
|
setElementHidden(
|
||||||
});
|
settingsStore.get(settingSwitch.settingsKey as any) as boolean,
|
||||||
|
settingSwitch
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.log("Refreshing settings failed.", error);
|
Logger.log("Refreshing settings failed.", error);
|
||||||
}
|
}
|
||||||
@ -264,7 +267,7 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||||||
discord_button_text = get("discord_button_text");
|
discord_button_text = get("discord_button_text");
|
||||||
discord_show_song = get("discord_show_song");
|
discord_show_song = get("discord_show_song");
|
||||||
discord_using_text = get("discord_using_text");
|
discord_using_text = get("discord_using_text");
|
||||||
discord_idle_text = get("discord_idle_text")
|
discord_idle_text = get("discord_idle_text");
|
||||||
|
|
||||||
refreshSettings();
|
refreshSettings();
|
||||||
addInputListener(adBlock, settings.adBlock);
|
addInputListener(adBlock, settings.adBlock);
|
||||||
@ -299,7 +302,11 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||||||
addInputListener(discord_details_prefix, settings.discord.detailsPrefix);
|
addInputListener(discord_details_prefix, settings.discord.detailsPrefix);
|
||||||
addInputListener(discord_include_timestamps, settings.discord.includeTimestamps);
|
addInputListener(discord_include_timestamps, settings.discord.includeTimestamps);
|
||||||
addInputListener(discord_button_text, settings.discord.buttonText);
|
addInputListener(discord_button_text, settings.discord.buttonText);
|
||||||
addInputListener(discord_show_song, settings.discord.showSong, switchesWithSettings.discord_show_song);
|
addInputListener(
|
||||||
|
discord_show_song,
|
||||||
|
settings.discord.showSong,
|
||||||
|
switchesWithSettings.discord_show_song
|
||||||
|
);
|
||||||
addInputListener(discord_idle_text, settings.discord.idleText);
|
addInputListener(discord_idle_text, settings.discord.idleText);
|
||||||
addInputListener(discord_using_text, settings.discord.usingText);
|
addInputListener(discord_using_text, settings.discord.usingText);
|
||||||
});
|
});
|
||||||
|
634
src/preload.ts
634
src/preload.ts
@ -1,634 +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 { convertDurationToSeconds } from "./features/time/parse";
|
|
||||||
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";
|
|
||||||
import { RepeatState } from "./models/repeatState";
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
let currentlyPlaying = MediaStatus.paused;
|
|
||||||
let currentRepeatState: RepeatState = RepeatState.off;
|
|
||||||
let currentShuffleState = false;
|
|
||||||
let currentMediaInfo: Options;
|
|
||||||
let currentNotification: Electron.Notification;
|
|
||||||
|
|
||||||
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:
|
|
||||||
case globalEvents.play:
|
|
||||||
case globalEvents.pause:
|
|
||||||
playPause();
|
|
||||||
break;
|
|
||||||
case globalEvents.next:
|
|
||||||
elements.click("next");
|
|
||||||
break;
|
|
||||||
case globalEvents.previous:
|
|
||||||
elements.click("previous");
|
|
||||||
break;
|
|
||||||
case globalEvents.toggleFavorite:
|
|
||||||
elements.click("favorite");
|
|
||||||
break;
|
|
||||||
case globalEvents.toggleShuffle:
|
|
||||||
elements.click("shuffle");
|
|
||||||
break;
|
|
||||||
case globalEvents.toggleRepeat:
|
|
||||||
elements.click("repeat");
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
*
|
|
||||||
* @param {*} options
|
|
||||||
*/
|
|
||||||
function updateMediaInfo(options: Options, notify: boolean) {
|
|
||||||
if (options) {
|
|
||||||
currentMediaInfo = options;
|
|
||||||
ipcRenderer.send(globalEvents.updateInfo, options);
|
|
||||||
if (settingsStore.get(settings.notifications) && notify) {
|
|
||||||
if (currentNotification) currentNotification.close();
|
|
||||||
currentNotification = new Notification({
|
|
||||||
title: options.title,
|
|
||||||
body: options.artists,
|
|
||||||
icon: options.icon,
|
|
||||||
});
|
|
||||||
currentNotification.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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 = {
|
|
||||||
title,
|
|
||||||
artists: artistsString,
|
|
||||||
album: album,
|
|
||||||
status: currentStatus,
|
|
||||||
url: getTrackURL(),
|
|
||||||
current,
|
|
||||||
currentInSeconds: convertDurationToSeconds(current),
|
|
||||||
duration,
|
|
||||||
durationInSeconds: convertDurationToSeconds(duration),
|
|
||||||
"app-name": appName,
|
|
||||||
image: "",
|
|
||||||
icon: "",
|
|
||||||
favorite: elements.isFavorite(),
|
|
||||||
|
|
||||||
player: {
|
|
||||||
status: currentStatus,
|
|
||||||
shuffle: shuffleState,
|
|
||||||
repeat: repeatState,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// just update the time
|
|
||||||
updateMediaInfo(
|
|
||||||
{ ...currentMediaInfo, ...{ current, currentInSeconds: convertDurationToSeconds(current) } },
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* automatically skip a song if the artists are found in the list of artists to skip
|
|
||||||
* @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
15
src/preload/index.ts
Normal 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);
|
96
src/preload/integrations/hotkeys.ts
Normal file
96
src/preload/integrations/hotkeys.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
35
src/preload/integrations/ipc.ts
Normal file
35
src/preload/integrations/ipc.ts
Normal 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);
|
||||||
|
});
|
38
src/preload/integrations/listenbrainz.ts
Normal file
38
src/preload/integrations/listenbrainz.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
77
src/preload/integrations/mpris.ts
Normal file
77
src/preload/integrations/mpris.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
23
src/preload/integrations/notifications.ts
Normal file
23
src/preload/integrations/notifications.ts
Normal 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();
|
||||||
|
});
|
15
src/preload/integrations/skipArtists.ts
Normal file
15
src/preload/integrations/skipArtists.ts
Normal 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
188
src/preload/redux.ts
Normal 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
165
src/preload/state.ts
Normal 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;
|
@ -3,10 +3,8 @@ import { app, ipcMain } from "electron";
|
|||||||
import { globalEvents } from "../constants/globalEvents";
|
import { globalEvents } from "../constants/globalEvents";
|
||||||
import { settings } from "../constants/settings";
|
import { settings } from "../constants/settings";
|
||||||
import { Logger } from "../features/logger";
|
import { Logger } from "../features/logger";
|
||||||
import { convertDurationToSeconds } from "../features/time/parse";
|
|
||||||
import { MediaStatus } from "../models/mediaStatus";
|
|
||||||
import { mediaInfo } from "./mediaInfo";
|
|
||||||
import { settingsStore } from "./settings";
|
import { settingsStore } from "./settings";
|
||||||
|
import { mainTidalState } from "../features/state";
|
||||||
|
|
||||||
const clientId = "833617820704440341";
|
const clientId = "833617820704440341";
|
||||||
|
|
||||||
@ -27,7 +25,7 @@ const defaultPresence = {
|
|||||||
const getActivity = (): Presence => {
|
const getActivity = (): Presence => {
|
||||||
const presence: Presence = { ...defaultPresence };
|
const presence: Presence = { ...defaultPresence };
|
||||||
|
|
||||||
if (mediaInfo.status === MediaStatus.paused) {
|
if (mainTidalState.status === "Paused") {
|
||||||
presence.details =
|
presence.details =
|
||||||
settingsStore.get<string, string>(settings.discord.idleText) ?? "Browsing Tidal";
|
settingsStore.get<string, string>(settings.discord.idleText) ?? "Browsing Tidal";
|
||||||
} else {
|
} else {
|
||||||
@ -55,24 +53,26 @@ const getActivity = (): Presence => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setPresenceFromMediaInfo(detailsPrefix: string, buttonText: string) {
|
function setPresenceFromMediaInfo(detailsPrefix: string, buttonText: string) {
|
||||||
if (mediaInfo.url) {
|
const track = mainTidalState.currentTrack;
|
||||||
presence.details = `${detailsPrefix}${mediaInfo.title}`;
|
if (!track) return;
|
||||||
presence.state = mediaInfo.artists ? mediaInfo.artists : "unknown artist(s)";
|
if (track.url) {
|
||||||
presence.largeImageKey = mediaInfo.image;
|
presence.details = `${detailsPrefix}${track.title}`;
|
||||||
if (mediaInfo.album) {
|
presence.state = track.artists.join(", ");
|
||||||
presence.largeImageText = mediaInfo.album;
|
presence.largeImageKey = track.image;
|
||||||
|
if (track.album) {
|
||||||
|
presence.largeImageText = track.album;
|
||||||
}
|
}
|
||||||
presence.buttons = [{ label: buttonText, url: mediaInfo.url }];
|
presence.buttons = [{ label: buttonText, url: track.url }];
|
||||||
} else {
|
} else {
|
||||||
presence.details = `Watching ${mediaInfo.title}`;
|
presence.details = `Watching ${track.title}`;
|
||||||
presence.state = mediaInfo.artists;
|
presence.state = track.artists.join(", ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function includeTimeStamps(includeTimestamps: boolean) {
|
function includeTimeStamps(includeTimestamps: boolean) {
|
||||||
if (includeTimestamps) {
|
if (includeTimestamps) {
|
||||||
const currentSeconds = convertDurationToSeconds(mediaInfo.current);
|
const currentSeconds = mainTidalState.currentTrack?.current ?? 0;
|
||||||
const durationSeconds = convertDurationToSeconds(mediaInfo.duration);
|
const durationSeconds = mainTidalState.currentTrack?.duration ?? 0;
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const now = (date.getTime() / 1000) | 0;
|
const now = (date.getTime() / 1000) | 0;
|
||||||
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
|
const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds));
|
||||||
|
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,65 +1,8 @@
|
|||||||
import { MediaInfo } from "../models/mediaInfo";
|
import { TidalState } from "../models/tidalState";
|
||||||
import { MediaStatus } from "../models/mediaStatus";
|
|
||||||
import { RepeatState } from "../models/repeatState";
|
|
||||||
|
|
||||||
export const mediaInfo = {
|
// This object is globally mutated
|
||||||
title: "",
|
export const tidalState: TidalState = {
|
||||||
artists: "",
|
status: "Stopped",
|
||||||
album: "",
|
repeat: "Off",
|
||||||
icon: "",
|
shuffle: false,
|
||||||
status: MediaStatus.paused as string,
|
|
||||||
url: "",
|
|
||||||
current: "",
|
|
||||||
currentInSeconds: 0,
|
|
||||||
duration: "",
|
|
||||||
durationInSeconds: 0,
|
|
||||||
image: "tidal-hifi-icon",
|
|
||||||
favorite: false,
|
|
||||||
|
|
||||||
player: {
|
|
||||||
status: MediaStatus.paused as string,
|
|
||||||
shuffle: false,
|
|
||||||
repeat: RepeatState.off as string,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = toUniversalUrl(propOrDefault(arg.url));
|
|
||||||
mediaInfo.status = propOrDefault(arg.status);
|
|
||||||
mediaInfo.current = propOrDefault(arg.current);
|
|
||||||
mediaInfo.currentInSeconds = arg.currentInSeconds ?? 0;
|
|
||||||
mediaInfo.duration = propOrDefault(arg.duration);
|
|
||||||
mediaInfo.durationInSeconds = arg.durationInSeconds ?? 0;
|
|
||||||
mediaInfo.image = propOrDefault(arg.image);
|
|
||||||
mediaInfo.favorite = arg.favorite;
|
|
||||||
|
|
||||||
mediaInfo.player.status = propOrDefault(arg.player?.status);
|
|
||||||
mediaInfo.player.shuffle = arg.player.shuffle;
|
|
||||||
mediaInfo.player.repeat = propOrDefault(arg.player?.repeat);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the property or a default value
|
|
||||||
* @param {*} prop property to check
|
|
||||||
* @param {*} defaultValue defaults to ""
|
|
||||||
*/
|
|
||||||
function propOrDefault(prop: string, defaultValue = "") {
|
|
||||||
return prop || defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append the universal link syntax (?u) to any url
|
|
||||||
* @param url url to append the universal link syntax to
|
|
||||||
* @returns url with `?u` appended, or the original value of url if falsy
|
|
||||||
*/
|
|
||||||
function toUniversalUrl(url: string) {
|
|
||||||
if (url) {
|
|
||||||
const queryParamsSet = url.indexOf("?");
|
|
||||||
return queryParamsSet > -1 ? `${url}&u` : `${url}?u`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { BrowserWindow, Menu, app } from "electron";
|
import { BrowserWindow, Menu, app } from "electron";
|
||||||
import { showSettingsWindow } from "./settings";
|
import { showSettingsWindow } from "./settings";
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
import name from "./../constants/values";
|
|
||||||
|
|
||||||
const settingsMenuEntry = {
|
const settingsMenuEntry = {
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
@ -31,7 +31,7 @@ export const getMenu = function (mainWindow: BrowserWindow) {
|
|||||||
...(isMac
|
...(isMac
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: name,
|
label: "TIDAL Hi-Fi",
|
||||||
submenu: [
|
submenu: [
|
||||||
settingsMenuEntry,
|
settingsMenuEntry,
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
@ -117,6 +117,7 @@ export const createSettingsWindow = function () {
|
|||||||
show: false,
|
show: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
type: "dialog",
|
||||||
title: "TIDAL Hi-Fi settings",
|
title: "TIDAL Hi-Fi settings",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "../pages/settings/preload.js"),
|
preload: path.join(__dirname, "../pages/settings/preload.js"),
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
export const setTitle = function (title: string) {
|
|
||||||
window.document.title = title;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTitle = function () {
|
|
||||||
return window.document.title;
|
|
||||||
};
|
|
121
src/types/mpris-service.d.ts
vendored
121
src/types/mpris-service.d.ts
vendored
@ -1,60 +1,63 @@
|
|||||||
declare class InitOptions {
|
declare module "mpris-service" {
|
||||||
name: string;
|
export interface InitOptions {
|
||||||
identity: string;
|
name: string;
|
||||||
supportedUriSchemes: string[];
|
identity: string;
|
||||||
supportedMimeTypes: string[];
|
supportedUriSchemes: string[];
|
||||||
supportedInterfaces: string[];
|
supportedMimeTypes: string[];
|
||||||
desktopEntry: string;
|
supportedInterfaces: string[];
|
||||||
}
|
desktopEntry: string;
|
||||||
|
}
|
||||||
declare class Player {
|
export interface Player {
|
||||||
metadata: {
|
metadata: {
|
||||||
"xesam:title": string;
|
"xesam:title"?: string;
|
||||||
"xesam:artist": string[];
|
"xesam:artist"?: string[];
|
||||||
"xesam:album": string;
|
"xesam:album"?: string;
|
||||||
"mpris:artUrl": string;
|
"mpris:artUrl"?: string;
|
||||||
"mpris:length": number;
|
"mpris:length"?: number | bigint;
|
||||||
"mpris:trackid": string;
|
"mpris:trackid": string;
|
||||||
// other options
|
// other options
|
||||||
[key: string]: string | number | string[] | object;
|
[key: string]: string | number | string[] | bigint | object;
|
||||||
};
|
};
|
||||||
playbackStatus: string;
|
playbackStatus: "Playing" | "Paused" | "Stopped";
|
||||||
identity: string;
|
identity: string;
|
||||||
fullscreen: boolean;
|
fullscreen: boolean;
|
||||||
supportedUriSchemes: string[];
|
supportedUriSchemes: string[];
|
||||||
supportedMimeTypes: string[];
|
supportedMimeTypes: string[];
|
||||||
canQuit: boolean;
|
canQuit: boolean;
|
||||||
canRaise: boolean;
|
canRaise: boolean;
|
||||||
canSetFullscreen: boolean;
|
canSetFullscreen: boolean;
|
||||||
hasTrackList: boolean;
|
hasTrackList: boolean;
|
||||||
desktopEntry: string;
|
desktopEntry: string;
|
||||||
loopStatus: string;
|
loopStatus: string;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
canPause: boolean;
|
canPause: boolean;
|
||||||
canPlay: boolean;
|
canPlay: boolean;
|
||||||
canSeek: boolean;
|
canSeek: boolean;
|
||||||
canGoNext: boolean;
|
canGoNext: boolean;
|
||||||
canGoPrevious: boolean;
|
canGoPrevious: boolean;
|
||||||
rate: number;
|
rate: number;
|
||||||
minimumRate: number;
|
minimumRate: number;
|
||||||
maximumRate: number;
|
maximumRate: number;
|
||||||
playlists: string[];
|
playlists: string[];
|
||||||
activePlaylist: string;
|
activePlaylist: string;
|
||||||
|
|
||||||
constructor(opts: { name: string; supportedInterfaces?: string[] });
|
getPosition(): number | bigint;
|
||||||
constructor(opts: InitOptions);
|
seeked(): void;
|
||||||
|
getTrackIndex(trackId: number): number;
|
||||||
getPosition(): number;
|
getTrack(trackId: number): string;
|
||||||
seeked(): void;
|
addTrack(track: object): void;
|
||||||
getTrackIndex(trackId: number): number;
|
removeTrack(trackId: number): number;
|
||||||
getTrack(trackId: number): string;
|
getPlaylistIndex(playlistId: number): number;
|
||||||
addTrack(track: object): void;
|
setPlaylists(playlists: object): void;
|
||||||
removeTrack(trackId: number): number;
|
setActivePlaylist(playlistId: number): void;
|
||||||
getPlaylistIndex(playlistId: number): number;
|
objectPath(path: string): string;
|
||||||
setPlaylists(playlists: object): void;
|
|
||||||
setActivePlaylist(playlistId: number): void;
|
on(event: string | symbol, listener: (...args: object[]) => void): this;
|
||||||
|
_bus: import("dbus-next").MessageBus;
|
||||||
on(event: string | symbol, listener: (...args: object[]) => void): this;
|
}
|
||||||
|
|
||||||
|
export default function Player(opts: { name: string; supportedInterfaces?: string[] }): Player;
|
||||||
|
export default function Player(opts: InitOptions): Player;
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"typeRoots": ["src/types", "node_modules/@types"],
|
"typeRoots": ["src/types", "node_modules/@types"],
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "ES6",
|
"target": "ESNext",
|
||||||
"lib": ["ES2020", "DOM"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user