Files
2026-04-17 16:52:51 -04:00
..
2026-04-17 16:52:51 -04:00
2026-04-17 16:52:51 -04:00
2026-04-17 16:52:51 -04:00
2026-04-17 16:52:51 -04:00
2026-04-17 16:52:51 -04:00

Greyscale App

Minimal vanilla JS library for searching and playing lossless music via Tidal-compatible API instances. Zero dependencies (except dash.js CDN for DASH stream playback). No build tools, no npm, no frameworks.

Files

File Purpose Dependencies Reusable?
js/api.js Search tracks, resolve stream URLs, get cover art URLs, manage API instances None Yes — this is the core library
js/player.js Wrap <audio> element + dash.js for playback with queue, prev/next dashjs global (CDN) Yes — optional playback helper
js/app.js Wire search UI → API → player for this PoC demo api.js, player.js No — PoC-specific glue code
index.html Demo page with search box, results list, player bar All of the above No — PoC demo only
style.css Dark theme styling for the demo None No — PoC demo only

Using in Another Project

Minimum: Just the API (1 file)

Copy js/api.js into your project. It has zero imports and works in any browser ES module context.

<script type="module">
    import { GreyscaleAPI } from './js/api.js';

    const api = new GreyscaleAPI();

    // Load API instances (call once on startup)
    await api.initInstances();

    // Search for tracks
    const tracks = await api.searchTracks('Bohemian Rhapsody');
    console.log(tracks);
    // Each track: { id, title, artist, duration, cover, albumTitle, raw }

    // Get a stream URL for a track
    const { streamUrl, isDash } = await api.getTrackStream(tracks[0].id, 'HI_RES_LOSSLESS');
    // streamUrl: direct FLAC/MP3 URL or blob: URL for DASH manifest
    // isDash: true if it's a DASH manifest (needs dash.js to play)

    // Get cover art URL
    const coverUrl = api.getCoverUrl(tracks[0].cover, 320);
    // Returns: https://resources.tidal.com/images/.../320x320.jpg
</script>

With Playback: API + Player (2 files)

Copy js/api.js and js/player.js. Load dash.js from CDN.

<audio id="audio-player"></audio>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script type="module">
    import { GreyscaleAPI } from './js/api.js';
    import { SimplePlayer } from './js/player.js';

    const api = new GreyscaleAPI();
    await api.initInstances();

    const player = new SimplePlayer({
        audio: document.getElementById('audio-player'),
        progressEl: document.getElementById('progress'),       // optional range input
        currentTimeEl: document.getElementById('current-time'), // optional span
        durationEl: document.getElementById('duration'),        // optional span
    });

    // Set up stream resolver
    player.setResolver(async (track) => {
        return api.getTrackStream(track.id, 'HI_RES_LOSSLESS');
    });

    // Search and play
    const tracks = await api.searchTracks('Bohemian Rhapsody');
    player.setQueue(tracks, 0);
    await player.playCurrent();

    // Controls
    player.togglePlayPause();
    player.playNext();
    player.playPrev();
</script>

API Reference

GreyscaleAPI

new GreyscaleAPI()

Creates a new API instance with fallback Tidal instances pre-configured.

async initInstances()

Fetches the latest list of healthy API instances from uptime workers. Call once on startup. Falls back to hardcoded instances if fetch fails.

async searchTracks(query)

Search for tracks. Returns an array of track objects:

{
    id: 12345,              // Tidal track ID
    title: 'Song Name',
    artist: 'Artist Name',
    duration: 245,          // seconds
    cover: 'uuid-string',  // pass to getCoverUrl()
    albumTitle: 'Album',
    raw: { ... }            // full Tidal API response object
}

async getTrackStream(id, quality)

Get a playable stream URL for a track ID.

Quality options: 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'

Returns: { streamUrl: string, isDash: boolean }

  • If isDash is true, the URL is a blob: URL pointing to a DASH manifest — use dash.js to play it
  • If isDash is false, the URL is a direct audio file URL — set it as audio.src

getCoverUrl(coverId, size)

Convert a cover ID to a Tidal CDN image URL.

  • size: pixel dimension (e.g., 160, 320, 640, 1280)

SimplePlayer

new SimplePlayer({ audio, progressEl, currentTimeEl, durationEl })

  • audio: an <audio> DOM element (required)
  • progressEl: a <input type="range"> for progress scrubbing (optional)
  • currentTimeEl: a DOM element to display current time (optional)
  • durationEl: a DOM element to display total duration (optional)

setResolver(fn)

Set the async function that resolves a track object to { streamUrl, isDash }.

setQueue(tracks, startIndex)

Set the playback queue and starting position.

async playCurrent(resolverOverride?)

Play the current track in the queue.

async playNext() / async playPrev()

Advance or rewind in the queue and play.

async togglePlayPause()

Toggle audio playback.

onTrackChanged

Callback property — set to a function that receives the new track object when playback changes.


Running the PoC Demo

node server.js
# Open http://127.0.0.1:12345

Or without the server script:

cd greyscale-app
python3 -m http.server 8080
# Open http://127.0.0.1:8080

Upstream Sync: Keeping Up with Monochrome

The monochrome/ directory is a git submodule pointing to github.com/monochrome-music/monochrome. It serves as the reference implementation — we don't import from it at runtime, but we use it to track upstream API changes.

For Agents: Automated Upstream Sync Procedure

When tasked with updating this project to match upstream Monochrome changes, follow these steps:

Step 1: Pull Latest Upstream

git submodule update --remote --merge monochrome

Step 2: Check for API-Relevant Changes

Only these upstream files affect our code:

Upstream File What to Check Our File Affected
monochrome/js/storage.js Instance URLs (lines 4-7), fallback instances (lines 60-83) js/api.jsUPTIME_URLS and FALLBACK_INSTANCES
monochrome/js/api.js Search endpoints (lines 325-415), track/stream endpoint (lines 1075-1109), manifest decoding (lines 266-289), cover URL format js/api.jssearchTracks(), getTrackStream(), decodeManifest(), getCoverUrl()
monochrome/js/qobuz-api.js Qobuz search/stream patterns (if adding Qobuz support) Not yet implemented

Run a targeted diff:

git -C monochrome diff HEAD~10..HEAD -- js/storage.js js/api.js js/qobuz-api.js

Step 3: Classify the Change

  • Instance URL changes (uptime worker URLs, fallback instance list) → Update UPTIME_URLS and FALLBACK_INSTANCES in js/api.js
  • Search endpoint format changes (e.g., /search/?s= becomes /v2/search/?q=) → Update searchTracks() in js/api.js
  • Track/stream endpoint changes → Update getTrackStream() in js/api.js
  • Manifest format changes → Update decodeManifest() in js/api.js
  • Cover URL pattern changes → Update getCoverUrl() in js/api.js
  • Internal refactors (caching, retry logic, error handling) → Usually no action needed
  • New features (new search types, new providers) → Optionally add to js/api.js

Step 4: Apply Changes

All changes go into one file: js/api.js. The player module (js/player.js) is independent of upstream.

Step 5: Validate

  1. Run node --check ./greyscale-app/js/api.js to verify syntax
  2. Start the server: node server.js
  3. Open http://127.0.0.1:12345
  4. Search for a track and verify results appear
  5. Click a track and verify playback starts
  6. Test next/prev buttons

Step 6: Commit

git add monochrome greyscale-app/js/api.js
git commit -m "Sync with upstream monochrome: [describe changes]"

Key Patterns to Watch

These are the specific code patterns in upstream that our js/api.js mirrors:

Pattern Upstream Location Our Implementation
Instance list fetch storage.jsapiSettings.loadInstancesFromGitHub() api.jsinitInstances()
Search tracks api.jssearchTracks() using /search/?s= api.jssearchTracks()
Get track stream api.jsgetTrack() using /track/?id=&quality= api.jsgetTrackStream()
Manifest decode api.jsextractStreamUrlFromManifest() api.jsdecodeManifest()
Cover URL api.jsgetCoverUrl() using resources.tidal.com api.jsgetCoverUrl()
Instance retry api.jsfetchWithRetry() with random instance selection api.jsfetchFromPool()