From db8a2c27417757f45afe3db1a323005b7f91829d Mon Sep 17 00:00:00 2001 From: Rick van Lieshout Date: Sun, 5 May 2024 20:12:10 +0200 Subject: [PATCH] feat: reworked the api, added duration/current in seconds + shuffle & repeat --- CHANGELOG.md | 4 ++ src/constants/globalEvents.ts | 2 + src/features/api/features/current.ts | 20 ++++++ src/features/api/features/player.ts | 36 ++++++++++ src/features/api/helpers/handleWindowEvent.ts | 12 ++++ src/features/api/index.ts | 31 +++++++++ src/features/api/legacy.ts | 47 +++++++++++++ src/features/time/parse.ts | 14 ++++ src/main.ts | 21 ++---- src/models/mediaInfo.ts | 2 + src/models/options.ts | 2 + src/preload.ts | 14 +++- src/scripts/discord.ts | 15 ++-- src/scripts/express.ts | 69 ------------------- src/scripts/mediaInfo.ts | 4 ++ 15 files changed, 199 insertions(+), 94 deletions(-) create mode 100644 src/features/api/features/current.ts create mode 100644 src/features/api/features/player.ts create mode 100644 src/features/api/helpers/handleWindowEvent.ts create mode 100644 src/features/api/index.ts create mode 100644 src/features/api/legacy.ts create mode 100644 src/features/time/parse.ts delete mode 100644 src/scripts/express.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b93f5..6220787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [next] +- 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] @@ -14,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#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] diff --git a/src/constants/globalEvents.ts b/src/constants/globalEvents.ts index 29ba175..c08e12e 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 0000000..8569114 --- /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 0000000..05c9e56 --- /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 0000000..907b143 --- /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 0000000..a2f1547 --- /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 0000000..866013f --- /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/time/parse.ts b/src/features/time/parse.ts new file mode 100644 index 0000000..2fe17d5 --- /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 551bcc7..f0c0064 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,9 @@ import { enable, initialize } from "@electron/remote/main"; -import { - app, - BrowserWindow, - components, - ipcMain, - session, -} from "electron"; +import { BrowserWindow, app, components, ipcMain, session } from "electron"; import path from "path"; import { globalEvents } from "./constants/globalEvents"; import { settings } from "./constants/settings"; +import { startApi } from "./features/api"; import { setDefaultFlags, setManagedFlagsFromSettings } from "./features/flags/flags"; import { acquireInhibitorIfInactive, @@ -19,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,7 +55,7 @@ function syncMenuBarWithStore() { * @returns true/false based on whether the current window is the main window */ function isMainInstance() { - return app.requestSingleInstanceLock(); + return app.requestSingleInstanceLock(); } /** @@ -82,7 +76,7 @@ function getCustomProtocolUrl(args: string[]) { return null; } - return tidalUrl + "/" + customProtocolArg.substring(PROTOCOL_PREFIX.length + 3) + return tidalUrl + "/" + customProtocolArg.substring(PROTOCOL_PREFIX.length + 3); } function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { @@ -109,7 +103,7 @@ function createWindow(options = { x: 0, y: 0, backgroundColor: "white" }) { // find the custom protocol argument const customProtocolUrl = getCustomProtocolUrl(process.argv); - if(customProtocolUrl) { + if (customProtocolUrl) { // load the url received from the custom protocol mainWindow.loadURL(customProtocolUrl); } else { @@ -164,10 +158,9 @@ function registerHttpProtocols() { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on("ready", async () => { - // check if the app is the main instance and multiple instances are not allowed if (isMainInstance() && !isMultipleInstancesAllowed()) { - app.on('second-instance', (_, commandLine) => { + app.on("second-instance", (_, commandLine) => { const customProtocolUrl = getCustomProtocolUrl(commandLine); if (customProtocolUrl) { @@ -200,7 +193,7 @@ app.on("ready", async () => { 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 fbcc3ce..7a87760 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 d909cf1..1aece34 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/preload.ts b/src/preload.ts index b1f6109..4127a7a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -12,6 +12,7 @@ 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"; @@ -318,6 +319,12 @@ function addIPCEventListeners() { case globalEvents.toggleFavorite: elements.click("favorite"); break; + case globalEvents.toggleShuffle: + elements.click("shuffle"); + break; + case globalEvents.toggleRepeat: + elements.click("repeat"); + break; default: break; } @@ -523,7 +530,9 @@ setInterval(function () { status: currentStatus, url: getTrackURL(), current, + currentInSeconds: convertDurationToSeconds(current), duration, + durationInSeconds: convertDurationToSeconds(duration), "app-name": appName, image: "", icon: "", @@ -560,7 +569,10 @@ setInterval(function () { }); } else { // just update the time - updateMediaInfo({ ...currentMediaInfo, ...{ current } }, false); + updateMediaInfo( + { ...currentMediaInfo, ...{ current, currentInSeconds: convertDurationToSeconds(current) } }, + false + ); } /** diff --git a/src/scripts/discord.ts b/src/scripts/discord.ts index 3f4ca6a..8e7c943 100644 --- a/src/scripts/discord.ts +++ b/src/scripts/discord.ts @@ -3,18 +3,13 @@ import { app, ipcMain } from "electron"; import { globalEvents } from "../constants/globalEvents"; import { settings } from "../constants/settings"; import { Logger } from "../features/logger"; +import { convertDurationToSeconds } from "../features/time/parse"; import { MediaStatus } from "../models/mediaStatus"; import { mediaInfo } from "./mediaInfo"; import { settingsStore } from "./settings"; const clientId = "833617820704440341"; -function timeToSeconds(timeArray: string[]) { - const minutes = parseInt(timeArray[0]) * 1; - const seconds = minutes * 60 + parseInt(timeArray[1]) * 1; - return seconds; -} - export let rpc: Client; const observer = () => { @@ -59,7 +54,7 @@ const getActivity = (): Presence => { return { includeTimestamps, detailsPrefix, buttonText }; } - function setPresenceFromMediaInfo(detailsPrefix: any, buttonText: any) { + function setPresenceFromMediaInfo(detailsPrefix: string, buttonText: string) { if (mediaInfo.url) { presence.details = `${detailsPrefix}${mediaInfo.title}`; presence.state = mediaInfo.artists ? mediaInfo.artists : "unknown artist(s)"; @@ -74,10 +69,10 @@ const getActivity = (): Presence => { } } - function includeTimeStamps(includeTimestamps: any) { + function includeTimeStamps(includeTimestamps: boolean) { if (includeTimestamps) { - const currentSeconds = timeToSeconds(mediaInfo.current.split(":")); - const durationSeconds = timeToSeconds(mediaInfo.duration.split(":")); + const currentSeconds = convertDurationToSeconds(mediaInfo.current); + const durationSeconds = convertDurationToSeconds(mediaInfo.duration); const date = new Date(); const now = (date.getTime() / 1000) | 0; const remaining = date.setSeconds(date.getSeconds() + (durationSeconds - currentSeconds)); diff --git a/src/scripts/express.ts b/src/scripts/express.ts deleted file mode 100644 index 2787fa6..0000000 --- a/src/scripts/express.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { BrowserWindow, dialog } from "electron"; -import express, { Response } from "express"; -import fs from "fs"; -import { settings } from "../constants/settings"; -import { MediaStatus } from "../models/mediaStatus"; -import { globalEvents } from "./../constants/globalEvents"; -import { mediaInfo } from "./mediaInfo"; -import { settingsStore } from "./settings"; - -/** - * Function to enable TIDAL Hi-Fi's express api - */ - -// expressModule.run = function (mainWindow) -export const startExpress = (mainWindow: BrowserWindow) => { - /** - * Shorthand to handle a fire and forget global event - * @param {*} res - * @param {*} action - */ - function handleGlobalEvent(res: Response, action: string) { - mainWindow.webContents.send("globalEvent", action); - res.sendStatus(200); - } - - const expressApp = express(); - expressApp.get("/", (req, res) => res.send("Hello World!")); - expressApp.get("/current", (req, res) => res.json({ ...mediaInfo, artist: mediaInfo.artists })); - expressApp.get("/image", (req, res) => { - const stream = fs.createReadStream(mediaInfo.icon); - stream.on("open", function () { - res.set("Content-Type", "image/png"); - stream.pipe(res); - }); - stream.on("error", function () { - res.set("Content-Type", "text/plain"); - res.status(404).end("Not found"); - }); - }); - - if (settingsStore.get(settings.playBackControl)) { - expressApp.get("/play", (req, res) => handleGlobalEvent(res, globalEvents.play)); - expressApp.post("/favorite/toggle", (req, res) => - handleGlobalEvent(res, globalEvents.toggleFavorite) - ); - expressApp.get("/pause", (req, res) => handleGlobalEvent(res, globalEvents.pause)); - expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next)); - expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous)); - expressApp.get("/playpause", (req, res) => { - if (mediaInfo.status === MediaStatus.playing) { - handleGlobalEvent(res, globalEvents.pause); - } else { - handleGlobalEvent(res, globalEvents.play); - } - }); - } - - const port = settingsStore.get(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/scripts/mediaInfo.ts b/src/scripts/mediaInfo.ts index 9158355..9cfada4 100644 --- a/src/scripts/mediaInfo.ts +++ b/src/scripts/mediaInfo.ts @@ -9,7 +9,9 @@ export const mediaInfo = { status: MediaStatus.paused as string, url: "", current: "", + currentInSeconds: 0, duration: "", + durationInSeconds: 0, image: "tidal-hifi-icon", favorite: false, }; @@ -22,7 +24,9 @@ export const updateMediaInfo = (arg: MediaInfo) => { 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; };