586 lines
15 KiB
Markdown
586 lines
15 KiB
Markdown
# 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.
|