diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a67aedc9..2ebc0afe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.11.0] + +- Re-implemented the API, added support for duration/current in seconds & shuffle+repeat + - made the original API "legacy" (still works the same) + - Now using the correct HTTP verb for all new endpoints +- Implemented TIDAL's universal links. All links are now universal. +- Custom `tidal://` protocol fixed - By [TheRockYT](https://github.com/TheRockYT) +- Global media shortcuts removed since TIDAL includes them by default - By [TheRockYT](https://github.com/TheRockYT) + +- Fixes + - [#390](https://github.com/Mastermindzh/tidal-hifi/issues/390) + - [#376](https://github.com/Mastermindzh/tidal-hifi/issues/376) + - [#383](https://github.com/Mastermindzh/tidal-hifi/issues/383) + - [#393](https://github.com/Mastermindzh/tidal-hifi/issues/393) + ## [5.10.0] - TIDAL will now close the previous notification if a new one is sent whilst the old is still visible. [#364](https://github.com/Mastermindzh/tidal-hifi/pull/364) diff --git a/package-lock.json b/package-lock.json index 62804ed13..79084a317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tidal-hifi", - "version": "5.10.0", + "version": "5.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tidal-hifi", - "version": "5.10.0", + "version": "5.11.0", "license": "MIT", "dependencies": { "@electron/remote": "^2.1.2", @@ -8564,4 +8564,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 6d0e7a000..d5d33e09c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tidal-hifi", - "version": "5.10.0", + "version": "5.11.0", "description": "Tidal on Electron with widevine(hifi) support", "main": "ts-dist/main.js", "scripts": { diff --git a/src/constants/globalEvents.ts b/src/constants/globalEvents.ts index 29ba17582..c08e12ecf 100644 --- a/src/constants/globalEvents.ts +++ b/src/constants/globalEvents.ts @@ -13,4 +13,6 @@ export const globalEvents = { whip: "whip", log: "log", toggleFavorite: "toggleFavorite", + toggleShuffle: "toggleShuffle", + toggleRepeat: "toggleRepeat", }; diff --git a/src/features/api/features/current.ts b/src/features/api/features/current.ts new file mode 100644 index 000000000..8569114cc --- /dev/null +++ b/src/features/api/features/current.ts @@ -0,0 +1,20 @@ +import { Request, Response, Router } from "express"; +import fs from "fs"; +import { mediaInfo } from "../../../scripts/mediaInfo"; + +export const addCurrentInfo = (expressApp: Router) => { + expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists })); + expressApp.get("/current/image", getCurrentImage); +}; + +export const getCurrentImage = (req: Request, res: Response) => { + const stream = fs.createReadStream(mediaInfo.icon); + stream.on("open", function () { + res.set("Content-Type", "image/png"); + stream.pipe(res); + }); + stream.on("error", function () { + res.set("Content-Type", "text/plain"); + res.status(404).end("Not found"); + }); +}; diff --git a/src/features/api/features/player.ts b/src/features/api/features/player.ts new file mode 100644 index 000000000..05c9e56c1 --- /dev/null +++ b/src/features/api/features/player.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BrowserWindow } from "electron"; +import { Router } from "express"; +import { globalEvents } from "../../../constants/globalEvents"; +import { settings } from "../../../constants/settings"; +import { MediaStatus } from "../../../models/mediaStatus"; +import { mediaInfo } from "../../../scripts/mediaInfo"; +import { settingsStore } from "../../../scripts/settings"; +import { handleWindowEvent } from "../helpers/handleWindowEvent"; + +export const addPlaybackControl = (expressApp: Router, mainWindow: BrowserWindow) => { + const windowEvent = handleWindowEvent(mainWindow); + const createRoute = (route: string) => `/player${route}`; + + const createPlayerAction = (route: string, action: string) => { + expressApp.post(createRoute(route), (req, res) => windowEvent(res, action)); + }; + + if (settingsStore.get(settings.playBackControl)) { + createPlayerAction("/play", globalEvents.play); + createPlayerAction("/favorite/toggle", globalEvents.toggleFavorite); + createPlayerAction("/pause", globalEvents.pause); + createPlayerAction("/next", globalEvents.next); + createPlayerAction("/previous", globalEvents.previous); + createPlayerAction("/shuffle/toggle", globalEvents.toggleShuffle); + createPlayerAction("/repeat/toggle", globalEvents.toggleRepeat); + + expressApp.post(createRoute("/playpause"), (req, res) => { + if (mediaInfo.status === MediaStatus.playing) { + windowEvent(res, globalEvents.pause); + } else { + windowEvent(res, globalEvents.play); + } + }); + } +}; diff --git a/src/features/api/helpers/handleWindowEvent.ts b/src/features/api/helpers/handleWindowEvent.ts new file mode 100644 index 000000000..907b1434f --- /dev/null +++ b/src/features/api/helpers/handleWindowEvent.ts @@ -0,0 +1,12 @@ +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); +}; diff --git a/src/features/api/index.ts b/src/features/api/index.ts new file mode 100644 index 000000000..a2f1547d7 --- /dev/null +++ b/src/features/api/index.ts @@ -0,0 +1,31 @@ +import { BrowserWindow, dialog } from "electron"; +import express from "express"; +import { settings } from "../../constants/settings"; +import { settingsStore } from "../../scripts/settings"; +import { addCurrentInfo } from "./features/current"; +import { addPlaybackControl } from "./features/player"; +import { addLegacyApi } from "./legacy"; + +/** + * Function to enable TIDAL Hi-Fi's express api + */ +export const startApi = (mainWindow: BrowserWindow) => { + const expressApp = express(); + expressApp.get("/", (req, res) => res.send("Hello World!")); + + // add features + addLegacyApi(expressApp, mainWindow); + addPlaybackControl(expressApp, mainWindow); + addCurrentInfo(expressApp); + + const port = settingsStore.get(settings.apiSettings.port); + const expressInstance = expressApp.listen(port, "127.0.0.1"); + expressInstance.on("error", function (e: { code: string }) { + let message = e.code; + if (e.code === "EADDRINUSE") { + message = `Port ${port} in use.`; + } + + dialog.showErrorBox("Api failed to start.", message); + }); +}; diff --git a/src/features/api/legacy.ts b/src/features/api/legacy.ts new file mode 100644 index 000000000..866013f74 --- /dev/null +++ b/src/features/api/legacy.ts @@ -0,0 +1,47 @@ +import { BrowserWindow } from "electron"; +import { Response, Router } from "express"; +import { globalEvents } from "../../constants/globalEvents"; +import { settings } from "../../constants/settings"; +import { MediaStatus } from "../../models/mediaStatus"; +import { mediaInfo } from "../../scripts/mediaInfo"; +import { settingsStore } from "../../scripts/settings"; +import { getCurrentImage } from "./features/current"; + +/** + * The legacy API, this will not be maintained and probably has duplicate code :) + * @param expressApp + * @param mainWindow + */ +export const addLegacyApi = (expressApp: Router, mainWindow: BrowserWindow) => { + expressApp.get("/image", getCurrentImage); + + if (settingsStore.get(settings.playBackControl)) { + addLegacyControls(); + } + function addLegacyControls() { + expressApp.get("/play", ({ res }) => handleGlobalEvent(res, globalEvents.play)); + expressApp.post("/favorite/toggle", (req, res) => + handleGlobalEvent(res, globalEvents.toggleFavorite) + ); + expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause)); + expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next)); + expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous)); + expressApp.get("/playpause", (req, res) => { + if (mediaInfo.status === MediaStatus.playing) { + handleGlobalEvent(res, globalEvents.pause); + } else { + handleGlobalEvent(res, globalEvents.play); + } + }); + } + + /** + * Shorthand to handle a fire and forget global event + * @param {*} res + * @param {*} action + */ + function handleGlobalEvent(res: Response, action: string) { + mainWindow.webContents.send("globalEvent", action); + res.sendStatus(200); + } +}; diff --git a/src/features/flags/flags.ts b/src/features/flags/flags.ts index 8b156e2c3..df5dbef5e 100644 --- a/src/features/flags/flags.ts +++ b/src/features/flags/flags.ts @@ -9,7 +9,6 @@ import { Logger } from "../logger"; */ export function setDefaultFlags(app: App) { setFlag(app, "disable-seccomp-filter-sandbox"); - setFlag(app, "disable-features", "MediaSessionService"); } /** diff --git a/src/features/time/parse.ts b/src/features/time/parse.ts new file mode 100644 index 000000000..2fe17d557 --- /dev/null +++ b/src/features/time/parse.ts @@ -0,0 +1,14 @@ +/** + * 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); +}; diff --git a/src/main.ts b/src/main.ts index f20b6ef5d..f0c006446 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,9 @@ import { enable, initialize } from "@electron/remote/main"; -import { - app, - BrowserWindow, - components, - globalShortcut, - ipcMain, - protocol, - session, -} from "electron"; +import { BrowserWindow, app, components, ipcMain, session } from "electron"; import path from "path"; import { globalEvents } from "./constants/globalEvents"; -import { mediaKeys } from "./constants/mediaKeys"; import { settings } from "./constants/settings"; +import { startApi } from "./features/api"; import { setDefaultFlags, setManagedFlagsFromSettings } from "./features/flags/flags"; import { acquireInhibitorIfInactive, @@ -22,7 +14,6 @@ import { Songwhip } from "./features/songwhip/songwhip"; import { MediaInfo } from "./models/mediaInfo"; import { MediaStatus } from "./models/mediaStatus"; import { initRPC, rpc, unRPC } from "./scripts/discord"; -import { startExpress } from "./scripts/express"; import { updateMediaInfo } from "./scripts/mediaInfo"; import { addMenu } from "./scripts/menu"; import { @@ -61,20 +52,31 @@ function syncMenuBarWithStore() { } /** - * Determine whether the current window is the main window - * if singleInstance is requested. - * If singleInstance isn't requested simply return true - * @returns true if singInstance is not requested, otherwise true/false based on whether the current window is the main window + * @returns true/false based on whether the current window is the main window */ -function isMainInstanceOrMultipleInstancesAllowed() { - if (settingsStore.get(settings.singleInstance)) { - const gotTheLock = app.requestSingleInstanceLock(); +function isMainInstance() { + return app.requestSingleInstanceLock(); +} - if (!gotTheLock) { - return false; - } +/** + * @returns true/false based on whether multiple instances are allowed + */ +function isMultipleInstancesAllowed() { + return !settingsStore.get(settings.singleInstance); +} + +/** + * @param args the arguments passed to the app + * @returns the custom protocol url if it exists, otherwise null + */ +function getCustomProtocolUrl(args: string[]) { + const customProtocolArg = args.find((arg) => arg.startsWith(PROTOCOL_PREFIX)); + + if (!customProtocolArg) { + return null; } - return true; + + return tidalUrl + "/" + customProtocolArg.substring(PROTOCOL_PREFIX.length + 3); } function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { @@ -98,8 +100,16 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { registerHttpProtocols(); syncMenuBarWithStore(); - // load the Tidal website - mainWindow.loadURL(tidalUrl); + // find the custom protocol argument + const customProtocolUrl = getCustomProtocolUrl(process.argv); + + if (customProtocolUrl) { + // load the url received from the custom protocol + mainWindow.loadURL(customProtocolUrl); + } else { + // load the Tidal website + mainWindow.loadURL(tidalUrl); + } if (settingsStore.get(settings.disableBackgroundThrottle)) { // prevent setInterval lag @@ -139,27 +149,32 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { } function registerHttpProtocols() { - protocol.registerHttpProtocol(PROTOCOL_PREFIX, (request) => { - mainWindow.loadURL(`${tidalUrl}/${request.url.substring(PROTOCOL_PREFIX.length + 3)}`); - }); if (!app.isDefaultProtocolClient(PROTOCOL_PREFIX)) { app.setAsDefaultProtocolClient(PROTOCOL_PREFIX); } } -function addGlobalShortcuts() { - Object.keys(mediaKeys).forEach((key) => { - globalShortcut.register(`${key}`, () => { - mainWindow.webContents.send("globalEvent", `${(mediaKeys as any)[key]}`); - }); - }); -} - // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on("ready", async () => { - if (isMainInstanceOrMultipleInstancesAllowed()) { + // check if the app is the main instance and multiple instances are not allowed + if (isMainInstance() && !isMultipleInstancesAllowed()) { + app.on("second-instance", (_, commandLine) => { + const customProtocolUrl = getCustomProtocolUrl(commandLine); + + if (customProtocolUrl) { + mainWindow.loadURL(customProtocolUrl); + } + + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + } + + if (isMainInstance() || isMultipleInstancesAllowed()) { await components.whenReady(); // Adblock @@ -174,12 +189,11 @@ app.on("ready", async () => { createWindow(); addMenu(mainWindow); createSettingsWindow(); - addGlobalShortcuts(); if (settingsStore.get(settings.trayIcon)) { addTray(mainWindow, { icon }); refreshTray(mainWindow); } - settingsStore.get(settings.api) && startExpress(mainWindow); + settingsStore.get(settings.api) && startApi(mainWindow); settingsStore.get(settings.enableDiscord) && initRPC(); } else { app.quit(); diff --git a/src/models/mediaInfo.ts b/src/models/mediaInfo.ts index fbcc3ce02..7a8776078 100644 --- a/src/models/mediaInfo.ts +++ b/src/models/mediaInfo.ts @@ -8,7 +8,9 @@ export interface MediaInfo { status: MediaStatus; url: string; current: string; + currentInSeconds?: number; duration: string; + durationInSeconds?: number; image: string; favorite: boolean; } diff --git a/src/models/options.ts b/src/models/options.ts index d909cf18f..1aece3478 100644 --- a/src/models/options.ts +++ b/src/models/options.ts @@ -5,7 +5,9 @@ export interface Options { status: string; url: string; current: string; + currentInSeconds: number; duration: string; + durationInSeconds: number; "app-name": string; image: string; icon: string; diff --git a/src/pages/settings/settings.html b/src/pages/settings/settings.html index 4bf2f5012..f27bd2856 100644 --- a/src/pages/settings/settings.html +++ b/src/pages/settings/settings.html @@ -433,7 +433,7 @@

Upload new themes

TIDAL Hi-Fi

5.10.0 + href="https://github.com/Mastermindzh/tidal-hifi/releases/tag/5.11.0">5.11.0