tidal-hifi/src/preload.ts

635 lines
18 KiB
TypeScript
Raw Normal View History

2023-07-23 23:11:04 +02:00
import { app, dialog, Notification } from "@electron/remote";
import { clipboard, ipcRenderer } from "electron";
2023-05-07 15:45:45 +02:00
import Player from "mpris-service";
2023-05-07 16:13:30 +02:00
import { globalEvents } from "./constants/globalEvents";
2023-05-07 15:45:45 +02:00
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";
2024-06-09 12:33:48 +02:00
import { MediaInfo } from "./models/mediaInfo";
import { MediaStatus } from "./models/mediaStatus";
2024-06-09 12:33:48 +02:00
import { RepeatState } from "./models/repeatState";
2023-05-01 23:23:43 +02:00
import { downloadFile } from "./scripts/download";
import { addHotkey } from "./scripts/hotkeys";
2023-05-07 15:45:45 +02:00
import { settingsStore } from "./scripts/settings";
2023-05-07 16:13:30 +02:00
import { setTitle } from "./scripts/window-functions";
2023-07-23 23:11:04 +02:00
2019-10-22 21:25:57 +02:00
const notificationPath = `${app.getPath("userData")}/notification.jpg`;
2019-11-03 18:52:15 +01:00
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;
2024-06-09 12:33:48 +02:00
let currentMediaInfo: MediaInfo;
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"]',
2019-10-22 21:25:57 +02:00
media: '*[data-test="current-media-imagery"]',
image: "img",
2021-04-20 21:56:02 +02:00
current: '*[data-test="current-time"]',
duration: '*[class^=playbackControlsContainer] *[data-test="duration"]',
2021-04-20 21:56:02 +02:00
bar: '*[data-test="progress-bar"]',
footer: "#footerPlayer",
mediaItem: "[data-type='mediaItem']",
2024-06-09 12:33:48 +02:00
album_header_title: '*[class^="playingFrom"] span:nth-child(2)',
playing_from: '*[class^="playingFrom"] span:nth-child(2)',
currentlyPlaying: "[class^='isPlayingIcon'], [data-test-is-playing='true']",
album_name_cell: '[class^="album"]',
tracklist_row: '[data-test="tracklist-row"]',
volume: '*[data-test="volume"]',
favorite: '*[data-test="footer-favorite-button"]',
2019-10-22 21:25:57 +02:00
/**
* Get an element from the dom
* @param {*} key key in elements object to fetch
*/
2023-05-01 23:23:43 +02:00
get: function (key: string) {
return window.document.querySelector(this[key.toLowerCase()]);
},
2019-10-22 21:25:57 +02:00
/**
* Get the icon of the current song
*/
2020-10-04 11:52:08 +02:00
getSongIcon: function () {
2019-10-22 21:25:57 +02:00
const figure = this.get("media");
if (figure) {
const mediaElement = figure.querySelector(this["image"]);
if (mediaElement) {
return mediaElement.src.replace("80x80", "640x640");
2019-10-22 21:25:57 +02:00
}
}
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);
2023-05-01 23:23:43 +02:00
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
*/
2023-05-01 23:23:43 +02:00
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";
},
2019-10-22 21:25:57 +02:00
/**
* Shorthand function to get the text of a dom element
* @param {*} key key in elements object to fetch
*/
2023-05-01 23:23:43 +02:00
getText: function (key: string) {
2019-10-22 21:25:57 +02:00
const element = this.get(key);
return element ? element.textContent : "";
},
2019-10-22 21:25:57 +02:00
/**
* Shorthand function to click a dom element
* @param {*} key key in elements object to fetch
*/
2023-05-01 23:23:43 +02:00
click: function (key: string) {
this.get(key).click();
return this;
},
2019-10-22 21:25:57 +02:00
/**
* Shorthand function to focus a dom element
* @param {*} key key in elements object to fetch
*/
2023-05-01 23:23:43 +02:00
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() {
2023-05-07 15:45:45 +02:00
if (settingsStore.get(settings.enableCustomHotkeys)) {
2023-05-01 23:23:43 +02:00
addHotkey("Control+p", function () {
elements.click("account");
setTimeout(() => {
elements.click("settings");
}, 100);
});
2023-05-01 23:23:43 +02:00
addHotkey("Control+l", function () {
handleLogout();
});
addHotkey("Control+a", function () {
elements.click("favorite");
});
2023-05-01 23:23:43 +02:00
addHotkey("Control+h", function () {
elements.click("home");
});
2023-05-01 23:23:43 +02:00
addHotkey("backspace", function () {
elements.click("back");
});
2023-05-01 23:23:43 +02:00
addHotkey("shift+backspace", function () {
elements.click("forward");
});
2023-05-01 23:23:43 +02:00
addHotkey("control+u", function () {
// reloading window without cache should show the update bar if applicable
2023-05-01 23:23:43 +02:00
window.location.reload();
});
2023-05-01 23:23:43 +02:00
addHotkey("control+r", function () {
elements.click("repeat");
});
2023-07-23 23:11:04 +02:00
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();
});
}
2019-11-03 18:52:15 +01:00
// always add the hotkey for the settings window
2023-05-01 23:23:43 +02:00
addHotkey("control+=", function () {
2019-11-03 18:52:15 +01:00
ipcRenderer.send(globalEvents.showSettings);
});
2023-05-01 23:23:43 +02:00
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"];
2023-05-01 23:23:43 +02:00
dialog
.showMessageBox(null, {
type: "question",
title: "Logging out",
message: "Are you sure you want to log out?",
buttons: logoutOptions,
defaultId: 2,
2023-05-01 23:23:43 +02:00
})
.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);
2023-05-01 23:23:43 +02:00
break;
}
}
window.location.reload();
}
2023-05-01 23:23:43 +02:00
});
}
function addFullScreenListeners() {
2022-04-21 17:49:11 +02:00
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", () => {
2022-04-21 17:49:11 +02:00
ipcRenderer.on("globalEvent", (_event, args) => {
2019-10-22 21:25:57 +02:00
switch (args) {
case globalEvents.playPause:
case globalEvents.play:
case globalEvents.pause:
2019-10-22 21:25:57 +02:00
playPause();
break;
case globalEvents.next:
2019-10-22 21:25:57 +02:00
elements.click("next");
break;
case globalEvents.previous:
2019-10-22 21:25:57 +02:00
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;
2019-10-22 21:25:57 +02:00
}
});
});
}
/**
* Update the current status of tidal (e.g playing or paused)
*/
function getCurrentlyPlayingStatus() {
2023-05-07 15:45:45 +02:00
const pause = elements.get("pause");
let status = undefined;
2020-10-04 11:52:08 +02:00
// if pause button is visible tidal is playing
if (pause) {
status = MediaStatus.playing;
2020-10-04 11:52:08 +02:00
} else {
status = MediaStatus.paused;
2020-10-04 11:52:08 +02:00
}
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
*/
2023-05-01 23:23:43 +02:00
function convertDuration(duration: string) {
const parts = duration.split(":");
return parseInt(parts[1]) + 60 * parseInt(parts[0]);
}
/**
* Update Tidal-hifi's media info
*
2024-06-09 12:33:48 +02:00
* @param {*} mediaInfo
*/
2024-06-09 12:33:48 +02:00
function updateMediaInfo(mediaInfo: MediaInfo, notify: boolean) {
if (mediaInfo) {
currentMediaInfo = mediaInfo;
ipcRenderer.send(globalEvents.updateInfo, mediaInfo);
2023-05-07 15:45:45 +02:00
if (settingsStore.get(settings.notifications) && notify) {
if (currentNotification) currentNotification.close();
currentNotification = new Notification({
2024-06-09 12:33:48 +02:00
title: mediaInfo.title,
body: mediaInfo.artists,
icon: mediaInfo.icon,
});
currentNotification.show();
}
2024-06-09 12:33:48 +02:00
updateMpris(mediaInfo);
updateListenBrainz(mediaInfo);
}
}
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);
}
}
}
2024-06-09 12:33:48 +02:00
function updateMpris(mediaInfo: MediaInfo) {
if (player) {
player.metadata = {
...player.metadata,
...{
2024-06-09 12:33:48 +02:00
"xesam:title": mediaInfo.title,
"xesam:artist": [mediaInfo.artists],
"xesam:album": mediaInfo.album,
"mpris:artUrl": mediaInfo.image,
"mpris:length": convertDuration(mediaInfo.duration) * 1000 * 1000,
"mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(),
},
};
2024-06-09 12:33:48 +02:00
player.playbackStatus = mediaInfo.status === MediaStatus.paused ? "Paused" : "Playing";
}
}
/**
* Update the listenbrainz service with new data based on a few conditions
*/
2024-06-09 12:33:48 +02:00
function updateListenBrainz(mediaInfo: MediaInfo) {
if (settingsStore.get(settings.ListenBrainz.enabled)) {
const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData;
if (
2024-06-09 12:33:48 +02:00
(!oldData && mediaInfo.status === MediaStatus.playing) ||
(oldData && oldData.title !== mediaInfo.title)
) {
if (!scrobbleWaitingForDelay) {
scrobbleWaitingForDelay = true;
clearTimeout(currentListenBrainzDelayId);
currentListenBrainzDelayId = setTimeout(
() => {
ListenBrainz.scrobble(
2024-06-09 12:33:48 +02:00
mediaInfo.title,
mediaInfo.artists,
mediaInfo.status,
convertDuration(mediaInfo.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;
}
/**
2019-10-22 21:25:57 +02:00
* Watch for song changes and update title + notify
*/
2020-10-04 11:52:08 +02:00
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");
2024-06-09 12:33:48 +02:00
const options: MediaInfo = {
title,
artists: artistsString,
album: album,
2024-06-09 12:33:48 +02:00
playingFrom: elements.getText("playing_from"),
status: currentStatus,
url: getTrackURL(),
current,
currentInSeconds: convertDurationToSeconds(current),
duration,
durationInSeconds: convertDurationToSeconds(duration),
image: "",
icon: "",
favorite: elements.isFavorite(),
player: {
status: currentStatus,
shuffle: shuffleState,
repeat: repeatState,
},
};
// update title, url and play info with new info
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
*/
2023-05-01 23:23:43 +02:00
function skipArtistsIfFoundInSkippedArtistsList(artists: string[]) {
2023-05-07 15:45:45 +02:00
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();