blastoise/docs/buildme.md

15 KiB

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 API contract in:

docs/api-reference.md

Paste This Prompt Into Your LLM

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:

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:

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:

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

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

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

Step 3: Load Library And Channels

Load:

GET /api/library
GET /api/channels

Store tracks in two forms:

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:

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:

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:

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

Apply state:

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:

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:

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:

PATCH /api/channels/:channelId/queue

Bodies:

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

Use REST for playback mode:

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:

GET /api/playlists

Render two lists:

  • mine
  • shared

Details:

GET /api/playlists/:playlistId

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

Common actions:

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:

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

To play next:

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

Step 9: Add Upload And URL Import

Upload:

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

Accepted file extensions:

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

URL import is optional. Show it only when:

status.ytdlp?.enabled && status.ytdlp?.available

Flow:

POST /api/fetch { url }

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

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

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

Poll:

GET /api/fetch

Listen for WebSocket progress messages:

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:

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.