# Build Me A Blastoise Frontend

This is a pasteable build brief for an LLM or coding agent. It tells the agent
how to build a frontend for a Blastoise music server without needing to read the
server code.

Reference the short API contract in:

```text
docs/api-reference.md
```

Use the full reference for edge cases:

```text
docs/api-reference-full.md
```

## Paste This Prompt Into Your LLM

```text
You are building a frontend for Blastoise, a synchronized music streaming
server. Build the actual app, not a landing page.

Use the Blastoise API documented in docs/api-reference.md. The server owns
channel state and time. The client owns UI, local audio playback, local caching,
and drift correction.

Reference server for testing:
- HTTP base URL: http://mhsgroove.peterino.com:3001
- WebSocket base URL: ws://mhsgroove.peterino.com:3001

Core rule:
- Always identify tracks by track.id.
- Always play audio from /api/tracks/:trackId.
- filename and title are display fields only.

Build an app with:
- Auth screen: login, signup, and guest mode when /api/status says guests are
  allowed.
- Channel list: load /api/channels, show listener counts, connect to a channel
  WebSocket, support switching channels.
- Now playing player: show current track, time, duration, play/pause, seek,
  previous/next, playback mode.
- Library: list tracks from /api/library, search/filter, click a track to play
  locally, add tracks to queue.
- Queue: render the current channel queue, highlight currentIndex, add/remove,
  move/reorder when the user has control permission.
- Playlists: list /api/playlists, show playlist details, add playlists/tracks
  to queue, create/edit/delete owned playlists.
- Optional URL import UI if /api/status reports ytdlp.enabled and
  ytdlp.available.

Do not assume the WebSocket always includes queue. It includes queue on connect,
after queue changes, and periodic refreshes. Keep the last known queue until a
new queue arrives.

Do not use alert() or prompt(). Use inline inputs, modals, toasts, or standard
UI components.

Auth uses an HttpOnly cookie named blastoise_session. If this app is served
from the same origin as the server, browser fetch calls can use relative URLs.
If this app is hosted separately, either proxy API requests through the same
origin or add CORS/credentials support to the server.

Implement robust API helpers that handle JSON errors and plain text errors.
Some Blastoise endpoints return JSON error objects, while some return plain
text.

Synced playback algorithm:
1. Connect to WS /api/channels/:channelId/ws.
2. When a normal ChannelState message arrives, store it with performance.now().
3. If state.queue exists, replace the local queue cache.
4. If state.track is null, pause and clear the player.
5. If track.id changed, set audio.src to /api/tracks/:trackId, seek to
   state.currentTimestamp, then play unless state.paused.
6. If track.id is the same and abs(audio.currentTime - state.currentTimestamp)
   >= 2, seek to state.currentTimestamp.
7. If state.paused, pause locally. If not paused, play locally.
8. Between WebSocket updates, estimate server time as
   state.currentTimestamp + elapsedSeconds since receipt, unless paused.

Control actions:
- Send WebSocket { action: "pause" } and { action: "unpause" } for play/pause.
- Send WebSocket { action: "seek", timestamp } for seek.
- Send WebSocket { action: "jump", index } for queue jumps.
- Send WebSocket { action: "switch", channelId } to switch channels.
- Use REST PATCH /api/channels/:channelId/queue for add/remove/move/set queue.
- Use REST POST /api/channels/:channelId/mode for playback mode.

Use track.id for local caching. If you build caching, store complete audio blobs
in IndexedDB under track.id. Range requests to /api/tracks/:trackId are
supported.

Make the interface responsive. Desktop can use panels for Channels, Library,
Queue, and Playlists. Mobile should use tabs or a single-panel navigation.
```

## Implementation Order

Follow this order. It keeps the project useful from the first milestone and
prevents sync bugs from getting buried under UI.

### Step 1: Create The API Client

Build a small wrapper around `fetch`.

Requirements:

- Use relative URLs when the frontend is same-origin.
- Allow an `API_BASE` override for native or separately hosted builds.
- Send `credentials: "include"` for browser fetch calls.
- Parse successful JSON.
- On errors, try JSON first, then fall back to text.
- Expose helpers for JSON, form upload, and raw audio URLs.

Recommended shape:

```ts
const API_BASE = "";

async function apiJson(path: string, options: RequestInit = {}) {
  const res = await fetch(API_BASE + path, {
    credentials: "include",
    ...options,
    headers: {
      ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
      ...(options.headers || {}),
    },
  });

  const text = await res.text();
  let data: any = null;
  if (text) {
    try {
      data = JSON.parse(text);
    } catch {
      data = text;
    }
  }

  if (!res.ok) {
    const message =
      typeof data === "object" && data
        ? data.error || data.message || `HTTP ${res.status}`
        : data || `HTTP ${res.status}`;
    throw new Error(message);
  }

  return data;
}

function trackUrl(trackId: string) {
  return `${API_BASE}/api/tracks/${encodeURIComponent(trackId)}`;
}
```

Native apps should store `Set-Cookie` from login/signup/me and send it as
`Cookie` in later HTTP and WebSocket requests.

### Step 2: Load Status And Session

On app start:

```text
GET /api/status
GET /api/auth/me
```

Use `/api/status` to decide whether to show:

- guest mode,
- signup,
- URL import.

Use `/api/auth/me` to get the user and effective permissions. When guests are
enabled, this call can create a guest session.

Auth actions:

```text
POST /api/auth/login   { username, password }
POST /api/auth/signup  { username, password }
POST /api/auth/logout
```

After login, signup, logout, or guest creation, reload:

```text
GET /api/auth/me
GET /api/library
GET /api/channels
GET /api/playlists
```

### Step 3: Load Library And Channels

Load:

```text
GET /api/library
GET /api/channels
```

Store tracks in two forms:

```ts
const library: Track[] = [];
const tracksById = new Map<string, Track>();
```

Pick the channel:

1. Last saved channel ID if still present.
2. The channel with `isDefault: true`.
3. The first channel.

Then connect the WebSocket.

### Step 4: Build WebSocket State Handling

Connect:

```ts
function wsUrl(channelId: string) {
  const base = API_BASE || window.location.origin;
  const url = new URL(base);
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
  url.pathname = `/api/channels/${encodeURIComponent(channelId)}/ws`;
  return url.toString();
}
```

Handle message types:

```ts
function onSocketMessage(data: any) {
  if (data.type === "channel_list") {
    setChannels(data.channels);
    return;
  }

  if (data.type === "switched") {
    setCurrentChannelId(data.channelId);
    return;
  }

  if (data.type === "kick") {
    disconnectAndShowLoginOrToast(data.reason);
    return;
  }

  if (data.type === "toast") {
    showToast(data.message, data.toastType);
    return;
  }

  if (data.type === "scan_progress") {
    updateScanProgress(data);
    return;
  }

  if (typeof data.type === "string" && data.type.startsWith("fetch_")) {
    updateFetchTask(data);
    return;
  }

  applyChannelState(data);
}
```

Reconnect while the user wants sync. Use a short delay such as 2 or 3 seconds.

### Step 5: Implement The Player Correctly

Keep this state:

```ts
let channelState: ChannelState | null = null;
let channelStateReceivedAt = 0;
let currentTrackId: string | null = null;
let queue: Track[] = [];
```

Apply state:

```ts
async function applyChannelState(state: ChannelState) {
  channelState = state;
  channelStateReceivedAt = performance.now();

  if (state.queue) queue = state.queue;

  if (!state.track) {
    audio.pause();
    currentTrackId = null;
    return;
  }

  const target = state.currentTimestamp;
  const nextTrackId = state.track.id;

  if (nextTrackId !== currentTrackId) {
    currentTrackId = nextTrackId;
    audio.src = getPlayableUrl(nextTrackId);
    audio.currentTime = target;
  } else if (Math.abs(audio.currentTime - target) >= 2) {
    audio.currentTime = target;
  }

  if (state.paused) {
    audio.pause();
  } else {
    audio.play().catch(() => showClickToPlay());
  }
}
```

Estimate current synced time for progress UI:

```ts
function syncedTime() {
  if (!channelState?.track) return 0;
  if (channelState.paused) return channelState.currentTimestamp;
  return channelState.currentTimestamp + (performance.now() - channelStateReceivedAt) / 1000;
}
```

Use the audio element's actual `currentTime` while audio is playing, but use
`syncedTime()` while waiting to play, paused, reconnecting, or rendering remote
state.

### Step 6: Add Controls

Use WebSocket for simple channel controls:

```ts
ws.send(JSON.stringify({ action: "pause" }));
ws.send(JSON.stringify({ action: "unpause" }));
ws.send(JSON.stringify({ action: "seek", timestamp }));
ws.send(JSON.stringify({ action: "jump", index }));
ws.send(JSON.stringify({ action: "switch", channelId }));
```

Use REST for queue mutation:

```text
PATCH /api/channels/:channelId/queue
```

Bodies:

```json
{ "add": ["sha256:track"], "insertAt": 3 }
{ "remove": [2] }
{ "move": [5], "to": 1 }
{ "set": ["sha256:a", "sha256:b"] }
```

Use REST for playback mode:

```text
POST /api/channels/:channelId/mode
{ "mode": "shuffle" }
```

If a control returns `403`, show a permission toast. Guests can listen but
cannot control.

### Step 7: Render Library, Queue, And Local Playback

Library:

- Render `/api/library`.
- Search over title, filename, artist, and album.
- Add selected tracks to queue with `PATCH /api/channels/:id/queue`.
- Play a track locally by setting the audio source to `/api/tracks/:trackId`
  and disconnecting or marking the player unsynced.

Queue:

- Render the last known `queue`.
- Highlight `currentIndex`.
- Jump by index.
- Remove by index.
- Reorder by index.
- Remember that duplicate track IDs can exist in the queue. Queue operations
  that remove or move tracks must use positions, not IDs.

Local playback:

- It is okay to let users preview/play a single track outside channel sync.
- Keep this mode visually distinct from synced playback.
- Offer a "sync" button to reconnect to the selected channel.

### Step 8: Add Playlists

Load:

```text
GET /api/playlists
```

Render two lists:

- `mine`
- `shared`

Details:

```text
GET /api/playlists/:playlistId
```

Join `playlist.trackIds` with `tracksById` from the library to render track
titles.

Common actions:

```text
POST   /api/playlists
PATCH  /api/playlists/:id
DELETE /api/playlists/:id
PATCH  /api/playlists/:id/tracks
POST   /api/playlists/:id/share
DELETE /api/playlists/:id/share
POST   /api/playlists/shared/:token
```

To add a playlist to queue:

```json
{ "add": ["sha256:a", "sha256:b"] }
```

To play next:

```json
{ "add": ["sha256:a", "sha256:b"], "insertAt": currentIndex + 1 }
```

### Step 9: Add Upload And URL Import

Upload:

```text
POST /api/upload
multipart/form-data field: file
```

Accepted file extensions:

```text
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
```

URL import is optional. Show it only when:

```ts
status.ytdlp?.enabled && status.ytdlp?.available
```

Flow:

```text
POST /api/fetch { url }
```

If response is `type: "single"`, show a queued/download task.

If response is `type: "playlist"`, show a confirmation modal, then:

```text
POST /api/fetch/confirm { playlistTitle, items }
```

Poll:

```text
GET /api/fetch
```

Listen for WebSocket progress messages:

```text
fetch_progress
fetch_complete
fetch_error
fetch_cancelled
```

### Step 10: Add Optional Local Caching

Caching is not needed for a valid frontend, but it is one of Blastoise's best
features.

Use IndexedDB:

```ts
interface CachedTrack {
  id: string;
  blob: Blob;
  contentType: string;
}
```

Rules:

- Key by `track.id`.
- Never key by filename.
- Prefer cached blob URLs for playback.
- Fall back to `/api/tracks/:trackId`.
- Use range requests to prefetch seek segments if you want a buffer bar.
- Revoke blob URLs when replacing or deleting cached blobs.

Simple mode:

1. When a user plays a track, fetch the full file in the background.
2. Store it in IndexedDB under `track.id`.
3. Next time, play from `URL.createObjectURL(blob)`.

Advanced mode:

1. Divide each track into virtual segments.
2. Use `Range: bytes=start-end` requests to fill missing segments.
3. When all segments are present, download and persist the full blob.

### Step 11: Validate The App

Manual smoke test:

1. Start the server with `bun run server.ts`.
2. Open the frontend.
3. Load status and auth state.
4. Continue as guest or log in with the test user if configured.
5. Load library and channels.
6. Connect to the default channel WebSocket.
7. Confirm first WebSocket state includes `queue`.
8. Confirm audio source uses `/api/tracks/:trackId`.
9. Seek locally after a state update and confirm drift correction snaps back.
10. Pause/unpause from one client and confirm another client follows.
11. Add a track to queue and confirm both clients receive a state with `queue`.
12. Switch channels and confirm the server sends `switched`.
13. Test mobile layout.

Permission smoke test:

1. Use a guest session.
2. Confirm listening works.
3. Try pause/seek/jump.
4. Confirm the UI reports lack of permission or no-ops gracefully.

Playlist smoke test:

1. Create a playlist as a non-guest user.
2. Add tracks to it.
3. Add the playlist to queue.
4. Make it public or generate a share token.
5. Load it through the shared endpoint.

## Common Pitfalls

| Symptom | Likely Cause |
|---|---|
| Audio 404s | The app used `filename` instead of `track.id` in `/api/tracks/:id`. |
| Queue disappears after a state update | The client replaced queue with `undefined`; WebSocket queue is optional. |
| Sync slowly drifts | The client only uses local audio time and does not correct against server timestamps. |
| Guests can see controls that do nothing | Guests cannot control playback even if they can listen. |
| Queue remove deletes the wrong duplicate | The UI removed by track ID instead of queue position. |
| Login works in same-origin dev but not hosted frontend | Cookie auth needs same-origin, a reverse proxy, or CORS with credentials. |
| Shared playlist copy fails | The route is `POST /api/playlists/shared/:token`, with no `/copy` suffix. |
| Native WebSocket connects as guest after login | The client did not send the stored session cookie in the WebSocket request. |

## Minimal Viable Scope

If you want the smallest useful Blastoise frontend, build only:

- `GET /api/auth/me`
- `GET /api/library`
- `GET /api/channels`
- `WS /api/channels/:id/ws`
- `GET /api/tracks/:trackId`
- WebSocket actions: `switch`, `pause`, `unpause`, `seek`, `jump`

That is enough to make a synchronized player.
