Compare commits

..

15 Commits
0.1.0 ... 1.0

Author SHA1 Message Date
d05d8f48b6 v1.0 (#6) 2020-02-01 21:14:34 +01:00
Matthieu Le brazidec
30844cb51b Updated the express api to only listen on localhost (#5) 2020-02-01 20:55:05 +01:00
a59cc16f34 Update README.md 2020-01-18 12:10:49 +01:00
52b32c0783 added arch (AUR) PKGBUILD files
Package is visible on: https://aur.archlinux.org/packages/tidal-hifi-git/
2019-12-02 22:55:10 +01:00
José Augusto Bolina
0636c8b92f added individual build scripts. (#1)
* added individual build scripts.

- squashed + changed commit messages.
2019-12-01 23:02:59 +01:00
94ed652619 added a custom menu and enabled the menu by default 2019-11-03 20:22:59 +01:00
f8aa97c15e added settings 2019-11-03 18:52:15 +01:00
6ef4a0854d now using electron-store to save settings between launches 2019-11-03 11:18:01 +01:00
3d334cd19e removed pacman target because github actions' ubuntu vm can't build pacman images. 2019-11-01 22:57:25 +01:00
896ed577f6 installed express and added github (CI) actions 2019-11-01 22:52:08 +01:00
bb49c112db added player status and worked on getting the integrations working with i3: 9714b2fa1d 2019-10-30 23:42:08 +01:00
d7dab07845 added express endpoints, a settings service and a media info service 2019-10-30 22:49:04 +01:00
e5dd8cb87a added temp tray and renamed mediaKeysModule 2019-10-30 21:43:52 +01:00
f389f1d6a2 added new icons 2019-10-30 21:34:26 +01:00
a56d18e414 added media keys + notifications 2019-10-22 21:25:57 +02:00
34 changed files with 2304 additions and 169 deletions

37
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Build CI
on:
push:
branches-ignore:
- master
- develop
jobs:
build_on_linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build
build_on_mac:
runs-on: macOS-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build
build_on_win:
runs-on: windows-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build

49
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Release CI
on:
push:
branches:
- master
- develop
jobs:
build_on_linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build
- uses: actions/upload-artifact@master
with:
name: linux-builds
path: dist/
build_on_mac:
runs-on: macOS-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build
- uses: actions/upload-artifact@master
with:
name: mac-builds
path: dist/
build_on_win:
runs-on: windows-latest
steps:
- uses: actions/checkout@master
- uses: actions/setup-node@master
with:
node-version: 12
- run: npm install
- run: npm run build
- uses: actions/upload-artifact@master
with:
name: windows-builds
path: dist/

7
.gitignore vendored
View File

@@ -1,2 +1,9 @@
node_modules
dist
# ignore all build files except for the .desktop and PKGBUILD files
build/linux/arch/*
!build/linux/arch/PKGBUILD
!build/linux/arch/.SRCINFO
!build/linux/arch/tidal-hifi.desktop
!build/linux/arch/install.sh

View File

@@ -1,17 +1,22 @@
<img src = "./build/icon.png" height="50" style="float:right; margin-top: 29px;" />
# Tidal-hifi
<h1>
Tidal-hifi
<img src = "./build/icon.png" height="40" align="right" />
</h1>
The web version of [listen.tidal.com](listen.tidal.com) running in electron with hifi support thanks to widevine.
![tidal-hifi preview](./docs/preview.png)
## Table of contents
<!-- toc -->
- [Installation](#installation)
- [Using releases](#using-releases)
- [Using source](#using-source)
- [Why](#why)
- [features](#features)
- [Integrations](#integrations)
- [Why](#why)
- [Why not extend existing projects?](#why-not-extend-existing-projects)
- [Special thanks to..](#special-thanks-to)
@@ -23,6 +28,14 @@ The web version of [listen.tidal.com](listen.tidal.com) running in electron with
Various packaged versions of the software are available on the [releases](https://github.com/Mastermindzh/tidal-hifi/releases) tab.
### Arch Linux
Arch Linux users can use the AUR to install tidal-hifi:
```sh
trizen tidal-hifi
```
### Using source
To install and work with the code on this project follow these steps:
@@ -32,16 +45,28 @@ To install and work with the code on this project follow these steps:
- npm install
- npm start
## features
- HiFi playback
- Notifications
- Shortcuts ([source](https://defkey.com/tidal-desktop-shortcuts))
- API for status and playback
- [Settings feature](./docs/settings.png) to disable certain functionality. (`ctrl+/`)
- Tray(/mini) player (coming soon)
## Integrations
- [i3 blocks config](https://github.com/Mastermindzh/dotfiles/commit/9714b2fa1d670108ce811d5511fd3b7a43180647) - My dotfiles where I use this app to fetch currently playing music (direct commit)
### Known bugs
- [Last.fm login doesn't work](https://github.com/Mastermindzh/tidal-hifi/issues/4).
## Why
I moved from Spotify over to Tidal and found Linux support to be lacking.
When I started this project there weren't any Linux apps that offered Tidal's "hifi" options nor any scripts to control it.
## Integrations
- [i3 blocks config]() - My dotfiles where I use this app to fetch currently playing music
## Why not extend existing projects?
Whilst there are a handful of projects attempting to run Tidal on Electron they are all unappealing to me because of various reasons:
@@ -49,7 +74,7 @@ Whilst there are a handful of projects attempting to run Tidal on Electron they
- Lack of a maintainers/developers. (no hotfixes, no issues being handled etc)
- Most are simple web wrappers, not my cup of tea.
- Some are DE oriented. I want this to work on WM's too.
- None have widevine working at the moment and that is really the hardest part..
- None have widevine working at the moment
Sometimes it's just easier to start over, cover my own needs and then making it available to the public :)

BIN
assets/icon-inverted.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -0,0 +1,6 @@
extends: ./build/electron-builder.yml
linux:
category: Audio
icon: ./assets/icon.png
target:
- deb

View File

@@ -0,0 +1,6 @@
extends: ./build/electron-builder.yml
linux:
category: Audio
icon: ./assets/icon.png
target:
- pacman

View File

@@ -0,0 +1,6 @@
extends: ./build/electron-builder.yml
linux:
category: Audio
icon: ./assets/icon.png
target:
- snap

View File

@@ -1,4 +1,6 @@
appId: com.rickvanlieshout.tidal-hifi
electronDownload:
mirror: https://github.com/castlabs/electron-releases/releases/download/v
snap:
plugs:
- default
@@ -6,7 +8,7 @@ snap:
linux:
category: Audio
target:
- pacman
# - pacman
- tar.gz
- deb
- AppImage
@@ -14,4 +16,4 @@ linux:
mac:
category: public.app-category.entertainment
win:
target: msi
target: msi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 102 KiB

19
build/linux/arch/.SRCINFO Normal file
View File

@@ -0,0 +1,19 @@
pkgbase = tidal-hifi-git
pkgdesc = The web version of listen.tidal.com running in electron with hifi support thanks to widevine.
pkgver = 0.5
pkgrel = 1
url = https://github.com/Mastermindzh/tidal-hifi
arch = x86_64
license = custom:MIT
makedepends = npm
makedepends = git
depends = libxss
depends = nss
depends = gtk3
provides = tidal-hifi
source = https://github.com/Mastermindzh/tidal-hifi/archive/0.5.zip
source = tidal-hifi.desktop
sha512sums = a25a9189a10aa35a62ad41299792909b0ac6547544802ef9d1f58d6d0bff75b4d364975c81d5a4d73eabf64bdb772c3823c3b3cd58540d40acaedf6594033f61
sha512sums = fa5fa918ea890baa5f500db3153a6eff3d63966528ffa3349acda3ea02fbecb1ea78a1ba1d23ef7402de2228fc0a483252e0b7e72c73cfb25ed401bedaf856f5
pkgname = tidal-hifi-git

56
build/linux/arch/PKGBUILD Normal file
View File

@@ -0,0 +1,56 @@
# Maintainer: Rick van Lieshout <info@rickvanlieshout.com>
_pkgname=tidal-hifi
pkgname="$_pkgname"
pkgver=0.5
pkgrel=1
pkgdesc="The web version of listen.tidal.com running in electron with hifi support thanks to widevine."
arch=("x86_64")
url="https://github.com/Mastermindzh/tidal-hifi"
license=("custom:MIT")
depends=("libxss" "nss" "gtk3")
makedepends=("npm" "git")
provides=("$_pkgname")
source=("https://github.com/Mastermindzh/tidal-hifi/archive/$pkgver.zip"
"${_pkgname}.desktop")
sha512sums=('a25a9189a10aa35a62ad41299792909b0ac6547544802ef9d1f58d6d0bff75b4d364975c81d5a4d73eabf64bdb772c3823c3b3cd58540d40acaedf6594033f61'
'fa5fa918ea890baa5f500db3153a6eff3d63966528ffa3349acda3ea02fbecb1ea78a1ba1d23ef7402de2228fc0a483252e0b7e72c73cfb25ed401bedaf856f5')
cdToPkg(){
cd "tidal-hifi-$pkgver"
}
prepare() {
cdToPkg
# install build dependencies
npm install
}
build() {
cdToPkg
# We are not using the systems Electron as we need castlab's Electron.
npx electron-builder --linux dir
}
package() {
cdToPkg
install -d "${pkgdir}/opt/${_pkgname}/" "${pkgdir}/usr/bin" "${pkgdir}/usr/share/doc" "${pkgdir}/usr/share/licenses"
cp -r dist/linux-unpacked/* "${pkgdir}/opt/${_pkgname}/"
chmod +x "${pkgdir}/opt/${_pkgname}/${_pkgname}"
ln -s "/opt/${_pkgname}/${_pkgname}" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm 644 "build/icon.png" "${pkgdir}/usr/share/pixmaps/${_pkgname}.png"
install -Dm 644 "${srcdir}/${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm 644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
install -Dm 644 "LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
ln -s "/opt/${_pkgname}/LICENSE.electron.txt" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.electron.txt"
ln -s "/opt/${_pkgname}/LICENSES.chromium.html" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSES.chromium.html"
}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Will generate a correctly formatted SRCINFO file
SCRIPT_DIST=".SRCINFO"
# generate SRCINFO
makepkg --printsrcinfo > $SCRIPT_DIST
# replace pkgbase with tidal-hifi-git
pkgName="tidal-hifi-git"
sed -i "1s/.*/pkgbase = $pkgName/" $SCRIPT_DIST
# replace pkgbase with tidal-hifi-git
sed -i '/^pkgname/ d' $SCRIPT_DIST
echo "pkgname = $pkgName" >> $SCRIPT_DIST
# remove double line breaks and replace with single line breaks
sed -i '/^$/N;/^\n$/D' $SCRIPT_DIST

View File

@@ -0,0 +1,12 @@
[Desktop Entry]
Encoding=UTF-8
Name=tidal-hifi
GenericName=tidal-hifi
Comment=The web version of listen.tidal.com running in electron with hifi support thanks to widevine.
Exec=tidal-hifi %u
Icon=tidal-hifi.png
StartupNotify=true
Terminal=false
Type=Application
Categories=Network;Application;Audio;Video
StartupWMClass=tidal-hifi

BIN
docs/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

1123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,16 @@
{
"name": "tidal-hifi",
"version": "0.1.0",
"version": "1.0.0",
"description": "Tidal on Electron with widevine(hifi) support",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder -c ./build/electron-builder.yml"
"build": "electron-builder -c ./build/electron-builder.yml",
"build-deb": "electron-builder -c ./build/electron-builder.deb.yml",
"build-snap": "electron-builder -c ./build/electron-builder.snap.yml",
"build-arch": "electron-builder -c ./build/electron-builder.pacman.yml",
"build-wl": "electron-builder -c ./build/electron-builder.yml -wl",
"build-mac": "electron-builder -c ./build/electron-builder.yml -m"
},
"keywords": [
"electron",
@@ -16,11 +21,15 @@
"author": "Rick van Lieshout <info@rickvanlieshout.com> (http://rickvanlieshout.com)",
"license": "MIT",
"dependencies": {
"hotkeys-js": "^3.7.1"
"electron-store": "^5.1.0",
"express": "^4.17.1",
"hotkeys-js": "^3.7.1",
"node-notifier": "^6.0.0",
"request": "^2.88.0"
},
"devDependencies": {
"@mastermindzh/prettier-config": "^1.0.0",
"electron": "https://github.com/castlabs/electron-releases#v6.0.12-wvvmp",
"electron": "https://github.com/castlabs/electron-releases#v6.1.0-wvvmp",
"electron-builder": "^21.2.0",
"electron-reload": "^1.5.0",
"prettier": "^1.18.2"

View File

@@ -0,0 +1,15 @@
const globalEvents = {
play: "play",
pause: "pause",
playPause: "playPause",
next: "next",
previous: "previous",
updateInfo: "update-info",
hideSettings: "hideSettings",
showSettings: "showSettings",
updateStatus: "update-status",
storeChanged: "storeChanged",
error: "error",
};
module.exports = globalEvents;

View File

@@ -0,0 +1,9 @@
const globalEvents = require("./globalEvents");
const mediaKeys = {
MediaPlayPause: globalEvents.playPause,
MediaNextTrack: globalEvents.next,
MediaPreviousTrack: globalEvents.previous,
};
module.exports = mediaKeys;

27
src/constants/settings.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Object to type my settings file:
*
* notifications: true,
* api: true,
* apiSettings: {
* port: 47836,
* },
* windowBounds: { width: 800, height: 600 },
*/
const settings = {
notifications: "notifications",
api: "api",
menuBar: "menuBar",
playBackControl: "playBackControl",
apiSettings: {
root: "apiSettings",
port: "apiSettings.port",
},
windowBounds: {
root: "windowBounds",
width: "windowBounds.width",
height: "windowBounds.height",
},
};
module.exports = settings;

View File

@@ -0,0 +1,4 @@
module.exports = {
playing: "playing",
paused: "paused",
};

View File

@@ -1,7 +1,24 @@
const { app, BrowserWindow } = require("electron");
const { app, BrowserWindow, globalShortcut, ipcMain } = require("electron");
const {
settings,
store,
createSettingsWindow,
showSettingsWindow,
closeSettingsWindow,
hideSettingsWindow,
} = require("./scripts/settings");
const { addTray, refreshTray } = require("./scripts/tray");
const { addMenu } = require("./scripts/menu");
const path = require("path");
const tidalUrl = "https://listen.tidal.com";
const expressModule = require("./scripts/express");
const mediaKeys = require("./constants/mediaKeys");
const mediaInfoModule = require("./scripts/mediaInfo");
const globalEvents = require("./constants/globalEvents");
let mainWindow;
let icon = path.join(__dirname, "../assets/icon.png");
/**
* Enable live reload in development builds
@@ -19,15 +36,18 @@ function createWindow(options = {}) {
y: options.y,
width: 1024,
height: 800,
icon,
tray: true,
backgroundColor: options.backgroundColor,
webPreferences: {
affinity: "window",
preload: path.join(__dirname, "preload.js"),
plugins: true,
devTools: !app.isPackaged,
},
});
mainWindow.setMenuBarVisibility(false);
mainWindow.setMenuBarVisibility(store.get(settings.menuBar));
// load the Tidal website
mainWindow.loadURL(tidalUrl);
@@ -37,24 +57,35 @@ function createWindow(options = {}) {
// Emitted when the window is closed.
mainWindow.on("closed", function() {
mainWindow = null;
closeSettingsWindow();
app.quit();
});
mainWindow.on("resize", () => {
let { width, height } = mainWindow.getBounds();
store.set(settings.windowBounds.root, { width, height });
});
}
function addGlobalShortcuts() {
// globalShortcut.register("Control+A", () => {
// dialog.showErrorBox("test", "test");
// // mainWindow.webContents.send("getPlayInfo");
// });
Object.keys(mediaKeys).forEach((key) => {
globalShortcut.register(`${key}`, () => {
mainWindow.webContents.send("globalEvent", `${mediaKeys[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", () => {
// window with white backround
createWindow();
addMenu();
createSettingsWindow();
addGlobalShortcuts();
addTray({ icon });
refreshTray();
store.get(settings.api) && expressModule.run(mainWindow);
});
app.on("activate", function() {
@@ -64,3 +95,27 @@ app.on("activate", function() {
createWindow();
}
});
// IPC
ipcMain.on(globalEvents.updateInfo, (event, arg) => {
mediaInfoModule.update(arg);
});
ipcMain.on(globalEvents.hideSettings, (event, arg) => {
hideSettingsWindow();
});
ipcMain.on(globalEvents.showSettings, (event, arg) => {
showSettingsWindow();
});
ipcMain.on(globalEvents.updateStatus, (event, arg) => {
mediaInfoModule.updateStatus(arg);
});
ipcMain.on(globalEvents.storeChanged, (event, arg) => {
mainWindow.setMenuBarVisibility(store.get(settings.menuBar));
});
ipcMain.on(globalEvents.error, (event, arg) => {
console.log(event);
});

BIN
src/pages/settings/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -0,0 +1,86 @@
let notifications;
let playBackControl;
let api;
let port;
let menuBar;
const { store, settings } = require("./../../scripts/settings");
const { ipcRenderer } = require("electron");
const globalEvents = require("./../../constants/globalEvents");
/**
* Sync the UI forms with the current settings
*/
function refreshSettings() {
notifications.checked = store.get(settings.notifications);
playBackControl.checked = store.get(settings.playBackControl);
api.checked = store.get(settings.api);
port.value = store.get(settings.apiSettings.port);
menuBar.checked = store.get(settings.menuBar);
}
/**
* Open an url in the default browsers
*/
window.openExternal = function(url) {
const { shell } = require("electron");
shell.openExternal(url);
};
/**
* hide the settings window
*/
window.hide = function() {
ipcRenderer.send(globalEvents.hideSettings);
};
/**
* Restart tidal-hifi after changes
*/
window.restart = function() {
const remote = require("electron").remote;
remote.app.relaunch();
remote.app.exit(0);
};
/**
* Bind UI components to functions after DOMContentLoaded
*/
window.addEventListener("DOMContentLoaded", () => {
function get(id) {
return document.getElementById(id);
}
function addInputListener(source, key) {
source.addEventListener("input", function(event, data) {
if (this.value === "on") {
store.set(key, source.checked);
} else {
store.set(key, this.value);
}
ipcRenderer.send(globalEvents.storeChanged);
});
}
ipcRenderer.on("refreshData", () => {
refreshSettings();
});
ipcRenderer.on("goToTab", (event, tab) => {
document.getElementById(tab).click();
});
notifications = get("notifications");
playBackControl = get("playBackControl");
api = get("apiCheckbox");
port = get("port");
menuBar = get("menuBar");
refreshSettings();
addInputListener(notifications, settings.notifications);
addInputListener(playBackControl, settings.playBackControl);
addInputListener(api, settings.api);
addInputListener(port, settings.apiSettings.port);
addInputListener(menuBar, settings.menuBar);
});

View File

@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
</head>
<body>
<div class="header">
<h1 class="title">Settings</h1>
<a href="javascript:hide();" class="exitWindow">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 348.333 348.334">
<g>
<path fill="white" d="M336.559,68.611L231.016,174.165l105.543,105.549c15.699,15.705,15.699,41.145,0,56.85
c-7.844,7.844-18.128,11.769-28.407,11.769c-10.296,0-20.581-3.919-28.419-11.769L174.167,231.003L68.609,336.563
c-7.843,7.844-18.128,11.769-28.416,11.769c-10.285,0-20.563-3.919-28.413-11.769c-15.699-15.698-15.699-41.139,0-56.85
l105.54-105.549L11.774,68.611c-15.699-15.699-15.699-41.145,0-56.844c15.696-15.687,41.127-15.687,56.829,0l105.563,105.554
L279.721,11.767c15.705-15.687,41.139-15.687,56.832,0C352.258,27.466,352.258,52.912,336.559,68.611z" />
</svg>
</a>
</div>
<div class="body">
<div class="tabset">
<!-- Tab 1 -->
<input type="radio" name="tabset" id="tab1" checked />
<label for="tab1">General</label>
<!-- Tab 2 -->
<input type="radio" name="tabset" id="tab2" />
<label for="tab2">Api</label>
<!-- Tab 3 -->
<input type="radio" name="tabset" id="tab3" />
<label for="tab3">About</label>
<div class="tab-panels">
<section id="general" class="tab-panel">
<div class="section">
<h3>Playback</h3>
<div class="option">
<h4>Notifications</h4>
<p>
Whether to show a notification when a new song starts.
</p>
<label class="switch">
<input id="notifications" type="checkbox">
<span class="slider round"></span>
</label>
</div>
</div>
<div class="section">
<h3>UI</h3>
<div class="option">
<h4>Menubar</h4>
<p>
Show Tidal-hifi's menu bar
</p>
<label class="switch">
<input id="menuBar" type="checkbox">
<span class="slider round"></span>
</label>
</div>
</div>
</section>
<section id="api" class="tab-panel">
<div class="section">
<h3>Api</h3>
<p style="margin-bottom: 15px;">
Tidal-hifi has a web api built in to allow users to get current song information. You can optionally enable playback control as well.
<br />
<br />
<small>* api changes require a restart to update</small>
</p>
<div class="option">
<h4>Web API</h4>
<p>
Whether to enable the Tidal-hifi web api
</p>
<label class="switch">
<input id="apiCheckbox" type="checkbox">
<span class="slider round"></span>
</label>
</div>
<div class="option">
<h4 style="margin-bottom: 5px;">API port</h4>
<input id="port" type="text" class="freeTextInput" name="port">
</div>
<div class="option">
<h4>Playback control</h4>
<p>
Whether to enable playback control from the api
</p>
<label class="switch">
<input id="playBackControl" type="checkbox">
<span class="slider round"></span>
</label>
</div>
</div>
<button onClick="restart()">Restart Tidal-hifi</button>
</section>
<section id="general" class="tab-panel">
<div class="section">
<img style="width: 100px; height: auto; display: block; margin: 0 auto; margin-bottom: 20px; margin-top: 20px;" src = "./icon.png">
<p style="max-width: 350px; display:block; margin: 0 auto; text-align: center;">
<a href ="javascript:openExternal('https://github.com/Mastermindzh/tidal-hifi');">Tidal-hifi</a> is made by <a href ="javascript:openExternal('https://www.rickvanlieshout.com')">Rick van Lieshout</a>.<br />
It uses <a href="javascript:openExternal('https://castlabs.com/');">castlabs</a> versions of Electron for widevine support.
</p>
</div>
</section>
</div>
</div>
</div>
</body>
<style>
.header {
-webkit-user-select: none;
-webkit-app-region: drag;
}
.header a {
-webkit-app-region: no-drag;
}
* {
margin: 0%;
padding: 0%;
color: #ffffff;
font-weight: 400;
font-stretch: normal;
-webkit-font-smoothing: antialiased;
font-family: nationale, nationale-regular, Helvetica, sans-serif;
}
html,
body {
height: 100%;
background-color: black;
display: flex;
flex-direction: column;
}
h2 {
font-size: 1.2rem;
}
small {
font-style: italic;
color: #72777f;
}
.header {
background-color: #242528;
border-bottom: 1px solid #5a5a5a;
height: 50px;
}
.title {
float: left;
line-height: 50px;
margin-left: 15px;
}
.accent {
color: #0ff;
}
.exitWindow {
border: none;
text-decoration: none;
font-size: 1.4rem;
float: right;
margin-right: 15px;
height: 50px;
line-height: 50px;
}
.exitWindow svg {
height: 50px;
color: white;
}
.section {
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(246, 245, 255, .1);
}
.section .option {
margin-bottom: 15px;
}
.section .option p {
max-width: 75%;
float: left
}
.section .option label {
float: right;
}
.section:after,
.section .option:after {
content: "";
display: table;
clear: both;
}
.section h3 {
margin-bottom: 15px;
}
.section h4 {
font-size: 0.9rem;
}
.section p {
color: #72777f;
}
.bottom-border {
border-bottom: 1px solid #0ff;
}
.body {
padding: 15px;
flex: 1 1 auto;
position: relative;
overflow-y: auto;
}
.body::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 5px;
background-color: 2a2a2a;
}
.body::-webkit-scrollbar {
width: 10px;
background-color: #2a2a2a;
}
.body::-webkit-scrollbar-thumb {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .3);
box-shadow: inset 0 0 6px rgba(0, 0, 0, .3);
background-color: #5a5a5a;
}
/* Tabs */
.tabset > input[type="radio"] {
position: absolute;
left: -200vw;
}
.tabset .tab-panel {
display: none;
}
.tabset > input:first-child:checked ~ .tab-panels > .tab-panel:first-child,
.tabset > input:nth-child(3):checked ~ .tab-panels > .tab-panel:nth-child(2),
.tabset > input:nth-child(5):checked ~ .tab-panels > .tab-panel:nth-child(3) {
display: block;
}
.tabset > label {
position: relative;
display: inline-block;
padding: 15px 0px 10px;
border-bottom: 0;
cursor: pointer;
}
.tabset > input + label {
color: #e0e0e0;
margin-right: 30px;
}
.tabset > input:checked + label {
color: #0ff;
border-bottom: 2px solid #0ff;
}
.tab-panel {
padding: 10px 0;
}
/* switches */
/* The switch - the box around the slider */
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 28px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(246, 245, 255, .1);
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 24px;
width: 24px;
left: 2px;
bottom: 2px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: #0ff;
}
input:focus + .slider {
box-shadow: 0 0 1px #0ff;
}
input:checked + .slider:before {
-webkit-transform: translateX(22px);
-ms-transform: translateX(22px);
transform: translateX(22px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* input field */
input {
background: transparent;
border: 0;
border-bottom: 1px solid rgba(246, 245, 255, .1);
color: rgba(229, 238, 255, .6);
width: 100%;
display: block;
padding: 0 0 12px;
}
.freeTextInput:focus {
outline: none;
border-bottom: 1px solid #0ff;
}
/* buttons */
button{
border:none;
background:none;
align-items: center;
background-color: rgba(229,238,255,.2);
display: inline-flex;
justify-content: center;
border-radius: 12px;
height: 48px;
line-height: 49px;
padding: 0 24px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background .35s ease;
min-height: 0;
min-width: 0;
font-size: 1.14286rem;
font-family: nationale,nationale-regular,Helvetica,sans-serif;
margin-top: 10px;
cursor: pointer;
}
button:hover{
background-color: rgba(229,238,255,.3);
}
</style>
</html>

View File

@@ -1,6 +1,15 @@
const { setTitle, getTitle } = require("./scripts/window-functions");
const { dialog } = require("electron").remote;
const { store, settings } = require("./scripts/settings");
const { ipcRenderer } = require("electron");
const { app } = require("electron").remote;
const { downloadFile } = require("./scripts/download");
const statuses = require("./constants/statuses");
const hotkeys = require("./scripts/hotkeys");
const globalEvents = require("./constants/globalEvents");
const notifier = require("node-notifier");
const notificationPath = `${app.getPath("userData")}/notification.jpg`;
let currentSong = "";
const elements = {
play: '*[data-test="play"]',
@@ -18,19 +27,55 @@ const elements = {
block: '[class="blockButton"]',
account: '*[data-test^="profile-image-button"]',
settings: '*[data-test^="open-settings"]',
media: '*[data-test="current-media-imagery"]',
image: '*[class^="image--"]',
/**
* Get an element from the dom
* @param {*} key key in elements object to fetch
*/
get: function(key) {
return window.document.querySelector(this[key.toLowerCase()]);
},
getText: function(key) {
return this.get(key).textContent;
/**
* 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;
}
}
return "";
},
/**
* Shorthand function to get the text of a dom element
* @param {*} key key in elements object to fetch
*/
getText: function(key) {
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) {
this.get(key).click();
return this;
},
/**
* Shorthand function to focus a dom element
* @param {*} key key in elements object to fetch
*/
focus: function(key) {
return this.get(key).focus();
},
@@ -102,6 +147,10 @@ function addHotKeys() {
hotkeys.add("control+r", function() {
elements.click("repeat");
});
hotkeys.add("control+/", function() {
ipcRenderer.send(globalEvents.showSettings);
});
}
/**
@@ -141,25 +190,86 @@ function handleLogout() {
*/
function addIPCEventListeners() {
window.addEventListener("DOMContentLoaded", () => {
const { ipcRenderer } = require("electron");
ipcRenderer.on("getPlayInfo", (event, col) => {
alert(`${elements.getText("title")} - ${elements.getText("artists")}`);
ipcRenderer.on("globalEvent", (event, args) => {
switch (args) {
case globalEvents.playPause:
playPause();
break;
case globalEvents.next:
elements.click("next");
break;
case globalEvents.previous:
elements.click("previous");
break;
case globalEvents.play:
elements.click("play");
break;
case globalEvents.pause:
elements.click("pause");
break;
}
});
});
}
/**
* Update window title
* Update the current status of tidal (e.g playing or paused)
*/
function updateStatus() {
const play = elements.get("play");
let status = statuses.paused;
// if play button is NOT visible tidal is playing
if (!play) {
status = statuses.playing;
}
ipcRenderer.send(globalEvents.updateStatus, status);
}
/**
* Watch for song changes and update title + notify
*/
setInterval(function() {
const title = elements.getText("title");
const artists = elements.getText("artists");
const songDashArtistTitle = `${title} - ${artists}`;
updateStatus();
if (getTitle() !== songDashArtistTitle) {
setTitle(songDashArtistTitle);
if (currentSong !== songDashArtistTitle) {
currentSong = songDashArtistTitle;
const image = elements.getSongIcon();
const options = {
title,
message: artists,
};
new Promise((resolve, reject) => {
if (image.startsWith("http")) {
downloadFile(image, notificationPath).then(
() => {
options.icon = notificationPath;
resolve();
},
() => {
reject();
}
);
} else {
reject();
}
}).then(
() => {
ipcRenderer.send(globalEvents.updateInfo, options);
store.get(settings.notifications) && notifier.notify(options);
},
() => {}
);
}
}
}, 1000);
}, 200);
addHotKeys();
addIPCEventListeners();

26
src/scripts/download.js Normal file
View File

@@ -0,0 +1,26 @@
const download = {};
const request = require("request");
const fs = require("fs");
/**
* download and save a file
* @param {*} fileUrl url to download
* @param {*} targetPath path to save it at
*/
download.downloadFile = function(fileUrl, targetPath) {
return new Promise((resolve, reject) => {
var req = request({
method: "GET",
uri: fileUrl,
});
var out = fs.createWriteStream(targetPath);
req.pipe(out);
req.on("end", resolve);
req.on("error", reject);
});
};
module.exports = download;

70
src/scripts/express.js Normal file
View File

@@ -0,0 +1,70 @@
const express = require("express");
const { mediaInfo } = require("./mediaInfo");
const { store, settings } = require("./settings");
const globalEvents = require("./../constants/globalEvents");
const statuses = require("./../constants/statuses");
const expressModule = {};
const fs = require("fs");
let expressInstance;
/**
* Function to enable tidal-hifi's express api
*/
expressModule.run = function(mainWindow) {
/**
* Shorthand to handle a fire and forget global event
* @param {*} res
* @param {*} action
*/
function handleGlobalEvent(res, action) {
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));
expressApp.get("/image", (req, res) => {
var 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 (store.get(settings.playBackControl)) {
expressApp.get("/play", (req, res) => handleGlobalEvent(res, globalEvents.play));
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 == statuses.playing) {
handleGlobalEvent(res, globalEvents.pause);
} else {
handleGlobalEvent(res, globalEvents.play);
}
});
}
if (store.get(settings.api)) {
let port = store.get(settings.apiSettings.port);
expressInstance = expressApp.listen(port, "127.0.0.1", () => {});
expressInstance.on("error", function(e) {
let message = e.code;
if (e.code === "EADDRINUSE") {
message = `Port ${port} in use.`;
}
const { dialog } = require("electron");
dialog.showErrorBox("Api failed to start.", message);
});
} else {
expressInstance = undefined;
}
};
module.exports = expressModule;

40
src/scripts/mediaInfo.js Normal file
View File

@@ -0,0 +1,40 @@
const statuses = require("./../constants/statuses");
const mediaInfo = {
title: "",
artist: "",
icon: "",
status: statuses.paused,
};
const mediaInfoModule = {
mediaInfo,
};
/**
* Update artist and song info in the mediaInfo constant
*/
mediaInfoModule.update = function(arg) {
mediaInfo.title = propOrDefault(arg.title);
mediaInfo.artist = propOrDefault(arg.message);
mediaInfo.icon = propOrDefault(arg.icon);
};
/**
* Update tidal's status in the mediaInfo constant
*/
mediaInfoModule.updateStatus = function(status) {
if (Object.values(statuses).includes(status)) {
mediaInfo.status = status;
}
};
/**
* Return the property or a default value
* @param {*} prop property to check
* @param {*} defaultValue defaults to ""
*/
function propOrDefault(prop, defaultValue = "") {
return prop ? prop : defaultValue;
}
module.exports = mediaInfoModule;

107
src/scripts/menu.js Normal file
View File

@@ -0,0 +1,107 @@
const { Menu } = require("electron");
const { showSettingsWindow } = require("./settings");
const isMac = process.platform === "darwin";
const settingsMenuEntry = {
label: "Settings",
click() {
showSettingsWindow();
},
accelerator: "Control+/",
};
const mainMenu = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" },
settingsMenuEntry,
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideothers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
]
: []),
// { role: 'fileMenu' }
{
label: "File",
submenu: [settingsMenuEntry, isMac ? { role: "close" } : { role: "quit" }],
},
// { role: 'editMenu' }
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
...(isMac
? [
{ role: "pasteAndMatchStyle" },
{ role: "delete" },
{ role: "selectAll" },
{ type: "separator" },
{
label: "Speech",
submenu: [{ role: "startspeaking" }, { role: "stopspeaking" }],
},
]
: [{ role: "delete" }, { type: "separator" }, { role: "selectAll" }]),
{ type: "separator" },
settingsMenuEntry,
],
},
// { role: 'viewMenu' }
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forcereload" },
{ type: "separator" },
{ role: "resetzoom" },
{ role: "zoomin" },
{ role: "zoomout" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
// { role: 'windowMenu' }
{
label: "Window",
submenu: [
{ role: "minimize" },
...(isMac
? [{ type: "separator" }, { role: "front" }, { type: "separator" }, { role: "window" }]
: [{ role: "close" }]),
],
},
settingsMenuEntry,
{
label: "About",
click() {
showSettingsWindow("tab3");
},
},
];
const menuModule = { mainMenu };
menuModule.getMenu = function() {
return Menu.buildFromTemplate(mainMenu);
};
menuModule.addMenu = function() {
Menu.setApplicationMenu(menuModule.getMenu());
};
module.exports = menuModule;

70
src/scripts/settings.js Normal file
View File

@@ -0,0 +1,70 @@
const Store = require("electron-store");
const settings = require("./../constants/settings");
const path = require("path");
const { BrowserWindow } = require("electron");
let settingsWindow;
const store = new Store({
defaults: {
notifications: true,
api: true,
playBackControl: true,
menuBar: true,
apiSettings: {
port: 47836,
},
windowBounds: { width: 800, height: 600 },
},
});
const settingsModule = {
store,
settings,
settingsWindow,
};
settingsModule.createSettingsWindow = function() {
settingsWindow = new BrowserWindow({
width: 500,
height: 600,
show: false,
frame: false,
title: "Tidal-hifi - settings",
webPreferences: {
affinity: "window",
preload: path.join(__dirname, "../pages/settings/preload.js"),
plugins: true,
nodeIntegration: true,
},
});
settingsWindow.on("close", (event) => {
if (settingsWindow != null) {
event.preventDefault();
settingsWindow.hide();
}
});
settingsWindow.loadURL(`file://${__dirname}/../pages/settings/settings.html`);
settingsModule.settingsWindow = settingsWindow;
};
settingsModule.showSettingsWindow = function(tab = "tab1") {
settingsWindow.webContents.send("goToTab", tab);
// refresh data just before showing the window
settingsWindow.webContents.send("refreshData");
settingsWindow.show();
};
settingsModule.hideSettingsWindow = function() {
settingsWindow.hide();
};
settingsModule.closeSettingsWindow = function() {
settingsWindow = null;
};
module.exports = settingsModule;

19
src/scripts/tray.js Normal file
View File

@@ -0,0 +1,19 @@
const { Tray } = require("electron");
const { getMenu } = require("./menu");
const trayModule = {};
let tray;
trayModule.addTray = function(options = { icon: "" }) {
tray = new Tray(options.icon);
};
trayModule.refreshTray = function() {
tray.on("click", function(e) {
// do nothing on click
});
tray.setToolTip("Tidal-hifi");
tray.setContextMenu(getMenu());
};
module.exports = trayModule;