A connection module for Bitfocus Companion and Bitfocus Buttons that talks to the TIDAL Developer Platform.
The Companion and Buttons connection ecosystems are shared (same @companion-module/base, same companion/manifest.json, same nodejs-ipc runtime), so a single module loads in both hosts. See companion/HELP.md for user-facing documentation rendered inside the app.
yarn install
yarn build # compile TypeScript → dist/
yarn dev # watch mode
yarn lint # eslint
yarn format # prettier
yarn package # build + create installable pkg via @companion-module/toolsThen point Companion or Buttons at this folder via the developer-modules path. See Setting up a Dev Folder for how to register a local module with a Companion or Buttons install.
companion/
manifest.json # Companion/Buttons module manifest (id: tidal)
HELP.md # user-facing help, rendered inside the app
src/
main.ts # InstanceBase entry point, lifecycle, OAuth callback handler
config.ts # connection config fields + defaults
tidal-auth.ts # OAuth 2.1 helpers (client credentials, PKCE, refresh)
tidal-api.ts # thin REST client for openapi.tidal.com/v2
actions.ts # search, load track/album/playlist, refresh, open URI
feedbacks.ts # boolean feedbacks (authenticated, explicit, has results)
variables.ts # exposed variable definitions
presets.ts # starter presets, grouped into sections
upgrades.ts # placeholder for future upgrade scripts
TIDAL's public Web API (openapi.tidal.com/v2) is JSON:API-shaped, cursor-paginated, and currently focuses on catalog data. Authorization uses OAuth 2.1 with PKCE on the authorization-code flow.
There is no /users/me endpoint in the v2 API (despite the v1-era convention). To list a user's owned playlists, the module decodes the JWT access token's sub claim to recover the user ID, then filters the catalog: GET /playlists?filter[owners.id]={userId}. Pagination follows the links.next cursor up to a hard cap (MAX_PAGES = 100, ≈ 2 000 entries).
User-library features (owned playlists, playlist track listing, search-result slots) are gated behind Authorization Code mode. In Client Credentials mode the dynamic dropdowns soft-degrade to "— Run Refresh user library first —" and the catalog actions remain fully functional.
If a future release adds public "now playing" or playback-control endpoints, extend src/tidal-api.ts and add corresponding actions/feedbacks rather than fanning out new files.
The module ships an end-to-end "discover → assign → play" flow for user playlists:
- Refresh user library action — fetches all your owned playlists in one call (paginated transparently), caches them, and re-emits both the
play_playlistdropdown and a "Your playlists" preset section so each playlist appears as a draggable preset. - Play playlist action — single dropdown of your cached playlists; runs
tidal://playlist/<id>through the OS handler. - Load playlist tracks into variables action — fetches up to 32 tracks of the chosen playlist and publishes them as
playlist_track_1_*…playlist_track_32_*variables. The "Current playlist tracks (live slots)" preset section comes alive with their titles, and a parallel "Playlist: " frozen-binding section (v0.5.1+) appears with one draggable preset per actual track. - Play search result (by index) — companion to Search catalog; each search additionally publishes the top 10 results as
last_search_result_N_*variables, a 10-button live-slot preset section, and (v0.5.1+) a "Last search: " frozen-binding section with one preset per actual result.
The preset library exposes the user's library data through two parallel UXes:
- Live slots (v0.5.0) — presets whose action URIs reference Companion variables like
$(tidal:playlist_track_5_uri). Companion re-resolves the variable on every press, so the button always plays whatever's currently in slot 5 of the most-recently-loaded playlist. Good for cue-stack workflows. - Frozen bindings (v0.5.1+) — presets where the literal
tidal://track/<id>URI is baked into the action options at preset-emission time. Dragging captures that specific track; the button keeps playing the same song forever, even after the user loads a different playlist or refreshes the library. Good for permanent "song X on key Y" assignments.
Both sections coexist in the preset library; pick whichever fits your show.
-
The manifest declares
runtime.permissions.child-process: truebecause the module spawns:open/start/xdg-openfor the Open URI in TIDAL desktop actions.osascript(macOS),powershell(Windows), orxdotool(Linux/X11) for the Playback: actions, which activate the TIDAL window and synthesise keyboard shortcuts.
Buttons enforces these declared permissions; without the flag, the spawns would be denied.
-
The previous module id
tidal-musicis listed inlegacyIds, so an existing connection from an earlier build will be auto-migrated.
TIDAL's public Web API does not expose a "play on device" endpoint comparable to Spotify Connect. The Playback: actions implement local control by automating the TIDAL desktop app on the same machine.
The behaviour is governed by a single connection-config setting — Playback control engine — with four options:
| Engine | What it does | Focus theft | Cross-platform |
|---|---|---|---|
Disabled |
Playback: actions log a warning and do nothing | n/a | n/a |
Focus + keystroke (default) |
Activates TIDAL window, sends the in-app keyboard shortcut | yes (briefly) | macOS / Windows / Linux (X11 native, Wayland best-effort) |
OS media keys |
Sends global media keys; non-focus-stealing | no | macOS (built-in via private MediaRemote, with nowplaying-cli fallback), Windows (built-in), Linux (redirects to playerctl) |
playerctl |
Targets TIDAL specifically over MPRIS | no | Linux only |
An additional checkbox — Restore previously focused app after each press — only applies to the Focus + keystroke engine and best-effort returns focus to the previous foreground app after the keystroke is delivered. (Ignored on Wayland — the Wayland path cannot reliably activate windows from outside.)
Every Playback: action also has a per-button Engine dropdown that defaults to Use connection config default. Set a specific engine on a single button if you want, e.g., a no-focus-stealing Play/Pause via OS media keys while the rest of your transport stays on Focus + keystroke.
- Focus + keystroke (cross-platform):
- macOS:
osascript+System Events, using key-codes for arrows/space andkeystroke "<letter>"for alphanumerics. - Windows: PowerShell +
WScript.Shell.AppActivate+SendKeys. - Linux X11:
xdotool search --name TIDAL windowactivate --sync key <combo>. - Linux Wayland (best-effort, added in 0.4.0): prefers
ydotoolwhen available (compositor-agnostic via kernel uinput — requires theydotoolddaemon running and the user in theinputgroup); falls back towtype(only injects to the currently focused window, so the user must have TIDAL focused at the time of the press). If neither tool is installed the engine reports a clear error and points atplayerctl.
- macOS:
- OS media keys:
- macOS (rewritten in 0.4.0): JXA script that
dlopens/System/Library/PrivateFrameworks/MediaRemote.frameworkand callsMRMediaRemoteSendCommanddirectly. No external dependencies. Supports Play/Pause, Next, Previous, Shuffle, Repeat (MRMediaRemoteCommand enum values 2, 4, 5, 6, 7). Transparently falls back tonowplaying-cliif installed and the JXA path ever fails. Volume/Seek/Mute log "not supported" — fall back toFocus + keystroke. - Windows: PowerShell P/Invoke to
user32.dll!keybd_eventwithVK_MEDIA_PLAY_PAUSE/VK_MEDIA_NEXT_TRACK/VK_MEDIA_PREV_TRACK/VK_VOLUME_UP/VK_VOLUME_DOWN/VK_VOLUME_MUTE. Seek/Shuffle/Repeat aren't Windows media keys, so they log "not supported". - Linux: transparently redirects to the
playerctlengine because Linux's equivalent of "global media keys" is the MPRIS bus.
- macOS (rewritten in 0.4.0): JXA script that
- playerctl: targets
playerctl --player=tidal-hifi,tidal,TIDALso it works against both the unofficial tidal-hifi Electron client and the official desktop app where it exposes MPRIS. Volume actions use0.05+/0.05-increments; seek actions use±10second offsets; Shuffle usesshuffle Toggle. Repeat cycle is intentionally unsupported viaplayerctlbecause the CLI has no Toggle verb forloop; the focus_keystroke engine is the recommended path for repeat.
The Focus + keystroke engine drives in-app shortcuts. Verified against publicly documented TIDAL desktop bindings (TutorialTactic, AudFree, DefKey, TuneSmake, CheatKeys) in May 2026:
| Action | Shortcut sent |
|---|---|
| Play / Pause | Space |
| Next track | Ctrl + → |
| Previous track | Ctrl + ← |
| Seek forward 10s | Ctrl + Shift + → |
| Seek backward 10s | Ctrl + Shift + ← |
| Volume up / down | Ctrl + ↑ / Ctrl + ↓ |
| Shuffle toggle | Ctrl + S |
| Repeat cycle | Ctrl + R |
| Toggle mute | no TIDAL shortcut — logs warning; use OS media keys (Windows) or playerctl (Linux) instead |
(Ctrl on Windows/Linux, Cmd on macOS for those marked Ctrl in TIDAL's docs — the module sends the platform-appropriate modifier.) If TIDAL changes any binding in a future update, override per button with the Send custom keyboard shortcut action.
All engines share the same Playback: actions in the UI — switching engines does not require re-wiring buttons.
MIT — see LICENSE.