# 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(); ``` 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.