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
isDashistrue, the URL is a blob: URL pointing to a DASH manifest — use dash.js to play it - If
isDashisfalse, the URL is a direct audio file URL — set it asaudio.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.js — UPTIME_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.js — searchTracks(), 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_URLSandFALLBACK_INSTANCESinjs/api.js - Search endpoint format changes (e.g.,
/search/?s=becomes/v2/search/?q=) → UpdatesearchTracks()injs/api.js - Track/stream endpoint changes → Update
getTrackStream()injs/api.js - Manifest format changes → Update
decodeManifest()injs/api.js - Cover URL pattern changes → Update
getCoverUrl()injs/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
- Run
node --check ./greyscale-app/js/api.jsto verify syntax - Start the server:
node server.js - Open
http://127.0.0.1:12345 - Search for a track and verify results appear
- Click a track and verify playback starts
- 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.js → apiSettings.loadInstancesFromGitHub() |
api.js → initInstances() |
| Search tracks | api.js → searchTracks() using /search/?s= |
api.js → searchTracks() |
| Get track stream | api.js → getTrack() using /track/?id=&quality= |
api.js → getTrackStream() |
| Manifest decode | api.js → extractStreamUrlFromManifest() |
api.js → decodeManifest() |
| Cover URL | api.js → getCoverUrl() using resources.tidal.com |
api.js → getCoverUrl() |
| Instance retry | api.js → fetchWithRetry() with random instance selection |
api.js → fetchFromPool() |