# 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.

```html
<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.

```html
<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:
```js
{
    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

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

Or without the server script:
```bash
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](https://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

```bash
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:
```bash
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

```bash
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()` |
