diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..68a485e --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,1515 @@ +# Blastoise API Reference + +Blastoise is a synchronized music server. The server does not play or decode +audio. It keeps channel time, queue order, user/session state, playlists, and +track files. Frontends connect over HTTP and WebSocket, then play audio locally. + +The most important rule for every client is: + +```text +Render metadata from the API, but play audio by track.id. +GET /api/tracks/:trackId is the canonical audio URL. +``` + +## Base URL + +Default local server: + +```text +HTTP: http://localhost:3001 +WS: ws://localhost:3001 +``` + +Production frontends should normally be served from the same origin as the +Blastoise server, or placed behind a reverse proxy at the same origin. The +current server does not add CORS headers, and auth is cookie-based. + +For separate-origin browser apps, add CORS and credential support on the server +or proxy the API through the frontend origin. + +## Client Mental Model + +1. Call `GET /api/status` to learn server features. +2. Call `GET /api/auth/me` to get the current user or create a guest session if + guests are enabled. +3. Call `GET /api/library` to load track metadata. +4. Call `GET /api/channels` to find the default or saved channel. +5. Connect to `WS /api/channels/:id/ws`. +6. On channel state, load `/api/tracks/:track.id`, seek to + `currentTimestamp`, and play or pause according to `paused`. + +The server advances channel time by comparing: + +```text +currentTimestamp = (Date.now() - channel.startedAt) / 1000 +``` + +Clients should treat server state as the source of truth for synced playback. + +## Authentication + +Auth uses an HttpOnly cookie named `blastoise_session`. + +Same-origin browser `fetch()` calls send this cookie automatically. If you add +CORS for a separate-origin app, use: + +```js +fetch(apiBase + "/api/auth/me", { credentials: "include" }); +``` + +Native apps should store the `Set-Cookie` header and send it back as a `Cookie` +header on HTTP and WebSocket requests. + +Guest behavior: + +- If `config.allowGuests` is true, many authenticated endpoints will + automatically create a guest user and return `Set-Cookie`. +- Guests can listen. +- Guests cannot control channel playback. +- Guests cannot create channels, playlists, copy shared playlists, or fetch + tracks from URLs. + +Permissions: + +- Admin users can do everything. +- `control` permission is required for synced playback controls and queue + mutation. +- Server `defaultPermissions` can grant channel permissions globally. +- Guests never receive effective `control` permission. + +Error responses are mixed in the current implementation. Most API errors are +JSON like `{ "error": "Message" }`, but some control endpoints return plain +text such as `Forbidden`, `Not found`, or `Invalid JSON`. Clients should handle +both. + +## Common Types + +### User + +```ts +interface User { + id: number; + username: string; + isAdmin: boolean; + isGuest?: boolean; +} +``` + +`isGuest` is included by `GET /api/auth/me`. Login and signup responses include +`id`, `username`, and `isAdmin`. + +### Permission + +```ts +interface Permission { + id: number; + user_id: number; + resource_type: string; // "channel" or "global" + resource_id: string | null; + permission: string; // "control", "admin", etc. +} +``` + +### Track + +Track IDs are content-addressed strings. Treat them as opaque strings that +currently start with `sha256:`. + +```ts +interface Track { + id: string; // canonical content hash, use for API/cache + filename: string; // display/fallback only + title: string | null; + artist?: string | null; + album?: string | null; + duration: number; // seconds + replayGainDb?: number | null; + replayPeak?: number | null; + available?: boolean; +} +``` + +Use `track.title || track.filename` for display. Use `track.id` for every API +call, queue operation, local cache key, and audio URL. + +### ChannelInfo + +Returned by `GET /api/channels` and `channel_list` WebSocket messages. + +```ts +interface ChannelInfo { + id: string; + name: string; + description: string; + trackCount: number; + listenerCount: number; + listeners: string[]; + isDefault: boolean; + createdBy: number | null; +} +``` + +### ChannelState + +Returned by `GET /api/channels/:id` and sent over WebSocket. + +```ts +interface ChannelState { + track: Track | null; + currentTimestamp: number; // seconds + channelName: string; + channelId: string; + description: string; + paused: boolean; + currentIndex: number; + listenerCount: number; + isDefault: boolean; + playbackMode: "once" | "repeat-all" | "repeat-one" | "shuffle"; + queue?: Track[]; // included on WS connect, queue changes, + // and periodic refreshes +} +``` + +The `queue` field is optional. Keep the last known queue until a state message +includes a new one. + +### Playlist + +```ts +interface Playlist { + id: string; + name: string; + description: string; + ownerId: number; + ownerName?: string; // included by detail/shared endpoints + isPublic: boolean; + shareToken: string | null; + trackIds: string[]; + createdAt: number; // Unix seconds + updatedAt: number; // Unix seconds +} +``` + +Playlist track lists store `track.id` values, not embedded track objects. +Join against `/api/library` on the client when rendering playlist contents. + +### QueueItem + +Used by the optional URL fetch/import API. + +```ts +interface QueueItem { + id: string; + url: string; + title: string; + userId: number; + status: "queued" | "downloading" | "complete" | "error" | "cancelled"; + progress: number; // 0..100 + queueType: "fast" | "slow"; + error?: string; + filename?: string; + createdAt: number; // milliseconds for in-memory queue items + completedAt?: number; + playlistId?: string; + playlistName?: string; + position?: number; + trackId?: string; +} +``` + +## Status + +### GET /api/status + +Public server status and feature flags. + +Response: + +```json +{ + "name": "MusicRoom", + "version": "1.0.0", + "allowGuests": true, + "allowSignups": true, + "channelCount": 1, + "defaultPermissions": ["control"], + "ytdlp": { + "available": true, + "enabled": true, + "version": "2026.01.01", + "ffmpeg": true + } +} +``` + +## Auth Endpoints + +### POST /api/auth/signup + +Create an account and start a session. The first non-guest user becomes admin. + +Body: + +```json +{ + "username": "test", + "password": "testuser" +} +``` + +Rules: + +- `username` is required and must be at least 3 characters. +- `password` is required and must be at least 6 characters. +- Username must be unique. + +Response `200`: + +```json +{ + "user": { + "id": 1, + "username": "test", + "isAdmin": true + } +} +``` + +Sets `blastoise_session`. + +Common errors: + +- `400` username/password missing, too short, or username taken +- `500` signup failed + +### POST /api/auth/login + +Start a session for an existing user. + +Body: + +```json +{ + "username": "test", + "password": "testuser" +} +``` + +Response `200`: + +```json +{ + "user": { + "id": 1, + "username": "test", + "isAdmin": false + } +} +``` + +Sets `blastoise_session`. + +Common errors: + +- `401` invalid username or password +- `500` login failed + +### POST /api/auth/logout + +Delete the current session and clear the session cookie. + +Response: + +```json +{ + "success": true +} +``` + +### GET /api/auth/me + +Get the current user. If guests are enabled and the request has no valid +session, this endpoint creates a guest session. + +Response with user: + +```json +{ + "user": { + "id": 2, + "username": "guest_ab12cd34", + "isAdmin": false, + "isGuest": true + }, + "permissions": [ + { + "id": 0, + "user_id": 2, + "resource_type": "channel", + "resource_id": null, + "permission": "listen" + } + ] +} +``` + +Response when no user and guests are disabled: + +```json +{ + "user": null +} +``` + +### POST /api/auth/kick-others + +Send a WebSocket `kick` message to all active connections for the current user. + +Requires an authenticated user. + +Response: + +```json +{ + "kicked": 2 +} +``` + +Common errors: + +- `401` not authenticated + +## Admin Endpoints + +### GET /api/admin/users + +List all non-guest users. + +Requires global `admin` permission. + +Response: + +```json +[ + { + "id": 1, + "username": "admin", + "is_admin": true, + "is_guest": false, + "created_at": 1760000000 + } +] +``` + +### POST /api/admin/users/:userId/permissions + +Grant a permission. + +Requires global `admin` permission. + +Body: + +```json +{ + "resourceType": "channel", + "resourceId": "default", + "permission": "control" +} +``` + +Use `resourceId: null` for a global resource-type permission. + +Response: + +```json +{ + "success": true +} +``` + +### DELETE /api/admin/users/:userId/permissions + +Revoke a permission. + +Requires global `admin` permission. + +Body: + +```json +{ + "resourceType": "channel", + "resourceId": "default", + "permission": "control" +} +``` + +Response: + +```json +{ + "success": true +} +``` + +## Channel Endpoints + +### GET /api/channels + +List channels. + +Requires an authenticated session, or creates a guest session if guests are +enabled. + +Response: + +```json +[ + { + "id": "default", + "name": "Default", + "description": "All tracks", + "trackCount": 42, + "listenerCount": 3, + "listeners": ["alice", "bob", "bob"], + "isDefault": true, + "createdBy": null + } +] +``` + +### POST /api/channels + +Create a dynamic channel. + +Requires an authenticated non-guest user. + +Body: + +```json +{ + "name": "Late Night", + "description": "Quiet queue", + "trackIds": ["sha256:abc123"] +} +``` + +Rules: + +- `name` is required. +- `name` must be 64 characters or less. +- `description` is optional. +- `trackIds` is optional. Unknown or unavailable track IDs are ignored. + +Response `201`: + +```json +{ + "id": "k8s2m1qp", + "name": "Late Night", + "description": "Quiet queue", + "trackCount": 1, + "listenerCount": 0, + "listeners": [], + "isDefault": false, + "createdBy": 1 +} +``` + +### GET /api/channels/:channelId + +Get current channel state. + +Response: + +```json +{ + "track": { + "id": "sha256:abc123", + "filename": "song.mp3", + "title": "Song", + "duration": 180.5, + "replayGainDb": -7.2, + "replayPeak": 0.91 + }, + "currentTimestamp": 42.1, + "channelName": "Default", + "channelId": "default", + "description": "All tracks", + "paused": false, + "currentIndex": 4, + "listenerCount": 3, + "isDefault": true, + "playbackMode": "repeat-all" +} +``` + +This route does not include `queue`; WebSocket connect does. + +### PATCH /api/channels/:channelId + +Rename a channel. + +Requires an authenticated non-guest user. + +Body: + +```json +{ + "name": "New Channel Name" +} +``` + +Response: + +```json +{ + "success": true, + "name": "New Channel Name" +} +``` + +Current implementation allows any signed-in non-guest user to rename any +channel. + +### DELETE /api/channels/:channelId + +Delete a dynamic channel. + +Requires an authenticated user who is either admin or the channel creator. +Default channels cannot be deleted. + +Response: + +```json +{ + "success": true +} +``` + +If clients are connected to the deleted channel, the server moves them to the +default channel and sends a `switched` WebSocket message. + +### POST /api/channels/:channelId/jump + +Jump the channel to a queue index. + +Requires `control` permission. + +Body: + +```json +{ + "index": 3 +} +``` + +Response: + +```json +{ + "success": true +} +``` + +### POST /api/channels/:channelId/seek + +Seek the channel to a timestamp in the current track. + +Requires `control` permission. + +Body: + +```json +{ + "timestamp": 45.5 +} +``` + +Response: + +```json +{ + "success": true +} +``` + +The server clamps the timestamp between `0` and the current track duration. + +### PATCH /api/channels/:channelId/queue + +Modify a channel queue. + +Requires `control` permission. + +Set the whole queue: + +```json +{ + "set": ["sha256:a", "sha256:b", "sha256:c"] +} +``` + +Add tracks to the end: + +```json +{ + "add": ["sha256:d", "sha256:e"] +} +``` + +Insert tracks at an index: + +```json +{ + "add": ["sha256:d"], + "insertAt": 2 +} +``` + +Remove by queue position: + +```json +{ + "remove": [3, 4] +} +``` + +Move queue positions to a target index: + +```json +{ + "move": [5, 6], + "to": 1 +} +``` + +Response: + +```json +{ + "success": true, + "queueLength": 23 +} +``` + +Queue behavior: + +- `set` takes priority over all other operations. +- `move` takes priority after `set`. +- If neither `set` nor `move` is present, the server applies `remove` first, + then `add`. +- Queue operations use positions, not track IDs, for removal and movement. +- Unknown track IDs are ignored. +- Queue changes are broadcast over WebSocket with `queue` included. + +### POST /api/channels/:channelId/mode + +Set playback mode. + +Requires `control` permission. + +Body: + +```json +{ + "mode": "repeat-all" +} +``` + +Valid modes: + +- `once` +- `repeat-all` +- `repeat-one` +- `shuffle` + +Response: + +```json +{ + "success": true, + "playbackMode": "repeat-all" +} +``` + +## Channel WebSocket + +### WS /api/channels/:channelId/ws + +Connect to live channel state. + +Example URL: + +```text +ws://localhost:3001/api/channels/default/ws +``` + +If using `https`, use `wss`. + +The server associates the socket with the current session cookie. If guests are +enabled and no session exists, the upgrade creates a guest user. + +On connect, the server immediately sends a full `ChannelState` with `queue`. + +### Client Messages + +Switch channels: + +```json +{ + "action": "switch", + "channelId": "k8s2m1qp" +} +``` + +Switching does not require `control` permission. + +Pause: + +```json +{ + "action": "pause" +} +``` + +Unpause: + +```json +{ + "action": "unpause" +} +``` + +Seek: + +```json +{ + "action": "seek", + "timestamp": 45.5 +} +``` + +Jump: + +```json +{ + "action": "jump", + "index": 3 +} +``` + +Control messages require a signed-in, non-guest user with `control` +permission. Unauthorized WebSocket control messages are ignored rather than +reported as errors. + +### Server Messages + +Channel list update: + +```json +{ + "type": "channel_list", + "channels": [] +} +``` + +Switch confirmation: + +```json +{ + "type": "switched", + "channelId": "k8s2m1qp" +} +``` + +Channel state: + +```json +{ + "track": {}, + "currentTimestamp": 12.3, + "channelId": "default", + "channelName": "Default", + "paused": false, + "currentIndex": 0, + "listenerCount": 1, + "isDefault": true, + "playbackMode": "repeat-all", + "queue": [] +} +``` + +`queue` is included: + +- on initial connect, +- after queue changes, +- after full queue replacement, +- roughly once per minute as a refresh. + +Error: + +```json +{ + "type": "error", + "message": "Channel not found" +} +``` + +Kick: + +```json +{ + "type": "kick", + "reason": "Kicked by another session" +} +``` + +Toast: + +```json +{ + "type": "toast", + "message": "Added: Song", + "toastType": "info" +} +``` + +Scan progress: + +```json +{ + "type": "scan_progress", + "scanning": true, + "processed": 10, + "total": 42 +} +``` + +Fetch progress: + +```json +{ + "type": "fetch_progress", + "id": "x9ab12cd", + "title": "Downloaded Song", + "status": "downloading", + "progress": 50, + "queueType": "fast", + "playlistId": null, + "playlistName": null, + "error": null +} +``` + +Other fetch message types include: + +- `fetch_queued` +- `fetch_complete` +- `fetch_error` +- `fetch_cancelled` + +## Track And Library Endpoints + +### GET /api/library + +List all library tracks. + +Requires an authenticated session, or creates a guest session if guests are +enabled. + +Response: + +```json +[ + { + "id": "sha256:abc123", + "filename": "song.mp3", + "title": "Song", + "artist": "Artist", + "album": "Album", + "duration": 180.5, + "replayGainDb": -7.2, + "replayPeak": 0.91, + "available": true + } +] +``` + +### GET /api/tracks/:trackId + +Serve audio by content-addressed track ID. + +Requires an authenticated session, or creates a guest session if guests are +enabled. + +Example: + +```text +GET /api/tracks/sha256%3Aabc123 +``` + +Response headers: + +```text +Accept-Ranges: bytes +Content-Length: 12345678 +Content-Type: audio/mpeg +``` + +Range requests are supported: + +```text +Range: bytes=0-999999 +``` + +Partial response: + +```text +HTTP/1.1 206 Partial Content +Content-Range: bytes 0-999999/12345678 +Accept-Ranges: bytes +Content-Length: 1000000 +Content-Type: audio/mpeg +``` + +Standard clients should only request by `track.id`. The implementation also has +a filename fallback for older clients, but that is not part of the recommended +frontend contract. + +### POST /api/upload + +Upload an audio file. + +Requires an authenticated session, or creates a guest session if guests are +enabled. + +Body: + +```text +multipart/form-data +field name: file +``` + +Accepted extensions: + +```text +.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4 +``` + +Response: + +```json +{ + "success": true, + "filename": "uploaded_file.mp3" +} +``` + +Common errors: + +- `400` no file or invalid audio format +- `409` file already exists +- `500` upload failed + +After upload, the library watcher scans the file and broadcasts updates. + +## Playlist Endpoints + +### GET /api/playlists + +List the current user's playlists plus public playlists owned by others. + +Requires an authenticated session. + +Response: + +```json +{ + "mine": [ + { + "id": "uuid", + "name": "Favorites", + "description": "", + "ownerId": 1, + "isPublic": false, + "shareToken": null, + "trackIds": ["sha256:abc123"], + "createdAt": 1760000000, + "updatedAt": 1760000000 + } + ], + "shared": [] +} +``` + +### POST /api/playlists + +Create a playlist. + +Requires an authenticated non-guest user. + +Body: + +```json +{ + "name": "Favorites", + "description": "Tracks I keep returning to" +} +``` + +Response `201`: `Playlist` + +Common errors: + +- `400` invalid JSON or missing name +- `401` unauthorized +- `403` guests cannot create playlists + +### GET /api/playlists/:playlistId + +Get playlist details. + +Access is allowed when: + +- the current user owns the playlist, +- the playlist is public, +- or the query string token matches the playlist share token. + +Optional query: + +```text +?token=share-token +``` + +Response: + +```json +{ + "id": "uuid", + "name": "Favorites", + "description": "", + "ownerId": 1, + "ownerName": "alice", + "isPublic": false, + "shareToken": "abc123def456", + "trackIds": ["sha256:abc123"], + "createdAt": 1760000000, + "updatedAt": 1760000000 +} +``` + +### PATCH /api/playlists/:playlistId + +Update playlist metadata. + +Requires owner or admin. + +Body: + +```json +{ + "name": "New Name", + "description": "New description", + "isPublic": true +} +``` + +All fields are optional. Sent string fields are trimmed. + +Response: + +```json +{ + "ok": true +} +``` + +### DELETE /api/playlists/:playlistId + +Delete a playlist. + +Requires owner or admin. + +Response: + +```json +{ + "ok": true +} +``` + +### PATCH /api/playlists/:playlistId/tracks + +Modify playlist tracks. + +Requires owner or admin. + +Set the whole playlist: + +```json +{ + "set": ["sha256:a", "sha256:b"] +} +``` + +Add tracks to the end: + +```json +{ + "add": ["sha256:c"] +} +``` + +Insert tracks at an index: + +```json +{ + "add": ["sha256:c"], + "insertAt": 1 +} +``` + +Remove by playlist position: + +```json +{ + "remove": [0, 2] +} +``` + +Move positions to a target index: + +```json +{ + "move": [3, 4], + "to": 1 +} +``` + +Response: + +```json +{ + "ok": true +} +``` + +Rules: + +- `set` takes priority over every other operation. +- `move` takes priority after `set`. +- Otherwise the server applies `remove` first, then `add`. +- Operations use positions, not track IDs, for removal and movement. + +### POST /api/playlists/:playlistId/share + +Generate or replace a share token. + +Requires owner or admin. + +Response: + +```json +{ + "shareToken": "abc123def456" +} +``` + +### DELETE /api/playlists/:playlistId/share + +Remove playlist sharing. + +Requires owner or admin. + +Response: + +```json +{ + "ok": true +} +``` + +### GET /api/playlists/shared/:token + +Get a playlist by share token. + +Response: + +```json +{ + "id": "uuid", + "name": "Favorites", + "description": "", + "ownerId": 1, + "ownerName": "alice", + "isPublic": false, + "shareToken": "abc123def456", + "trackIds": ["sha256:abc123"], + "createdAt": 1760000000, + "updatedAt": 1760000000 +} +``` + +### POST /api/playlists/shared/:token + +Copy a shared playlist into the current user's playlists. + +Requires an authenticated non-guest user. + +Response `201`: copied `Playlist` + +Note: the current route copies directly at `POST /api/playlists/shared/:token`; +there is no `/copy` suffix in the implementation. + +## URL Fetch Endpoints + +These endpoints are available only when the `yt-dlp` feature is enabled and +available. + +### POST /api/fetch + +Check a URL and queue it for download if it is a single item. + +Requires an authenticated non-guest user. + +Body: + +```json +{ + "url": "https://example.com/watch?v=..." +} +``` + +Single-item response: + +```json +{ + "type": "single", + "id": "x9ab12cd", + "title": "Song Title", + "queueType": "fast" +} +``` + +Playlist response: + +```json +{ + "type": "playlist", + "title": "Playlist Title", + "count": 25, + "items": [ + { + "id": "a1b2c3d4", + "url": "https://example.com/watch?v=...", + "title": "Track Title" + } + ], + "requiresConfirmation": true +} +``` + +When the response is `type: "playlist"`, call `/api/fetch/confirm` to enqueue +the items. + +Common errors: + +- `400` URL missing or invalid +- `403` guests cannot fetch, feature disabled, or playlist downloads disabled +- `503` yt-dlp unavailable + +### POST /api/fetch/confirm + +Confirm and enqueue playlist download items. The server creates a playlist for +the imported items and slowly downloads them. + +Requires an authenticated non-guest user. + +Body: + +```json +{ + "playlistTitle": "Playlist Title", + "items": [ + { + "url": "https://example.com/watch?v=...", + "title": "Track Title" + } + ] +} +``` + +Response: + +```json +{ + "message": "Added 25 items to queue", + "queueType": "slow", + "estimatedTime": "1h 15m", + "playlistId": "uuid", + "playlistName": "Playlist Title", + "items": [ + { + "id": "x9ab12cd", + "title": "Track Title" + } + ] +} +``` + +### GET /api/fetch + +Get fetch queue status. All authenticated users can see all current queue +items. + +Response: + +```json +{ + "fastQueue": [], + "slowQueue": [], + "slowQueueNextIn": 120 +} +``` + +### DELETE /api/fetch/:itemId + +Cancel a slow queue item owned by the current user. + +Response: + +```json +{ + "message": "Item cancelled" +} +``` + +The server cannot cancel an item that is already downloading. + +### DELETE /api/fetch + +Cancel all queued slow queue items owned by the current user. + +Response: + +```json +{ + "message": "Cancelled 3 items", + "cancelled": 3 +} +``` + +## Static Routes + +These are not required for custom frontends, but they exist in the bundled web +app. + +```text +GET / -> public/index.html +GET /index.html -> public/index.html +GET /listen/:trackId -> public/index.html for direct local playback +GET /styles.css -> public/styles.css +GET /favicon.ico -> public/favicon.ico +GET /*.js -> public JavaScript files +``` + +Direct listening links should encode the track ID: + +```text +/listen/sha256%3Aabc123 +``` + +## Playback Sync Algorithm + +Clients should implement the synced player like this: + +```ts +let lastState: ChannelState | null = null; +let lastStateAt = 0; + +function estimatedServerTime() { + if (!lastState?.track) return 0; + if (lastState.paused) return lastState.currentTimestamp; + return lastState.currentTimestamp + (performance.now() - lastStateAt) / 1000; +} + +async function applyChannelState(state: ChannelState, audio: HTMLAudioElement) { + lastState = state; + lastStateAt = performance.now(); + + if (state.queue) { + renderQueue(state.queue, state.currentIndex); + } + + if (!state.track) { + audio.pause(); + audio.removeAttribute("src"); + return; + } + + const trackId = state.track.id; + const targetTime = state.currentTimestamp; + const nextSrc = `/api/tracks/${encodeURIComponent(trackId)}`; + + if (!audio.src.endsWith(encodeURIComponent(trackId))) { + audio.src = nextSrc; + audio.currentTime = targetTime; + } else if (Math.abs(audio.currentTime - targetTime) >= 2) { + audio.currentTime = targetTime; + } + + if (state.paused) { + audio.pause(); + } else { + await audio.play().catch(() => { + // Browser autoplay policy: show a click-to-play affordance. + }); + } +} +``` + +For blob URLs or custom caching, track the current `track.id` separately instead +of checking `audio.src`. + +## Caching Contract + +Caching is optional. If implemented, key everything by `track.id`. + +Recommended browser cache shape: + +```ts +type TrackId = string; + +interface CachedTrack { + blob: Blob; + contentType: string; +} +``` + +Useful behavior: + +- Store complete track blobs in IndexedDB using `track.id`. +- Use `/api/tracks/:trackId` for full downloads. +- Use `Range` requests against `/api/tracks/:trackId` to fill seekable + segments. +- Keep a per-track segment map, for example `Map>`. +- Treat a fully cached track as ready for blob URL playback. + +The bundled client divides tracks into 20 virtual segments, but that number is +not part of the server contract. + +## Minimal Frontend Checklist + +A working synced frontend only needs: + +1. Login/signup/guest session via `/api/auth/me`, `/login`, and `/signup`. +2. Library list via `/api/library`. +3. Channel list via `/api/channels`. +4. WebSocket connection to `/api/channels/:id/ws`. +5. Audio playback from `/api/tracks/:trackId`. +6. Drift correction when local audio differs from server time by 2 seconds or + more. +7. Queue rendering that accepts sparse `queue` updates. + +Everything else is additive. + diff --git a/docs/buildme.md b/docs/buildme.md new file mode 100644 index 0000000..64dcade --- /dev/null +++ b/docs/buildme.md @@ -0,0 +1,575 @@ +# 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: + +```text +docs/api-reference.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. + +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. diff --git a/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj b/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj index dbc20ce..bb2ec2a 100644 --- a/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj +++ b/ios/BlastoisePing/BlastoisePing.xcodeproj/project.pbxproj @@ -10,6 +10,14 @@ 1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */; }; 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000102 /* ContentView.swift */; }; 1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */; }; + 1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000110 /* AppTypes.swift */; }; + 1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000111 /* AppModel.swift */; }; + 1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000112 /* Theme.swift */; }; + 1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000113 /* AuthView.swift */; }; + 1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000114 /* HeaderView.swift */; }; + 1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */; }; + 1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000116 /* Panels.swift */; }; + 1A2B3C4D5E6F700000000017 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000117 /* Components.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -18,6 +26,14 @@ 1A2B3C4D5E6F700000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 1A2B3C4D5E6F700000000103 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Fonts/pixelify_sans.ttf; sourceTree = ""; }; + 1A2B3C4D5E6F700000000110 /* AppTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTypes.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000111 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000112 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000113 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000114 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDeckView.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000116 /* Panels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000117 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,12 +60,52 @@ children = ( 1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */, 1A2B3C4D5E6F700000000102 /* ContentView.swift */, + 1A2B3C4D5E6F700000000304 /* State */, + 1A2B3C4D5E6F700000000303 /* Models */, + 1A2B3C4D5E6F700000000305 /* UI */, + 1A2B3C4D5E6F700000000306 /* Views */, 1A2B3C4D5E6F700000000103 /* Info.plist */, 1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */, ); path = BlastoisePing; sourceTree = ""; }; + 1A2B3C4D5E6F700000000303 /* Models */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000110 /* AppTypes.swift */, + ); + path = Models; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000304 /* State */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000111 /* AppModel.swift */, + ); + path = State; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000305 /* UI */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000112 /* Theme.swift */, + ); + path = UI; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000306 /* Views */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000113 /* AuthView.swift */, + 1A2B3C4D5E6F700000000114 /* HeaderView.swift */, + 1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */, + 1A2B3C4D5E6F700000000116 /* Panels.swift */, + 1A2B3C4D5E6F700000000117 /* Components.swift */, + ); + path = Views; + sourceTree = ""; + }; 1A2B3C4D5E6F700000000302 /* Products */ = { isa = PBXGroup; children = ( @@ -129,6 +185,14 @@ files = ( 1A2B3C4D5E6F700000000001 /* BlastoisePingApp.swift in Sources */, 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */, + 1A2B3C4D5E6F700000000010 /* AppTypes.swift in Sources */, + 1A2B3C4D5E6F700000000011 /* AppModel.swift in Sources */, + 1A2B3C4D5E6F700000000012 /* Theme.swift in Sources */, + 1A2B3C4D5E6F700000000013 /* AuthView.swift in Sources */, + 1A2B3C4D5E6F700000000014 /* HeaderView.swift in Sources */, + 1A2B3C4D5E6F700000000015 /* PlayerDeckView.swift in Sources */, + 1A2B3C4D5E6F700000000016 /* Panels.swift in Sources */, + 1A2B3C4D5E6F700000000017 /* Components.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/BlastoisePing/BlastoisePing/ContentView.swift b/ios/BlastoisePing/BlastoisePing/ContentView.swift index 36fa264..c72f574 100644 --- a/ios/BlastoisePing/BlastoisePing/ContentView.swift +++ b/ios/BlastoisePing/BlastoisePing/ContentView.swift @@ -1,6 +1,4 @@ -import AVFoundation import SwiftUI -import UniformTypeIdentifiers struct ContentView: View { @StateObject private var model = AppModel() @@ -30,6 +28,11 @@ struct ContentView: View { .font(Theme.bodyFont) .buttonBorderShape(.roundedRectangle(radius: Theme.corner)) } + .onChange(of: model.authState) { _, authState in + if authState == .signedIn { + password = "" + } + } } private var mainApp: some View { @@ -121,2305 +124,3 @@ private enum MainTab: String, CaseIterable, Identifiable { } } } - -private enum SourceMode: String { - case radio = "RADIO" - case library = "LIBRARY" -} - -private enum AuthState: String { - case checking = "CHECKING" - case signedOut = "SIGNED OUT" - case signedIn = "SIGNED IN" -} - -private struct Theme { - static let background = Color(red: 0.055, green: 0.052, blue: 0.067) - static let panel = Color(red: 0.112, green: 0.105, blue: 0.135) - static let panel2 = Color(red: 0.170, green: 0.157, blue: 0.205) - static let stroke = Color(red: 0.475, green: 0.425, blue: 0.545) - static let text = Color(red: 0.965, green: 0.930, blue: 0.760) - static let muted = Color(red: 0.640, green: 0.585, blue: 0.710) - static let accent = Color(red: 1.000, green: 0.812, blue: 0.176) - static let ready = Color(red: 0.350, green: 0.820, blue: 1.000) - static let amber = Color(red: 1.000, green: 0.570, blue: 0.240) - static let red = Color(red: 1.000, green: 0.310, blue: 0.340) - - static let corner: CGFloat = 0 - static let smallCorner: CGFloat = 0 - - static func pixel(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { - .custom("PixelifySans-Regular", size: size).weight(weight) - } - - static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { - pixel(size, weight: weight).monospacedDigit() - } - - static let bodyFont = pixel(16) - static let headlineFont = pixel(19, weight: .semibold) - static let captionFont = pixel(13) - static let microFont = mono(11, weight: .semibold) - static func display(_ size: CGFloat) -> Font { pixel(size, weight: .bold) } -} - -@MainActor -private final class AppModel: ObservableObject { - private let defaultServer = "http://mhsgroove.peterino.com:3001" - private let userAgent = "BlastoiseiOSSketch/0.1" - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - private let player = AVPlayer() - - private var cookieHeader: String { - didSet { UserDefaults.standard.set(cookieHeader, forKey: "blastoise.cookie") } - } - private var webSocket: URLSessionWebSocketTask? - private var receiveTask: Task? - private var reconnectTask: Task? - private var tickerTask: Task? - private var endObserver: NSObjectProtocol? - private var reconnectAttempts = 0 - private var intentionalDisconnect = false - private var lastLibraryEndTrackId: String? - - @Published var serverURL: String { - didSet { UserDefaults.standard.set(serverURL, forKey: "blastoise.serverURL") } - } - @Published var authState: AuthState = .checking - @Published var currentUser: UserSession? - @Published var status = "Starting" - @Published var channels: [ChannelInfo] = [] - @Published var libraryTracks: [Track] = [] - @Published var libraryLoaded = false - @Published var myPlaylists: [Playlist] = [] - @Published var sharedPlaylists: [Playlist] = [] - @Published var playlistsLoaded = false - @Published var selectedPlaylistId: String? - @Published var selectedPlaylist: Playlist? - @Published var sourceMode: SourceMode = .radio - @Published var currentChannelId: String? - @Published var currentTrackId: String? - @Published var localLibraryIndex = -1 - @Published var listeners: [String] = [] - @Published var paused = true - @Published var queue: [Track] = [] - @Published var queueLoaded = false - @Published var currentIndex = 0 - @Published var playbackMode = "repeat-all" - @Published var channelName = "No room" - @Published var trackTitle = "No track" - @Published var trackDuration: TimeInterval = 0 - @Published var serverTimestampMs: Int64 = 0 - @Published var stateMonotonicTime: TimeInterval = 0 - @Published var expectedPositionMs: Int64 = 0 - @Published var playerPositionMs: Int64 = 0 - @Published var driftMs: Int64 = 0 - @Published var playbackState = "idle" - @Published var isPlaying = false - @Published var debugEvents: [String] = [] - @Published var isUploading = false - @Published var isFetching = false - @Published var importStatus = "" - @Published var pendingFetchPlaylist: FetchPlaylistResponse? - - var allPlaylists: [Playlist] { myPlaylists + sharedPlaylists } - - init() { - serverURL = UserDefaults.standard.string(forKey: "blastoise.serverURL") ?? defaultServer - cookieHeader = UserDefaults.standard.string(forKey: "blastoise.cookie") ?? "" - configureAudioSession() - startTicker() - endObserver = NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.handlePlaybackEnded() - } - } - Task { await validateSession() } - } - - deinit { - tickerTask?.cancel() - receiveTask?.cancel() - reconnectTask?.cancel() - webSocket?.cancel(with: .goingAway, reason: nil) - if let endObserver { - NotificationCenter.default.removeObserver(endObserver) - } - } - - func validateSession() async { - if cookieHeader.isEmpty { - authState = .signedOut - status = "Sign in required" - return - } - - authState = .checking - status = "Checking session" - do { - let session = try await me() - if let user = session, !user.isGuest { - await becomeSignedIn(user) - } else { - clearSession() - status = "Sign in required" - } - } catch { - clearSession() - status = "Session expired" - addDebug("me failed: \(error.localizedDescription)") - } - } - - func signIn(username: String, password: String) async { - guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - status = "Username required" - return - } - authState = .checking - status = "Signing in" - do { - let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] - let envelope: AuthEnvelope = try await request("/api/auth/login", method: "POST", json: body) - if let user = envelope.user { - await becomeSignedIn(user) - } else { - status = "Login returned no user" - authState = .signedOut - } - } catch { - authState = .signedOut - status = "Login failed" - addDebug("login failed: \(error.localizedDescription)") - } - } - - func signUp(username: String, password: String) async { - guard password.count >= 6 else { - status = "Password must be 6+ chars" - return - } - authState = .checking - status = "Signing up" - do { - let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] - let envelope: AuthEnvelope = try await request("/api/auth/signup", method: "POST", json: body) - if let user = envelope.user { - await becomeSignedIn(user) - } else { - status = "Signup returned no user" - authState = .signedOut - } - } catch { - authState = .signedOut - status = "Signup failed" - addDebug("signup failed: \(error.localizedDescription)") - } - } - - func logout() async { - intentionalDisconnect = true - closeSocket() - player.pause() - player.replaceCurrentItem(with: nil) - _ = try? await requestRaw("/api/auth/logout", method: "POST", json: [:]) - clearSession() - resetPlayback() - status = "Signed out" - } - - func connectToServer() async { - if authState == .signedIn { - status = "Reloading" - await loadInitialData() - } else { - await validateSession() - } - } - - func loadLibraryIfNeeded() async { - if !libraryLoaded { - await loadLibrary() - } - } - - func loadPlaylistsIfNeeded() async { - if !playlistsLoaded { - await loadPlaylists() - } - } - - func loadLibrary() async { - guard requireSignedIn("Sign in to browse library") else { return } - do { - libraryTracks = try await request("/api/library") - libraryLoaded = true - status = "Library loaded" - addDebug("library \(libraryTracks.count)") - } catch { - status = "Library failed" - addDebug("library failed: \(error.localizedDescription)") - } - } - - func loadPlaylists() async { - guard requireSignedIn("Sign in to load playlists") else { return } - do { - let bundle: PlaylistBundle = try await request("/api/playlists") - myPlaylists = bundle.mine - sharedPlaylists = bundle.shared - playlistsLoaded = true - if selectedPlaylistId == nil { - selectedPlaylistId = allPlaylists.first?.id - } - if let id = selectedPlaylistId { - await loadPlaylist(id) - } - status = "Playlists loaded" - } catch { - status = "Playlists failed" - addDebug("playlists failed: \(error.localizedDescription)") - } - } - - func loadPlaylist(_ id: String) async { - guard requireSignedIn("Sign in to load playlists") else { return } - selectedPlaylistId = id - selectedPlaylist = nil - do { - selectedPlaylist = try await request("/api/playlists/\(encodePath(id))") - } catch { - status = "Playlist failed" - addDebug("playlist failed: \(error.localizedDescription)") - } - } - - func uploadFiles(_ urls: [URL]) async { - guard requireSignedIn("Sign in to upload") else { return } - guard !urls.isEmpty else { return } - - isUploading = true - importStatus = "Uploading \(urls.count) file(s)" - var uploaded = 0 - var failed = 0 - - for url in urls { - do { - try await uploadFile(url) - uploaded += 1 - importStatus = "Uploaded \(uploaded)/\(urls.count)" - } catch { - failed += 1 - importStatus = "Upload failed: \(error.localizedDescription)" - addDebug("upload failed: \(url.lastPathComponent)") - } - } - - isUploading = false - importStatus = failed == 0 ? "Uploaded \(uploaded) file(s)" : "Uploaded \(uploaded), failed \(failed)" - if uploaded > 0 { - await loadLibrary() - } - } - - func fetchFromWebsite(_ urlString: String) async { - guard requireSignedIn("Sign in to fetch URLs") else { return } - let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - importStatus = "Enter a URL" - return - } - - isFetching = true - pendingFetchPlaylist = nil - importStatus = "Checking URL" - do { - let response: FetchResponse = try await request("/api/fetch", method: "POST", json: ["url": trimmed]) - switch response { - case .single(let item): - importStatus = "Queued: \(item.title)" - addDebug("fetch queued \(item.title.prefix(36))") - case .playlist(let playlist): - pendingFetchPlaylist = playlist - importStatus = "Playlist found: \(playlist.title)" - } - } catch { - importStatus = "Fetch failed" - addDebug("fetch failed: \(error.localizedDescription)") - } - isFetching = false - } - - func confirmFetchPlaylist() async { - guard let playlist = pendingFetchPlaylist else { return } - guard requireSignedIn("Sign in to fetch URLs") else { return } - - isFetching = true - importStatus = "Queueing playlist" - do { - let body: [String: Any] = [ - "items": playlist.items.map { ["url": $0.url, "title": $0.title] }, - "playlistTitle": playlist.title - ] - let response: FetchConfirmResponse = try await request("/api/fetch/confirm", method: "POST", json: body) - pendingFetchPlaylist = nil - importStatus = "\(response.message) -> \(response.playlistName ?? "playlist")" - await loadPlaylists() - } catch { - importStatus = "Playlist fetch failed" - addDebug("fetch confirm failed: \(error.localizedDescription)") - } - isFetching = false - } - - func cancelFetchPlaylist() { - pendingFetchPlaylist = nil - importStatus = "Playlist fetch cancelled" - } - - func joinChannel(_ id: String) async { - guard requireSignedIn("Sign in to join rooms") else { return } - sourceMode = .radio - intentionalDisconnect = false - localLibraryIndex = -1 - currentChannelId = id - status = "Joining" - addDebug("join \(id)") - connectWebSocket(channelId: id) - await loadChannelState(id) - } - - func playLibraryTrack(_ track: Track) { - guard let index = libraryTracks.firstIndex(where: { $0.id == track.id }) else { return } - sourceMode = .library - intentionalDisconnect = true - closeSocket() - localLibraryIndex = index - currentTrackId = track.id - trackTitle = track.title - trackDuration = track.duration - channelName = "Library" - paused = false - status = "Local" - serverTimestampMs = 0 - stateMonotonicTime = 0 - lastLibraryEndTrackId = nil - replacePlayerItem(track: track, positionMs: 0) - player.play() - addDebug("local play \(track.title.prefix(36))") - tick() - } - - func togglePlay() { - if sourceMode == .radio { - sendSocket(["action": paused ? "unpause" : "pause"]) - status = paused ? "Unpause sent" : "Pause sent" - return - } - - if player.timeControlStatus == .playing { - player.pause() - paused = true - } else { - if localLibraryIndex < 0, let first = libraryTracks.first { - playLibraryTrack(first) - return - } - player.play() - paused = false - } - tick() - } - - func previous() { - if sourceMode == .radio { - jumpRadio(delta: -1) - } else { - guard !libraryTracks.isEmpty else { return } - let nextIndex: Int - if playbackMode == "shuffle", libraryTracks.count > 1 { - nextIndex = randomLibraryIndex() - } else if localLibraryIndex <= 0 { - nextIndex = libraryTracks.count - 1 - } else { - nextIndex = localLibraryIndex - 1 - } - playLibraryTrack(libraryTracks[nextIndex]) - } - } - - func next() { - if sourceMode == .radio { - jumpRadio(delta: 1) - } else if !libraryTracks.isEmpty { - playLibraryTrack(libraryTracks[nextLibraryIndex()]) - } - } - - func seekBy(seconds: Int) { - let durationMs = max(Int64(trackDuration * 1000), playerPositionMs) - let target = max(Int64(0), min(durationMs, playerPositionMs + Int64(seconds * 1000))) - seek(to: Double(target) / 1000) - } - - func seek(to seconds: Double) { - let target = max(0, seconds) - if sourceMode == .radio { - sendSocket(["action": "seek", "timestamp": target]) - status = "Seek sent" - } else { - player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) - } - tick() - } - - func cyclePlaybackMode() async { - let modes = ["once", "repeat-all", "repeat-one", "shuffle"] - let current = modes.firstIndex(of: playbackMode) ?? 0 - let next = modes[(current + 1) % modes.count] - playbackMode = next - - if sourceMode == .radio { - guard let channelId = currentChannelId else { - status = "No room" - return - } - do { - let _: ModeResponse = try await request( - "/api/channels/\(encodePath(channelId))/mode", - method: "POST", - json: ["mode": next] - ) - status = "Mode \(modeLabel(next))" - } catch { - status = "Mode failed" - addDebug("mode failed: \(error.localizedDescription)") - } - } else { - status = "Mode \(modeLabel(next))" - } - } - - func queueCurrent(playNext: Bool) async { - guard let trackId = currentTrackId else { - status = "No track" - return - } - await queueTrackIds([trackId], playNext: playNext) - } - - func queueTrack(_ track: Track, playNext: Bool) async { - await queueTrackIds([track.id], playNext: playNext) - } - - func addPlaylistToQueue(_ playlist: Playlist, playNext: Bool) async { - guard !playlist.trackIds.isEmpty else { - status = "Empty playlist" - return - } - await queueTrackIds(playlist.trackIds, playNext: playNext) - } - - func jumpToQueueIndex(_ index: Int) { - guard index >= 0, index < queue.count else { return } - sendSocket(["action": "jump", "index": index]) - status = "Jump sent" - } - - func removeQueueIndex(_ index: Int) async { - guard let channelId = currentChannelId else { - status = "No room" - return - } - do { - let _: QueueResponse = try await request( - "/api/channels/\(encodePath(channelId))/queue", - method: "PATCH", - json: ["remove": [index]] - ) - status = "Removed" - await loadChannelState(channelId) - } catch { - status = "Remove failed" - addDebug("remove failed: \(error.localizedDescription)") - } - } - - func stopAndExit() { - intentionalDisconnect = true - closeSocket() - player.pause() - player.replaceCurrentItem(with: nil) - resetPlayback() - status = "Stopped" - } - - func track(for id: String) -> Track? { - libraryTracks.first { $0.id == id } ?? queue.first { $0.id == id } - } - - private func becomeSignedIn(_ user: UserSession) async { - currentUser = user - authState = user.isGuest ? .signedOut : .signedIn - if user.isGuest { - status = "Sign in required" - return - } - status = "Signed in as \(user.username)" - addDebug("auth \(user.username)") - await loadInitialData() - } - - private func loadInitialData() async { - async let channelsLoad: Void = loadChannels() - async let libraryLoad: Void = loadLibrary() - async let playlistsLoad: Void = loadPlaylists() - _ = await (channelsLoad, libraryLoad, playlistsLoad) - } - - private func loadChannels() async { - guard requireSignedIn("Sign in to load rooms") else { return } - do { - channels = try await request("/api/channels") - addDebug("rooms \(channels.count)") - let target = currentChannelId - ?? channels.first(where: { $0.isDefault })?.id - ?? channels.first?.id - if let target, currentChannelId == nil { - await joinChannel(target) - } - } catch { - status = "Rooms failed" - addDebug("rooms failed: \(error.localizedDescription)") - } - } - - private func loadChannelState(_ channelId: String) async { - do { - let state: ChannelState = try await request("/api/channels/\(encodePath(channelId))") - handleChannelState(state) - } catch { - addDebug("room state failed: \(error.localizedDescription)") - } - } - - private func queueTrackIds(_ trackIds: [String], playNext: Bool) async { - guard requireSignedIn("Sign in to change queue") else { return } - guard let channelId = currentChannelId - ?? channels.first(where: { $0.isDefault })?.id - ?? channels.first?.id else { - status = "No room" - return - } - let insertAt = playNext ? max(0, currentIndex + 1) : nil - var body: [String: Any] = ["add": trackIds] - if let insertAt { - body["insertAt"] = insertAt - } - do { - let _: QueueResponse = try await request( - "/api/channels/\(encodePath(channelId))/queue", - method: "PATCH", - json: body - ) - status = playNext ? "Queued next" : "Queued" - currentChannelId = channelId - await loadChannelState(channelId) - await loadChannels() - } catch { - status = "Queue failed" - addDebug("queue failed: \(error.localizedDescription)") - } - } - - private func me() async throws -> UserSession? { - let envelope: AuthEnvelope = try await request("/api/auth/me") - return envelope.user - } - - private func request( - _ path: String, - method: String = "GET", - json: Any? = nil - ) async throws -> T { - let data = try await requestRaw(path, method: method, json: json) - return try decoder.decode(T.self, from: data) - } - - private func requestRaw( - _ path: String, - method: String = "GET", - json: Any? = nil - ) async throws -> Data { - guard let url = URL(string: normalizedBaseURL() + path) else { - throw APIError.invalidURL - } - var request = URLRequest(url: url) - request.httpMethod = method - request.timeoutInterval = 12 - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - if !cookieHeader.isEmpty { - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - } - if let json { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: json) - } - - let (data, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse { - captureCookie(http) - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" - throw APIError.http(http.statusCode, text) - } - } - return data - } - - private func uploadFile(_ fileURL: URL) async throws { - guard let url = URL(string: normalizedBaseURL() + "/api/upload") else { - throw APIError.invalidURL - } - - let didAccess = fileURL.startAccessingSecurityScopedResource() - defer { - if didAccess { - fileURL.stopAccessingSecurityScopedResource() - } - } - - let fileData = try Data(contentsOf: fileURL) - let boundary = "BlastoiseBoundary-\(UUID().uuidString)" - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = 120 - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if !cookieHeader.isEmpty { - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - } - - let body = multipartBody( - boundary: boundary, - fieldName: "file", - filename: fileURL.lastPathComponent, - contentType: contentType(for: fileURL), - data: fileData - ) - - let (data, response) = try await URLSession.shared.upload(for: request, from: body) - if let http = response as? HTTPURLResponse { - captureCookie(http) - guard (200..<300).contains(http.statusCode) else { - let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" - throw APIError.http(http.statusCode, text) - } - } - } - - private func multipartBody( - boundary: String, - fieldName: String, - filename: String, - contentType: String, - data: Data - ) -> Data { - var body = Data() - body.appendString("--\(boundary)\r\n") - body.appendString("Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\r\n") - body.appendString("Content-Type: \(contentType)\r\n\r\n") - body.append(data) - body.appendString("\r\n--\(boundary)--\r\n") - return body - } - - private func contentType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "mp3": return "audio/mpeg" - case "ogg", "opus": return "audio/ogg" - case "flac": return "audio/flac" - case "wav": return "audio/wav" - case "m4a", "aac": return "audio/mp4" - case "mp4": return "video/mp4" - case "wma": return "audio/x-ms-wma" - default: return "application/octet-stream" - } - } - - private func connectWebSocket(channelId: String) { - closeSocket() - guard let url = webSocketURL(channelId: channelId) else { - status = "Bad WebSocket URL" - return - } - var request = URLRequest(url: url) - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - if !cookieHeader.isEmpty { - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - } - - let task = URLSession.shared.webSocketTask(with: request) - webSocket = task - task.resume() - status = "Connected" - addDebug("ws connect \(channelId)") - reconnectAttempts = 0 - - receiveTask = Task { [weak self, task] in - while !Task.isCancelled { - do { - let message = try await task.receive() - await MainActor.run { - self?.handleWebSocketMessage(message) - } - } catch { - await MainActor.run { - self?.handleSocketError(error) - } - break - } - } - } - } - - private func closeSocket() { - receiveTask?.cancel() - receiveTask = nil - reconnectTask?.cancel() - reconnectTask = nil - webSocket?.cancel(with: .goingAway, reason: nil) - webSocket = nil - } - - private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { - let data: Data - switch message { - case .string(let text): - data = Data(text.utf8) - case .data(let payload): - data = payload - @unknown default: - addDebug("ws unknown message") - return - } - - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - addDebug("ws bad json") - return - } - - if let type = object["type"] as? String, !type.isEmpty { - handleTypedSocketMessage(type, object: object, data: data) - return - } - - do { - let state = try decoder.decode(ChannelState.self, from: data) - handleChannelState(state) - } catch { - addDebug("ws state decode failed") - } - } - - private func handleTypedSocketMessage(_ type: String, object: [String: Any], data: Data) { - switch type { - case "channel_list": - if let channelData = try? JSONSerialization.data(withJSONObject: object["channels"] ?? []), - let nextChannels = try? decoder.decode([ChannelInfo].self, from: channelData) { - channels = nextChannels - } - case "switched": - currentChannelId = object["channelId"] as? String - status = "Tuned" - case "kick": - status = "Kicked" - addDebug("kick \(object["reason"] as? String ?? "")") - case "error": - status = object["message"] as? String ?? "Server error" - addDebug("ws error \(status)") - default: - addDebug("ws event \(type)") - } - } - - private func handleSocketError(_ error: Error) { - guard !intentionalDisconnect, sourceMode == .radio else { return } - status = "Disconnected" - addDebug("ws fail: \(error.localizedDescription)") - scheduleReconnect() - } - - private func scheduleReconnect() { - guard let channelId = currentChannelId else { return } - reconnectTask?.cancel() - let delaySeconds = min(3 * (reconnectAttempts + 1), 30) - reconnectAttempts += 1 - addDebug("reconnect in \(delaySeconds)s") - reconnectTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) - await MainActor.run { - guard let self, self.sourceMode == .radio, !self.intentionalDisconnect else { return } - self.connectWebSocket(channelId: channelId) - } - if let self { - await self.loadChannelState(channelId) - } - } - } - - private func sendSocket(_ dictionary: [String: Any]) { - guard let webSocket else { - status = "Socket not connected" - scheduleReconnect() - return - } - guard let data = try? JSONSerialization.data(withJSONObject: dictionary), - let payload = String(data: data, encoding: .utf8) else { - return - } - webSocket.send(.string(payload)) { [weak self] error in - if let error { - Task { @MainActor in - self?.addDebug("ws send failed: \(error.localizedDescription)") - } - } - } - } - - private func handleChannelState(_ state: ChannelState) { - guard sourceMode == .radio else { return } - reconnectAttempts = 0 - currentChannelId = state.channelId.isEmpty ? currentChannelId : state.channelId - channelName = state.channelName.isEmpty ? "Room" : state.channelName - paused = state.paused - playbackMode = state.playbackMode.isEmpty ? playbackMode : state.playbackMode - currentIndex = state.currentIndex - listeners = state.listeners - if let stateQueue = state.queue { - queue = stateQueue - queueLoaded = true - } - serverTimestampMs = max(0, Int64(state.currentTimestamp * 1000)) - stateMonotonicTime = ProcessInfo.processInfo.systemUptime - - guard let track = state.track else { - currentTrackId = nil - trackTitle = "No track" - trackDuration = 0 - player.pause() - tick() - return - } - - trackTitle = track.title - trackDuration = track.duration - let targetMs = expectedPosition() - - if currentTrackId != track.id { - currentTrackId = track.id - replacePlayerItem(track: track, positionMs: targetMs) - if state.paused { - player.pause() - } else { - player.play() - } - tick() - return - } - - let drift = playerPosition() - targetMs - if abs(drift) >= 2000 { - seekPlayer(toMs: targetMs) - addDebug("drift correction \(drift)ms") - } - - if state.paused, player.timeControlStatus == .playing { - player.pause() - } else if !state.paused, player.timeControlStatus != .playing { - player.play() - } - tick() - } - - private func replacePlayerItem(track: Track, positionMs: Int64) { - guard let url = trackURL(track.id) else { - status = "Bad track URL" - return - } - var headers = ["User-Agent": userAgent] - if !cookieHeader.isEmpty { - headers["Cookie"] = cookieHeader - } - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) - let item = AVPlayerItem(asset: asset) - player.replaceCurrentItem(with: item) - seekPlayer(toMs: positionMs) - } - - private func seekPlayer(toMs ms: Int64) { - let seconds = Double(max(0, ms)) / 1000 - player.seek( - to: CMTime(seconds: seconds, preferredTimescale: 600), - toleranceBefore: .zero, - toleranceAfter: .zero - ) - } - - private func tick() { - playerPositionMs = playerPosition() - expectedPositionMs = expectedPosition() - driftMs = sourceMode == .radio && currentTrackId != nil ? playerPositionMs - expectedPositionMs : 0 - isPlaying = player.timeControlStatus == .playing - playbackState = playbackStateLabel() - - if sourceMode == .radio, currentTrackId != nil, !paused, abs(driftMs) >= 2000 { - seekPlayer(toMs: expectedPositionMs) - addDebug("tick drift correction \(driftMs)ms") - } - - if sourceMode == .library { - paused = !isPlaying - if let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 { - trackDuration = duration - } - } - } - - private func expectedPosition() -> Int64 { - guard sourceMode == .radio, stateMonotonicTime > 0 else { - return playerPosition() - } - if paused { - return serverTimestampMs - } - let elapsedMs = Int64((ProcessInfo.processInfo.systemUptime - stateMonotonicTime) * 1000) - return serverTimestampMs + max(0, elapsedMs) - } - - private func playerPosition() -> Int64 { - let seconds = player.currentTime().seconds - guard seconds.isFinite, seconds >= 0 else { return 0 } - return Int64(seconds * 1000) - } - - private func handlePlaybackEnded() { - guard sourceMode == .library else { return } - guard currentTrackId != lastLibraryEndTrackId else { return } - lastLibraryEndTrackId = currentTrackId - switch playbackMode { - case "repeat-one": - seekPlayer(toMs: 0) - player.play() - lastLibraryEndTrackId = nil - case "repeat-all", "shuffle": - if !libraryTracks.isEmpty { - playLibraryTrack(libraryTracks[nextLibraryIndex()]) - } - default: - paused = true - tick() - } - } - - private func jumpRadio(delta: Int) { - guard !queue.isEmpty else { return } - let target = (currentIndex + delta + queue.count) % queue.count - sendSocket(["action": "jump", "index": target]) - status = "Jump sent" - } - - private func nextLibraryIndex() -> Int { - guard !libraryTracks.isEmpty else { return 0 } - if playbackMode == "shuffle", libraryTracks.count > 1 { - return randomLibraryIndex() - } - return localLibraryIndex < 0 ? 0 : (localLibraryIndex + 1) % libraryTracks.count - } - - private func randomLibraryIndex() -> Int { - guard libraryTracks.count > 1 else { return 0 } - var next = Int.random(in: 0.. Bool { - if authState == .signedIn, currentUser?.isGuest == false { return true } - status = message - addDebug("auth required") - return false - } - - private func startTicker() { - tickerTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 500_000_000) - await MainActor.run { - self?.tick() - } - } - } - } - - private func configureAudioSession() { - #if os(iOS) - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - addDebug("audio session failed") - } - #endif - } - - private func playbackStateLabel() -> String { - switch player.timeControlStatus { - case .paused: - return player.currentItem == nil ? "idle" : "paused" - case .waitingToPlayAtSpecifiedRate: - return "buffering" - case .playing: - return "playing" - @unknown default: - return "unknown" - } - } - - private func modeLabel(_ mode: String) -> String { - switch mode { - case "once": return "ONCE" - case "repeat-all": return "LOOP ALL" - case "repeat-one": return "LOOP ONE" - case "shuffle": return "SHUFFLE" - default: return mode.uppercased() - } - } - - private func captureCookie(_ response: HTTPURLResponse) { - for (key, value) in response.allHeaderFields { - guard String(describing: key).lowercased() == "set-cookie" else { continue } - let raw = String(describing: value) - if let cookie = raw - .components(separatedBy: ";") - .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("blastoise_session=") }) { - let clean = cookie.trimmingCharacters(in: .whitespaces) - cookieHeader = clean == "blastoise_session=" ? "" : clean - } - } - } - - private func normalizedBaseURL() -> String { - var value = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) - if value.isEmpty { - value = defaultServer - } - if !value.contains("://") { - value = "http://\(value)" - } - return value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - } - - private func webSocketURL(channelId: String) -> URL? { - guard var components = URLComponents(string: normalizedBaseURL()) else { return nil } - components.scheme = components.scheme == "https" ? "wss" : "ws" - components.path = "/api/channels/\(encodePath(channelId))/ws" - return components.url - } - - private func trackURL(_ trackId: String) -> URL? { - URL(string: normalizedBaseURL() + "/api/tracks/\(encodePath(trackId))") - } - - private func encodePath(_ value: String) -> String { - var allowed = CharacterSet.urlPathAllowed - allowed.remove(charactersIn: ":/?#[]@!$&'()*+,;=") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } - - private func addDebug(_ message: String) { - let stamp = Date().formatted(.dateTime.hour().minute().second()) - debugEvents.append("\(stamp) \(message)") - while debugEvents.count > 12 { - debugEvents.removeFirst() - } - } -} - -private struct AuthView: View { - @ObservedObject var model: AppModel - @Binding var username: String - @Binding var password: String - @FocusState private var focused: Field? - - private enum Field { - case server - case username - case password - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 8) { - Text("BLASTOISE") - .font(Theme.display(40)) - .foregroundStyle(Theme.text) - Text("Tune into a shared room, stream the queue, and keep your local player in sync.") - .foregroundStyle(Theme.muted) - } - - VStack(alignment: .leading, spacing: 12) { - Label("Server", systemImage: "server.rack") - .foregroundStyle(Theme.text) - .font(Theme.headlineFont) - field("http://host:3001", text: $model.serverURL, field: .server) - - HStack(spacing: 10) { - Button { - model.serverURL = "http://mhsgroove.peterino.com:3001" - } label: { - Label("Default", systemImage: "radio") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - - Button { - model.serverURL = "http://localhost:3001" - } label: { - Label("Local", systemImage: "desktopcomputer") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - } - .panel() - - VStack(alignment: .leading, spacing: 12) { - Label("Account", systemImage: "person.crop.circle") - .foregroundStyle(Theme.text) - .font(Theme.headlineFont) - field("username", text: $username, field: .username) - SecureField("password", text: $password) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focused, equals: .password) - .textFieldStyle() - - HStack(spacing: 10) { - Button { - focused = nil - Task { await model.signIn(username: username, password: password) } - } label: { - Label("Sign In", systemImage: "arrow.right.circle") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(Theme.accent) - - Button { - focused = nil - Task { await model.signUp(username: username, password: password) } - } label: { - Label("Sign Up", systemImage: "person.badge.plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - } - .panel() - - StatusStrip(model: model) - } - .padding(18) - .frame(maxWidth: 640, alignment: .topLeading) - } - } - - private func field(_ placeholder: String, text: Binding, field: Field) -> some View { - TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(field == .server ? .URL : .default) - .focused($focused, equals: field) - .textFieldStyle() - } -} - -private struct HeaderView: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(spacing: 10) { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text("BLASTOISE") - .font(Theme.display(28)) - .foregroundStyle(Theme.text) - Text(model.currentUser?.username ?? "signed out") - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - } - - Spacer() - - Button { - Task { await model.connectToServer() } - } label: { - Image(systemName: "arrow.clockwise") - .frame(width: 38, height: 36) - } - .buttonStyle(.bordered) - - Button(role: .destructive) { - Task { await model.logout() } - } label: { - Image(systemName: "rectangle.portrait.and.arrow.right") - .frame(width: 38, height: 36) - } - .buttonStyle(.bordered) - } - - HStack(spacing: 8) { - Image(systemName: "server.rack") - .foregroundStyle(Theme.amber) - TextField("server", text: $model.serverURL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(Theme.mono(12)) - .foregroundStyle(Theme.text) - } - .padding(10) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } - .padding(14) - .background(Theme.background) - } -} - -private struct PlayerDeckView: View { - @ObservedObject var model: AppModel - - var progress: Double { - guard model.trackDuration > 0 else { return 0 } - return min(1, max(0, Double(model.playerPositionMs) / (model.trackDuration * 1000))) - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text(model.sourceMode.rawValue) - .font(Theme.mono(12, weight: .bold)) - .foregroundStyle(Theme.accent) - Text(model.channelName) - .font(Theme.headlineFont) - .foregroundStyle(Theme.text) - Text(model.trackTitle) - .font(Theme.pixel(22, weight: .bold)) - .foregroundStyle(Theme.text) - .lineLimit(2) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - Text(model.playbackMode.uppercased()) - .font(Theme.mono(12)) - .foregroundStyle(Theme.amber) - Text(model.playbackState.uppercased()) - .font(Theme.mono(12)) - .foregroundStyle(model.isPlaying ? Theme.ready : Theme.muted) - } - } - - VStack(alignment: .leading, spacing: 8) { - ProgressView(value: progress) - .tint(Theme.ready) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) - - HStack { - Text(formatTime(model.playerPositionMs)) - Spacer() - Text(formatDuration(model.trackDuration)) - } - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - } - - HStack(spacing: 8) { - iconButton("backward.end.fill") { model.previous() } - iconButton("gobackward.15") { model.seekBy(seconds: -15) } - Button { - model.togglePlay() - } label: { - Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") - .font(Theme.pixel(24, weight: .bold)) - .frame(width: 58, height: 48) - } - .buttonStyle(.borderedProminent) - .tint(Theme.accent) - iconButton("goforward.15") { model.seekBy(seconds: 15) } - iconButton("forward.end.fill") { model.next() } - } - - HStack(spacing: 8) { - actionButton("Mode", icon: "repeat") { - Task { await model.cyclePlaybackMode() } - } - actionButton("Queue", icon: "text.badge.plus") { - Task { await model.queueCurrent(playNext: false) } - } - actionButton("Next", icon: "text.line.first.and.arrowtriangle.forward") { - Task { await model.queueCurrent(playNext: true) } - } - actionButton("Stop", icon: "power") { - model.stopAndExit() - } - } - - HStack(spacing: 12) { - meter("DRIFT", "\(model.driftMs)ms", model.sourceMode == .radio && abs(model.driftMs) > 1800 ? Theme.amber : Theme.ready) - meter("ROOMS", "\(model.channels.count)", Theme.text) - meter("QUEUE", "\(model.queue.count)", Theme.text) - } - } - .panel() - } - - private func iconButton(_ systemName: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemName) - .frame(maxWidth: .infinity, minHeight: 44) - } - .buttonStyle(.bordered) - } - - private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Label(title, systemImage: icon) - .labelStyle(.iconOnly) - .frame(maxWidth: .infinity, minHeight: 38) - } - .buttonStyle(.bordered) - .accessibilityLabel(title) - } - - private func meter(_ label: String, _ value: String, _ color: Color) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(Theme.microFont) - .foregroundStyle(Theme.muted) - Text(value) - .font(Theme.mono(13, weight: .bold)) - .foregroundStyle(color) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } -} - -private struct RoomsPanel: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PanelTitle("Rooms", icon: "radio") - if model.channels.isEmpty { - EmptyLine("No rooms loaded") - } else { - ForEach(model.channels) { channel in - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(channel.name) - .font(Theme.headlineFont) - .foregroundStyle(Theme.text) - if channel.isDefault { - Text("DEFAULT") - .font(Theme.microFont) - .foregroundStyle(Theme.background) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Theme.amber) - .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) - } - } - Text(channel.description.isEmpty ? "\(channel.trackCount) tracks" : channel.description) - .font(Theme.captionFont) - .foregroundStyle(Theme.muted) - Text("\(channel.listenerCount) listener(s)") - .font(Theme.mono(12)) - .foregroundStyle(Theme.ready) - } - Spacer() - Button { - Task { await model.joinChannel(channel.id) } - } label: { - Image(systemName: model.currentChannelId == channel.id ? "checkmark.circle.fill" : "dot.radiowaves.left.and.right") - .frame(width: 44, height: 38) - } - .buttonStyle(.borderedProminent) - .tint(model.currentChannelId == channel.id ? Theme.ready : Theme.accent) - } - .rowStyle(isActive: model.currentChannelId == channel.id) - } - } - } - .panel() - } -} - -private struct QueuePanel: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PanelTitle("Queue", icon: "list.bullet") - if model.queue.isEmpty { - EmptyLine(model.queueLoaded ? "Queue is empty" : "Queue not loaded") - } else { - ForEach(Array(model.queue.prefix(80).enumerated()), id: \.offset) { index, track in - TrackLine( - track: track, - isActive: index == model.currentIndex, - subtitle: "#\(index + 1) \(formatDuration(track.duration))" - ) { - Button { - model.jumpToQueueIndex(index) - } label: { - Image(systemName: "play.fill") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - - Button(role: .destructive) { - Task { await model.removeQueueIndex(index) } - } label: { - Image(systemName: "trash") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - } - } - } - } - .panel() - } -} - -private struct PeoplePanel: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PanelTitle("People", icon: "person.2") - if model.listeners.isEmpty { - EmptyLine("No listener names in this room yet") - } else { - ForEach(model.listeners, id: \.self) { listener in - HStack { - Image(systemName: listener == model.currentUser?.username ? "person.fill.checkmark" : "person.fill") - .foregroundStyle(listener == model.currentUser?.username ? Theme.ready : Theme.muted) - Text(listener) - .foregroundStyle(Theme.text) - Spacer() - if listener == model.currentUser?.username { - Text("YOU") - .font(Theme.microFont) - .foregroundStyle(Theme.ready) - } - } - .rowStyle(isActive: listener == model.currentUser?.username) - } - } - } - .panel() - } -} - -private struct LibraryPanel: View { - @ObservedObject var model: AppModel - @State private var query = "" - @State private var fetchURL = "" - @State private var fileImporterPresented = false - - var matches: [Track] { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let base = model.libraryTracks - if trimmed.isEmpty { - return Array(base.prefix(80)) - } - return Array(base.filter { - $0.title.lowercased().contains(trimmed) || - $0.filename.lowercased().contains(trimmed) || - ($0.artist ?? "").lowercased().contains(trimmed) - }.prefix(80)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PanelTitle("Library", icon: "music.note.list") - importTools - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(Theme.muted) - TextField("Search tracks", text: $query) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .foregroundStyle(Theme.text) - } - .padding(10) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - - if !model.libraryLoaded { - EmptyLine("Loading library") - } else if matches.isEmpty { - EmptyLine("No matching tracks") - } else { - ForEach(matches) { track in - TrackLine( - track: track, - isActive: model.sourceMode == .library && model.currentTrackId == track.id, - subtitle: track.artist ?? track.filename - ) { - Button { - model.playLibraryTrack(track) - } label: { - Image(systemName: "play.fill") - .frame(width: 38, height: 34) - } - .buttonStyle(.borderedProminent) - .tint(Theme.accent) - - Menu { - Button("Add to Queue") { - Task { await model.queueTrack(track, playNext: false) } - } - Button("Play Next") { - Task { await model.queueTrack(track, playNext: true) } - } - } label: { - Image(systemName: "plus") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - } - } - } - } - .panel() - .fileImporter( - isPresented: $fileImporterPresented, - allowedContentTypes: [.audio, .movie], - allowsMultipleSelection: true - ) { result in - switch result { - case .success(let urls): - Task { await model.uploadFiles(urls) } - case .failure(let error): - model.importStatus = "File picker failed: \(error.localizedDescription)" - } - } - } - - private var importTools: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Button { - fileImporterPresented = true - } label: { - Label(model.isUploading ? "Uploading" : "Upload Files", systemImage: "square.and.arrow.up") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(Theme.accent) - .disabled(model.isUploading) - - Button { - Task { await model.loadLibrary() } - } label: { - Image(systemName: "arrow.clockwise") - .frame(width: 42, height: 34) - } - .buttonStyle(.bordered) - .accessibilityLabel("Reload Library") - } - - HStack(spacing: 8) { - Image(systemName: "link") - .foregroundStyle(Theme.muted) - TextField("Fetch from website URL", text: $fetchURL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .foregroundStyle(Theme.text) - Button { - Task { await model.fetchFromWebsite(fetchURL) } - } label: { - Image(systemName: model.isFetching ? "hourglass" : "arrow.down.circle") - .frame(width: 40, height: 34) - } - .buttonStyle(.bordered) - .disabled(model.isFetching) - .accessibilityLabel("Fetch URL") - } - .padding(10) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - - if let playlist = model.pendingFetchPlaylist { - VStack(alignment: .leading, spacing: 8) { - Text("Playlist found") - .font(Theme.mono(12, weight: .bold)) - .foregroundStyle(Theme.amber) - Text("\(playlist.title) ยท \(playlist.count) items") - .font(Theme.pixel(16, weight: .semibold)) - .foregroundStyle(Theme.text) - .lineLimit(2) - HStack(spacing: 8) { - Button { - Task { await model.confirmFetchPlaylist() } - } label: { - Label("Queue Playlist", systemImage: "checkmark.circle") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(Theme.ready) - .disabled(model.isFetching) - - Button { - model.cancelFetchPlaylist() - } label: { - Label("Cancel", systemImage: "xmark") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - } - .padding(10) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } - - if !model.importStatus.isEmpty { - Text(model.importStatus) - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - .lineLimit(2) - } - } - } -} - -private struct PlaylistsPanel: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - PanelTitle("Playlists", icon: "rectangle.stack") - if model.allPlaylists.isEmpty { - EmptyLine(model.playlistsLoaded ? "No playlists" : "Loading playlists") - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(model.allPlaylists.prefix(40)) { playlist in - let isSelected = model.selectedPlaylistId == playlist.id - Button { - Task { await model.loadPlaylist(playlist.id) } - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(playlist.name) - .font(Theme.pixel(16, weight: .bold)) - .foregroundStyle(isSelected ? Theme.background : Theme.text) - .lineLimit(1) - Text("\(playlist.trackIds.count) tracks") - .font(Theme.mono(12)) - .foregroundStyle(isSelected ? Theme.background.opacity(0.72) : Theme.muted) - } - .frame(width: 150, alignment: .leading) - .padding(10) - } - .buttonStyle(.plain) - .background(isSelected ? Theme.accent : Theme.panel2) - .overlay( - RoundedRectangle(cornerRadius: Theme.corner) - .stroke(isSelected ? Theme.text : Theme.stroke.opacity(0.38), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } - } - } - } - - if let playlist = model.selectedPlaylist { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(playlist.name) - .font(Theme.headlineFont) - .foregroundStyle(Theme.text) - Text(playlist.ownerName.isEmpty ? "\(playlist.trackIds.count) tracks" : "by \(playlist.ownerName)") - .font(Theme.captionFont) - .foregroundStyle(Theme.muted) - } - Spacer() - Button { - Task { await model.addPlaylistToQueue(playlist, playNext: false) } - } label: { - Image(systemName: "text.badge.plus") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - Button { - Task { await model.addPlaylistToQueue(playlist, playNext: true) } - } label: { - Image(systemName: "text.line.first.and.arrowtriangle.forward") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - } - - ForEach(Array(playlist.trackIds.prefix(80).enumerated()), id: \.offset) { index, trackId in - let track = model.track(for: trackId) ?? Track(id: trackId, filename: trackId, title: trackId, duration: 0) - TrackLine( - track: track, - isActive: model.currentTrackId == track.id, - subtitle: "#\(index + 1)" - ) { - Button { - Task { await model.queueTrack(track, playNext: false) } - } label: { - Image(systemName: "plus") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - Button { - Task { await model.queueTrack(track, playNext: true) } - } label: { - Image(systemName: "arrow.up.to.line") - .frame(width: 38, height: 34) - } - .buttonStyle(.bordered) - } - } - } - } - .panel() - } -} - -private struct DebugPanel: View { - @ObservedObject var model: AppModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - PanelTitle("Diagnostics", icon: "waveform.path.ecg") - debugRow("Server", model.serverURL) - debugRow("Auth", model.authState.rawValue) - debugRow("User", model.currentUser?.username ?? "-") - debugRow("Room", model.currentChannelId ?? "-") - debugRow("Track", model.currentTrackId ?? "-") - debugRow("Expected", "\(model.expectedPositionMs)ms") - debugRow("Player", "\(model.playerPositionMs)ms") - debugRow("Drift", "\(model.driftMs)ms") - Divider().overlay(Theme.stroke) - ForEach(model.debugEvents, id: \.self) { event in - Text(event) - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .panel() - } - - private func debugRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .top) { - Text(label) - .foregroundStyle(Theme.muted) - .frame(width: 78, alignment: .leading) - Text(value) - .foregroundStyle(Theme.text) - .textSelection(.enabled) - Spacer(minLength: 0) - } - .font(Theme.mono(12)) - } -} - -private struct DebugFooterView: View { - @ObservedObject var model: AppModel - - var body: some View { - HStack(spacing: 8) { - Circle() - .fill(model.authState == .signedIn ? Theme.ready : Theme.amber) - .frame(width: 8, height: 8) - Text(model.status) - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - .lineLimit(1) - Spacer() - } - .padding(.horizontal, 2) - } -} - -private struct StatusStrip: View { - @ObservedObject var model: AppModel - - var body: some View { - HStack(spacing: 8) { - Circle() - .fill(model.authState == .checking ? Theme.amber : model.authState == .signedIn ? Theme.ready : Theme.red) - .frame(width: 10, height: 10) - Text(model.status) - .font(Theme.mono(12)) - .foregroundStyle(Theme.muted) - Spacer() - } - .panel() - } -} - -private struct PanelTitle: View { - private let title: String - private let icon: String - - init(_ title: String, icon: String) { - self.title = title - self.icon = icon - } - - var body: some View { - Label(title, systemImage: icon) - .font(Theme.headlineFont) - .foregroundStyle(Theme.text) - } -} - -private struct EmptyLine: View { - private let text: String - - init(_ text: String) { - self.text = text - } - - var body: some View { - Text(text) - .font(Theme.captionFont) - .foregroundStyle(Theme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Theme.panel2) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } -} - -private struct TrackLine: View { - let track: Track - let isActive: Bool - let subtitle: String - @ViewBuilder let actions: () -> Actions - - var body: some View { - HStack(spacing: 10) { - Rectangle() - .fill(isActive ? Theme.ready : Theme.amber) - .frame(width: 4) - .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) - - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(Theme.pixel(16, weight: .semibold)) - .foregroundStyle(Theme.text) - .lineLimit(2) - Text(subtitle) - .font(Theme.captionFont) - .foregroundStyle(Theme.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - actions() - } - .rowStyle(isActive: isActive) - } -} - -private extension View { - func panel() -> some View { - self - .padding(14) - .background(Theme.panel) - .overlay( - RoundedRectangle(cornerRadius: Theme.corner) - .stroke(Theme.stroke, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } - - func rowStyle(isActive: Bool = false) -> some View { - self - .padding(10) - .background(isActive ? Theme.panel2.opacity(1.0) : Theme.panel2.opacity(0.76)) - .overlay( - RoundedRectangle(cornerRadius: Theme.corner) - .stroke(isActive ? Theme.accent : Theme.stroke.opacity(0.38), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } - - func textFieldStyle() -> some View { - self - .padding(12) - .foregroundStyle(Theme.text) - .background(Theme.panel2) - .overlay( - RoundedRectangle(cornerRadius: Theme.corner) - .stroke(Theme.stroke, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) - } -} - -private func formatTime(_ ms: Int64) -> String { - let total = max(0, Int(ms / 1000)) - return "\(total / 60):" + String(format: "%02d", total % 60) -} - -private func formatDuration(_ duration: TimeInterval) -> String { - guard duration.isFinite, duration > 0 else { return "--:--" } - return formatTime(Int64(duration * 1000)) -} - -private enum APIError: LocalizedError { - case invalidURL - case http(Int, String) - - var errorDescription: String? { - switch self { - case .invalidURL: - return "Invalid URL" - case .http(let status, let body): - return "HTTP \(status): \(body)" - } - } -} - -private struct Track: Codable, Hashable, Identifiable { - var id: String - var filename: String - var title: String - var duration: Double - var artist: String? - var album: String? - var available: Bool? - - init( - id: String, - filename: String, - title: String, - duration: Double, - artist: String? = nil, - album: String? = nil, - available: Bool? = nil - ) { - self.id = id - self.filename = filename - self.title = title - self.duration = duration - self.artist = artist - self.album = album - self.available = available - } -} - -private struct ChannelInfo: Decodable, Identifiable { - let id: String - let name: String - let description: String - let listenerCount: Int - let isDefault: Bool - let trackCount: Int - let listeners: [String] - - enum CodingKeys: String, CodingKey { - case id - case name - case description - case listenerCount - case isDefault - case trackCount - case listeners - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Room" - description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" - listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 - isDefault = try c.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false - trackCount = try c.decodeIfPresent(Int.self, forKey: .trackCount) ?? 0 - listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] - } -} - -private struct ChannelState: Decodable { - let track: Track? - let currentTimestamp: Double - let channelName: String - let channelId: String - let paused: Bool - let queue: [Track]? - let currentIndex: Int - let playbackMode: String - let listenerCount: Int - let listeners: [String] - - enum CodingKeys: String, CodingKey { - case track - case currentTimestamp - case channelName - case channelId - case paused - case queue - case currentIndex - case playbackMode - case listenerCount - case listeners - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - track = try c.decodeIfPresent(Track.self, forKey: .track) - currentTimestamp = try c.decodeIfPresent(Double.self, forKey: .currentTimestamp) ?? 0 - channelName = try c.decodeIfPresent(String.self, forKey: .channelName) ?? "" - channelId = try c.decodeIfPresent(String.self, forKey: .channelId) ?? "" - paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? true - queue = try c.decodeIfPresent([Track].self, forKey: .queue) - currentIndex = try c.decodeIfPresent(Int.self, forKey: .currentIndex) ?? 0 - playbackMode = try c.decodeIfPresent(String.self, forKey: .playbackMode) ?? "repeat-all" - listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 - listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] - } -} - -private struct PlaylistBundle: Decodable { - let mine: [Playlist] - let shared: [Playlist] -} - -private struct Playlist: Decodable, Identifiable { - let id: String - let name: String - let description: String - let ownerId: Int - let ownerName: String - let isPublic: Bool - let shareToken: String? - let trackIds: [String] - - enum CodingKeys: String, CodingKey { - case id - case name - case description - case ownerId - case ownerName - case isPublic - case shareToken - case trackIds - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Playlist" - description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" - ownerId = try c.decodeIfPresent(Int.self, forKey: .ownerId) ?? 0 - ownerName = try c.decodeIfPresent(String.self, forKey: .ownerName) ?? "" - isPublic = try c.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false - shareToken = try c.decodeIfPresent(String.self, forKey: .shareToken) - trackIds = try c.decodeIfPresent([String].self, forKey: .trackIds) ?? [] - } -} - -private struct UserSession: Decodable { - let id: Int - let username: String - let isAdmin: Bool - let isGuest: Bool - let permissions: [Permission] - - enum CodingKeys: String, CodingKey { - case id - case username - case isAdmin - case isAdminSnake = "is_admin" - case isGuest - case isGuestSnake = "is_guest" - case permissions - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0 - username = try c.decodeIfPresent(String.self, forKey: .username) ?? "guest" - isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) - ?? c.decodeIfPresent(Bool.self, forKey: .isAdminSnake) - ?? false - isGuest = try c.decodeIfPresent(Bool.self, forKey: .isGuest) - ?? c.decodeIfPresent(Bool.self, forKey: .isGuestSnake) - ?? false - permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] - } -} - -private struct Permission: Decodable { - let resourceType: String - let resourceId: String? - let permission: String - - enum CodingKeys: String, CodingKey { - case resourceType - case resourceTypeSnake = "resource_type" - case resourceId - case resourceIdSnake = "resource_id" - case permission - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - resourceType = try c.decodeIfPresent(String.self, forKey: .resourceType) - ?? c.decodeIfPresent(String.self, forKey: .resourceTypeSnake) - ?? "" - resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId) - ?? c.decodeIfPresent(String.self, forKey: .resourceIdSnake) - permission = try c.decodeIfPresent(String.self, forKey: .permission) ?? "" - } -} - -private struct AuthEnvelope: Decodable { - let user: UserSession? - let permissions: [Permission] - - enum CodingKeys: String, CodingKey { - case user - case permissions - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - user = try c.decodeIfPresent(UserSession.self, forKey: .user) - permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] - } -} - -private struct QueueResponse: Decodable { - let success: Bool? - let queueLength: Int? -} - -private struct ModeResponse: Decodable { - let success: Bool? - let playbackMode: String? -} - -private struct FetchItem: Codable, Hashable { - let id: String? - let url: String - let title: String -} - -private struct FetchPlaylistResponse: Decodable { - let type: String - let title: String - let count: Int - let items: [FetchItem] - let requiresConfirmation: Bool? -} - -private struct FetchSingleResponse: Decodable { - let type: String - let id: String? - let title: String - let queueType: String? -} - -private enum FetchResponse: Decodable { - case single(FetchSingleResponse) - case playlist(FetchPlaylistResponse) - - enum CodingKeys: String, CodingKey { - case type - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - let type = try c.decodeIfPresent(String.self, forKey: .type) - switch type { - case "playlist": - self = .playlist(try FetchPlaylistResponse(from: decoder)) - default: - self = .single(try FetchSingleResponse(from: decoder)) - } - } -} - -private struct FetchConfirmResponse: Decodable { - let message: String - let queueType: String? - let estimatedTime: String? - let playlistId: String? - let playlistName: String? - let items: [FetchItem]? -} - -private extension Data { - mutating func appendString(_ value: String) { - append(Data(value.utf8)) - } -} diff --git a/ios/BlastoisePing/BlastoisePing/Models/AppTypes.swift b/ios/BlastoisePing/BlastoisePing/Models/AppTypes.swift new file mode 100644 index 0000000..5d9db0b --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Models/AppTypes.swift @@ -0,0 +1,298 @@ +import Foundation + +enum SourceMode: String { + case radio = "RADIO" + case library = "LIBRARY" +} + +enum AuthState: String { + case checking = "CHECKING" + case signedOut = "SIGNED OUT" + case signedIn = "SIGNED IN" +} + +enum APIError: LocalizedError { + case invalidURL + case file(String) + case http(Int, String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .file(let message): + return message + case .http(let status, let body): + return "HTTP \(status): \(body)" + } + } +} + +struct Track: Codable, Hashable, Identifiable { + var id: String + var filename: String + var title: String + var duration: Double + var artist: String? + var album: String? + var available: Bool? + + init( + id: String, + filename: String, + title: String, + duration: Double, + artist: String? = nil, + album: String? = nil, + available: Bool? = nil + ) { + self.id = id + self.filename = filename + self.title = title + self.duration = duration + self.artist = artist + self.album = album + self.available = available + } +} + +struct ChannelInfo: Decodable, Identifiable { + let id: String + let name: String + let description: String + let listenerCount: Int + let isDefault: Bool + let trackCount: Int + let listeners: [String] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case listenerCount + case isDefault + case trackCount + case listeners + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Room" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 + isDefault = try c.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false + trackCount = try c.decodeIfPresent(Int.self, forKey: .trackCount) ?? 0 + listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] + } +} + +struct ChannelState: Decodable { + let track: Track? + let currentTimestamp: Double + let channelName: String + let channelId: String + let paused: Bool + let queue: [Track]? + let currentIndex: Int + let playbackMode: String + let listenerCount: Int + let listeners: [String] + + enum CodingKeys: String, CodingKey { + case track + case currentTimestamp + case channelName + case channelId + case paused + case queue + case currentIndex + case playbackMode + case listenerCount + case listeners + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + track = try c.decodeIfPresent(Track.self, forKey: .track) + currentTimestamp = try c.decodeIfPresent(Double.self, forKey: .currentTimestamp) ?? 0 + channelName = try c.decodeIfPresent(String.self, forKey: .channelName) ?? "" + channelId = try c.decodeIfPresent(String.self, forKey: .channelId) ?? "" + paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? true + queue = try c.decodeIfPresent([Track].self, forKey: .queue) + currentIndex = try c.decodeIfPresent(Int.self, forKey: .currentIndex) ?? 0 + playbackMode = try c.decodeIfPresent(String.self, forKey: .playbackMode) ?? "repeat-all" + listenerCount = try c.decodeIfPresent(Int.self, forKey: .listenerCount) ?? 0 + listeners = try c.decodeIfPresent([String].self, forKey: .listeners) ?? [] + } +} + +struct PlaylistBundle: Decodable { + let mine: [Playlist] + let shared: [Playlist] +} + +struct Playlist: Decodable, Identifiable { + let id: String + let name: String + let description: String + let ownerId: Int + let ownerName: String + let isPublic: Bool + let shareToken: String? + let trackIds: [String] + + enum CodingKeys: String, CodingKey { + case id + case name + case description + case ownerId + case ownerName + case isPublic + case shareToken + case trackIds + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Playlist" + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + ownerId = try c.decodeIfPresent(Int.self, forKey: .ownerId) ?? 0 + ownerName = try c.decodeIfPresent(String.self, forKey: .ownerName) ?? "" + isPublic = try c.decodeIfPresent(Bool.self, forKey: .isPublic) ?? false + shareToken = try c.decodeIfPresent(String.self, forKey: .shareToken) + trackIds = try c.decodeIfPresent([String].self, forKey: .trackIds) ?? [] + } +} + +struct UserSession: Decodable { + let id: Int + let username: String + let isAdmin: Bool + let isGuest: Bool + let permissions: [Permission] + + enum CodingKeys: String, CodingKey { + case id + case username + case isAdmin + case isAdminSnake = "is_admin" + case isGuest + case isGuestSnake = "is_guest" + case permissions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0 + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "guest" + isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) + ?? c.decodeIfPresent(Bool.self, forKey: .isAdminSnake) + ?? false + isGuest = try c.decodeIfPresent(Bool.self, forKey: .isGuest) + ?? c.decodeIfPresent(Bool.self, forKey: .isGuestSnake) + ?? false + permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] + } +} + +struct Permission: Decodable { + let resourceType: String + let resourceId: String? + let permission: String + + enum CodingKeys: String, CodingKey { + case resourceType + case resourceTypeSnake = "resource_type" + case resourceId + case resourceIdSnake = "resource_id" + case permission + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + resourceType = try c.decodeIfPresent(String.self, forKey: .resourceType) + ?? c.decodeIfPresent(String.self, forKey: .resourceTypeSnake) + ?? "" + resourceId = try c.decodeIfPresent(String.self, forKey: .resourceId) + ?? c.decodeIfPresent(String.self, forKey: .resourceIdSnake) + permission = try c.decodeIfPresent(String.self, forKey: .permission) ?? "" + } +} + +struct AuthEnvelope: Decodable { + let user: UserSession? + let permissions: [Permission] + + enum CodingKeys: String, CodingKey { + case user + case permissions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + user = try c.decodeIfPresent(UserSession.self, forKey: .user) + permissions = try c.decodeIfPresent([Permission].self, forKey: .permissions) ?? [] + } +} + +struct QueueResponse: Decodable { + let success: Bool? + let queueLength: Int? +} + +struct ModeResponse: Decodable { + let success: Bool? + let playbackMode: String? +} + +struct FetchItem: Codable, Hashable { + let id: String? + let url: String + let title: String +} + +struct FetchPlaylistResponse: Decodable { + let type: String + let title: String + let count: Int + let items: [FetchItem] + let requiresConfirmation: Bool? +} + +struct FetchSingleResponse: Decodable { + let type: String + let id: String? + let title: String + let queueType: String? +} + +enum FetchResponse: Decodable { + case single(FetchSingleResponse) + case playlist(FetchPlaylistResponse) + + enum CodingKeys: String, CodingKey { + case type + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decodeIfPresent(String.self, forKey: .type) + switch type { + case "playlist": + self = .playlist(try FetchPlaylistResponse(from: decoder)) + default: + self = .single(try FetchSingleResponse(from: decoder)) + } + } +} + +struct FetchConfirmResponse: Decodable { + let message: String + let queueType: String? + let estimatedTime: String? + let playlistId: String? + let playlistName: String? + let items: [FetchItem]? +} diff --git a/ios/BlastoisePing/BlastoisePing/State/AppModel.swift b/ios/BlastoisePing/BlastoisePing/State/AppModel.swift new file mode 100644 index 0000000..a503188 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/State/AppModel.swift @@ -0,0 +1,1153 @@ +import AVFoundation +import Foundation +import SwiftUI + +@MainActor +final class AppModel: ObservableObject { + private let defaultServer = "http://mhsgroove.peterino.com:3001" + private let userAgent = "BlastoiseiOSSketch/0.1" + private let decoder = JSONDecoder() + private let player = AVPlayer() + + private var cookieHeader: String { + didSet { UserDefaults.standard.set(cookieHeader, forKey: "blastoise.cookie") } + } + private var webSocket: URLSessionWebSocketTask? + private var receiveTask: Task? + private var reconnectTask: Task? + private var tickerTask: Task? + private var endObserver: NSObjectProtocol? + private var reconnectAttempts = 0 + private var intentionalDisconnect = false + private var lastLibraryEndTrackId: String? + + @Published var serverURL: String { + didSet { UserDefaults.standard.set(serverURL, forKey: "blastoise.serverURL") } + } + @Published var authState: AuthState = .checking + @Published var currentUser: UserSession? + @Published var status = "Starting" + @Published var channels: [ChannelInfo] = [] + @Published var libraryTracks: [Track] = [] + @Published var libraryLoaded = false + @Published var myPlaylists: [Playlist] = [] + @Published var sharedPlaylists: [Playlist] = [] + @Published var playlistsLoaded = false + @Published var selectedPlaylistId: String? + @Published var selectedPlaylist: Playlist? + @Published var sourceMode: SourceMode = .radio + @Published var currentChannelId: String? + @Published var currentTrackId: String? + @Published var localLibraryIndex = -1 + @Published var listeners: [String] = [] + @Published var paused = true + @Published var queue: [Track] = [] + @Published var queueLoaded = false + @Published var currentIndex = 0 + @Published var playbackMode = "repeat-all" + @Published var channelName = "No room" + @Published var trackTitle = "No track" + @Published var trackDuration: TimeInterval = 0 + @Published var serverTimestampMs: Int64 = 0 + @Published var stateMonotonicTime: TimeInterval = 0 + @Published var expectedPositionMs: Int64 = 0 + @Published var playerPositionMs: Int64 = 0 + @Published var driftMs: Int64 = 0 + @Published var playbackState = "idle" + @Published var isPlaying = false + @Published var debugEvents: [String] = [] + @Published var isUploading = false + @Published var isFetching = false + @Published var importStatus = "" + @Published var pendingFetchPlaylist: FetchPlaylistResponse? + + var allPlaylists: [Playlist] { myPlaylists + sharedPlaylists } + + init() { + serverURL = UserDefaults.standard.string(forKey: "blastoise.serverURL") ?? defaultServer + cookieHeader = UserDefaults.standard.string(forKey: "blastoise.cookie") ?? "" + configureAudioSession() + startTicker() + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.handlePlaybackEnded() + } + } + Task { await validateSession() } + } + + deinit { + tickerTask?.cancel() + receiveTask?.cancel() + reconnectTask?.cancel() + webSocket?.cancel(with: .goingAway, reason: nil) + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + } + + func validateSession() async { + if cookieHeader.isEmpty { + authState = .signedOut + status = "Sign in required" + return + } + + authState = .checking + status = "Checking session" + do { + let session = try await me() + if let user = session, !user.isGuest { + await becomeSignedIn(user) + } else { + clearSession() + status = "Sign in required" + } + } catch { + clearSession() + status = "Session expired" + addDebug("me failed: \(error.localizedDescription)") + } + } + + func signIn(username: String, password: String) async { + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + status = "Username required" + return + } + authState = .checking + status = "Signing in" + do { + let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] + let envelope: AuthEnvelope = try await request("/api/auth/login", method: "POST", json: body) + if let user = envelope.user { + await becomeSignedIn(user) + } else { + status = "Login returned no user" + authState = .signedOut + } + } catch { + authState = .signedOut + status = "Login failed" + addDebug("login failed: \(error.localizedDescription)") + } + } + + func signUp(username: String, password: String) async { + guard password.count >= 6 else { + status = "Password must be 6+ chars" + return + } + authState = .checking + status = "Signing up" + do { + let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password] + let envelope: AuthEnvelope = try await request("/api/auth/signup", method: "POST", json: body) + if let user = envelope.user { + await becomeSignedIn(user) + } else { + status = "Signup returned no user" + authState = .signedOut + } + } catch { + authState = .signedOut + status = "Signup failed" + addDebug("signup failed: \(error.localizedDescription)") + } + } + + func logout() async { + intentionalDisconnect = true + closeSocket() + player.pause() + player.replaceCurrentItem(with: nil) + _ = try? await requestRaw("/api/auth/logout", method: "POST", json: [:]) + clearSession() + resetPlayback() + status = "Signed out" + } + + func connectToServer() async { + if authState == .signedIn { + status = "Reloading" + await loadInitialData() + } else { + await validateSession() + } + } + + func loadLibraryIfNeeded() async { + if !libraryLoaded { + await loadLibrary() + } + } + + func loadPlaylistsIfNeeded() async { + if !playlistsLoaded { + await loadPlaylists() + } + } + + func loadLibrary() async { + guard requireSignedIn("Sign in to browse library") else { return } + do { + libraryTracks = try await request("/api/library") + libraryLoaded = true + status = "Library loaded" + addDebug("library \(libraryTracks.count)") + } catch { + status = "Library failed" + addDebug("library failed: \(error.localizedDescription)") + } + } + + func loadPlaylists() async { + guard requireSignedIn("Sign in to load playlists") else { return } + do { + let bundle: PlaylistBundle = try await request("/api/playlists") + myPlaylists = bundle.mine + sharedPlaylists = bundle.shared + playlistsLoaded = true + if selectedPlaylistId == nil { + selectedPlaylistId = allPlaylists.first?.id + } + if let id = selectedPlaylistId { + await loadPlaylist(id) + } + status = "Playlists loaded" + } catch { + status = "Playlists failed" + addDebug("playlists failed: \(error.localizedDescription)") + } + } + + func loadPlaylist(_ id: String) async { + guard requireSignedIn("Sign in to load playlists") else { return } + selectedPlaylistId = id + selectedPlaylist = nil + do { + selectedPlaylist = try await request("/api/playlists/\(encodePath(id))") + } catch { + status = "Playlist failed" + addDebug("playlist failed: \(error.localizedDescription)") + } + } + + func uploadFiles(_ urls: [URL]) async { + guard requireSignedIn("Sign in to upload") else { return } + guard !urls.isEmpty else { return } + + isUploading = true + importStatus = "Uploading \(urls.count) file(s)" + var uploaded = 0 + var failed = 0 + + for url in urls { + do { + try await uploadFile(url) + uploaded += 1 + importStatus = "Uploaded \(uploaded)/\(urls.count)" + } catch { + failed += 1 + importStatus = "Upload failed: \(error.localizedDescription)" + addDebug("upload failed: \(url.lastPathComponent)") + } + } + + isUploading = false + importStatus = failed == 0 ? "Uploaded \(uploaded) file(s)" : "Uploaded \(uploaded), failed \(failed)" + if uploaded > 0 { + await loadLibrary() + } + } + + func fetchFromWebsite(_ urlString: String) async { + guard requireSignedIn("Sign in to fetch URLs") else { return } + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + importStatus = "Enter a URL" + return + } + + isFetching = true + pendingFetchPlaylist = nil + importStatus = "Checking URL" + do { + let response: FetchResponse = try await request("/api/fetch", method: "POST", json: ["url": trimmed]) + switch response { + case .single(let item): + importStatus = "Queued: \(item.title)" + addDebug("fetch queued \(item.title.prefix(36))") + case .playlist(let playlist): + pendingFetchPlaylist = playlist + importStatus = "Playlist found: \(playlist.title)" + } + } catch { + importStatus = "Fetch failed" + addDebug("fetch failed: \(error.localizedDescription)") + } + isFetching = false + } + + func confirmFetchPlaylist() async { + guard let playlist = pendingFetchPlaylist else { return } + guard requireSignedIn("Sign in to fetch URLs") else { return } + + isFetching = true + importStatus = "Queueing playlist" + do { + let body: [String: Any] = [ + "items": playlist.items.map { ["url": $0.url, "title": $0.title] }, + "playlistTitle": playlist.title + ] + let response: FetchConfirmResponse = try await request("/api/fetch/confirm", method: "POST", json: body) + pendingFetchPlaylist = nil + importStatus = "\(response.message) -> \(response.playlistName ?? "playlist")" + await loadPlaylists() + } catch { + importStatus = "Playlist fetch failed" + addDebug("fetch confirm failed: \(error.localizedDescription)") + } + isFetching = false + } + + func cancelFetchPlaylist() { + pendingFetchPlaylist = nil + importStatus = "Playlist fetch cancelled" + } + + func joinChannel(_ id: String) async { + guard requireSignedIn("Sign in to join rooms") else { return } + sourceMode = .radio + intentionalDisconnect = false + localLibraryIndex = -1 + currentChannelId = id + status = "Joining" + addDebug("join \(id)") + connectWebSocket(channelId: id) + await loadChannelState(id) + } + + func playLibraryTrack(_ track: Track) { + guard let index = libraryTracks.firstIndex(where: { $0.id == track.id }) else { return } + sourceMode = .library + intentionalDisconnect = true + closeSocket() + localLibraryIndex = index + currentTrackId = track.id + trackTitle = track.title + trackDuration = track.duration + channelName = "Library" + paused = false + status = "Local" + serverTimestampMs = 0 + stateMonotonicTime = 0 + lastLibraryEndTrackId = nil + replacePlayerItem(track: track, positionMs: 0) + player.play() + addDebug("local play \(track.title.prefix(36))") + tick() + } + + func togglePlay() { + if sourceMode == .radio { + sendSocket(["action": paused ? "unpause" : "pause"]) + status = paused ? "Unpause sent" : "Pause sent" + return + } + + if player.timeControlStatus == .playing { + player.pause() + paused = true + } else { + if localLibraryIndex < 0, let first = libraryTracks.first { + playLibraryTrack(first) + return + } + player.play() + paused = false + } + tick() + } + + func previous() { + if sourceMode == .radio { + jumpRadio(delta: -1) + } else { + guard !libraryTracks.isEmpty else { return } + let nextIndex: Int + if playbackMode == "shuffle", libraryTracks.count > 1 { + nextIndex = randomLibraryIndex() + } else if localLibraryIndex <= 0 { + nextIndex = libraryTracks.count - 1 + } else { + nextIndex = localLibraryIndex - 1 + } + playLibraryTrack(libraryTracks[nextIndex]) + } + } + + func next() { + if sourceMode == .radio { + jumpRadio(delta: 1) + } else if !libraryTracks.isEmpty { + playLibraryTrack(libraryTracks[nextLibraryIndex()]) + } + } + + func seekBy(seconds: Int) { + let durationMs = max(Int64(trackDuration * 1000), playerPositionMs) + let target = max(Int64(0), min(durationMs, playerPositionMs + Int64(seconds * 1000))) + seek(to: Double(target) / 1000) + } + + func seek(to seconds: Double) { + let target = max(0, seconds) + if sourceMode == .radio { + sendSocket(["action": "seek", "timestamp": target]) + status = "Seek sent" + } else { + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) + } + tick() + } + + func cyclePlaybackMode() async { + let modes = ["once", "repeat-all", "repeat-one", "shuffle"] + let current = modes.firstIndex(of: playbackMode) ?? 0 + let next = modes[(current + 1) % modes.count] + playbackMode = next + + if sourceMode == .radio { + guard let channelId = currentChannelId else { + status = "No room" + return + } + do { + let _: ModeResponse = try await request( + "/api/channels/\(encodePath(channelId))/mode", + method: "POST", + json: ["mode": next] + ) + status = "Mode \(modeLabel(next))" + } catch { + status = "Mode failed" + addDebug("mode failed: \(error.localizedDescription)") + } + } else { + status = "Mode \(modeLabel(next))" + } + } + + func queueCurrent(playNext: Bool) async { + guard let trackId = currentTrackId else { + status = "No track" + return + } + await queueTrackIds([trackId], playNext: playNext) + } + + func queueTrack(_ track: Track, playNext: Bool) async { + await queueTrackIds([track.id], playNext: playNext) + } + + func addPlaylistToQueue(_ playlist: Playlist, playNext: Bool) async { + guard !playlist.trackIds.isEmpty else { + status = "Empty playlist" + return + } + await queueTrackIds(playlist.trackIds, playNext: playNext) + } + + func jumpToQueueIndex(_ index: Int) { + guard index >= 0, index < queue.count else { return } + sendSocket(["action": "jump", "index": index]) + status = "Jump sent" + } + + func removeQueueIndex(_ index: Int) async { + guard let channelId = currentChannelId else { + status = "No room" + return + } + do { + let _: QueueResponse = try await request( + "/api/channels/\(encodePath(channelId))/queue", + method: "PATCH", + json: ["remove": [index]] + ) + status = "Removed" + await loadChannelState(channelId) + } catch { + status = "Remove failed" + addDebug("remove failed: \(error.localizedDescription)") + } + } + + func stopAndExit() { + intentionalDisconnect = true + closeSocket() + player.pause() + player.replaceCurrentItem(with: nil) + resetPlayback() + status = "Stopped" + } + + func track(for id: String) -> Track? { + libraryTracks.first { $0.id == id } ?? queue.first { $0.id == id } + } + + private func becomeSignedIn(_ user: UserSession) async { + currentUser = user + authState = user.isGuest ? .signedOut : .signedIn + if user.isGuest { + status = "Sign in required" + return + } + status = "Signed in as \(user.username)" + addDebug("auth \(user.username)") + await loadInitialData() + } + + private func loadInitialData() async { + async let channelsLoad: Void = loadChannels() + async let libraryLoad: Void = loadLibrary() + async let playlistsLoad: Void = loadPlaylists() + _ = await (channelsLoad, libraryLoad, playlistsLoad) + } + + private func loadChannels() async { + guard requireSignedIn("Sign in to load rooms") else { return } + do { + channels = try await request("/api/channels") + addDebug("rooms \(channels.count)") + let target = currentChannelId + ?? channels.first(where: { $0.isDefault })?.id + ?? channels.first?.id + if let target, currentChannelId == nil { + await joinChannel(target) + } + } catch { + status = "Rooms failed" + addDebug("rooms failed: \(error.localizedDescription)") + } + } + + private func loadChannelState(_ channelId: String) async { + do { + let state: ChannelState = try await request("/api/channels/\(encodePath(channelId))") + handleChannelState(state) + } catch { + addDebug("room state failed: \(error.localizedDescription)") + } + } + + private func queueTrackIds(_ trackIds: [String], playNext: Bool) async { + guard requireSignedIn("Sign in to change queue") else { return } + guard let channelId = currentChannelId + ?? channels.first(where: { $0.isDefault })?.id + ?? channels.first?.id else { + status = "No room" + return + } + let insertAt = playNext ? max(0, currentIndex + 1) : nil + var body: [String: Any] = ["add": trackIds] + if let insertAt { + body["insertAt"] = insertAt + } + do { + let _: QueueResponse = try await request( + "/api/channels/\(encodePath(channelId))/queue", + method: "PATCH", + json: body + ) + status = playNext ? "Queued next" : "Queued" + currentChannelId = channelId + await loadChannelState(channelId) + await loadChannels() + } catch { + status = "Queue failed" + addDebug("queue failed: \(error.localizedDescription)") + } + } + + private func me() async throws -> UserSession? { + let envelope: AuthEnvelope = try await request("/api/auth/me") + return envelope.user + } + + private func request( + _ path: String, + method: String = "GET", + json: Any? = nil + ) async throws -> T { + let data = try await requestRaw(path, method: method, json: json) + return try decoder.decode(T.self, from: data) + } + + private func requestRaw( + _ path: String, + method: String = "GET", + json: Any? = nil + ) async throws -> Data { + guard let url = URL(string: normalizedBaseURL() + path) else { + throw APIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 12 + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + if let json { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: json) + } + + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse { + captureCookie(http) + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw APIError.http(http.statusCode, text) + } + } + return data + } + + private func uploadFile(_ fileURL: URL) async throws { + guard let url = URL(string: normalizedBaseURL() + "/api/upload") else { + throw APIError.invalidURL + } + + let didAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + let boundary = "BlastoiseBoundary-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 120 + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + + let bodyFile = try makeMultipartUploadFile( + boundary: boundary, + fieldName: "file", + filename: fileURL.lastPathComponent, + contentType: contentType(for: fileURL), + fileURL: fileURL + ) + defer { try? FileManager.default.removeItem(at: bodyFile) } + + let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyFile) + if let http = response as? HTTPURLResponse { + captureCookie(http) + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw APIError.http(http.statusCode, text) + } + } + } + + private func makeMultipartUploadFile( + boundary: String, + fieldName: String, + filename: String, + contentType: String, + fileURL: URL + ) throws -> URL { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("BlastoiseUpload-\(UUID().uuidString)") + .appendingPathExtension("tmp") + + guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else { + throw APIError.file("Could not create upload body") + } + + do { + let output = try FileHandle(forWritingTo: tempURL) + defer { try? output.close() } + + let input = try FileHandle(forReadingFrom: fileURL) + defer { try? input.close() } + + let header = "--\(boundary)\r\n" + + "Content-Disposition: form-data; name=\"\(multipartEscaped(fieldName))\"; filename=\"\(multipartEscaped(filename))\"\r\n" + + "Content-Type: \(contentType)\r\n\r\n" + try output.write(contentsOf: Data(header.utf8)) + + while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty { + try output.write(contentsOf: chunk) + } + + try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8)) + return tempURL + } catch { + try? FileManager.default.removeItem(at: tempURL) + throw error + } + } + + private func multipartEscaped(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } + + private func contentType(for url: URL) -> String { + switch url.pathExtension.lowercased() { + case "mp3": return "audio/mpeg" + case "ogg", "opus": return "audio/ogg" + case "flac": return "audio/flac" + case "wav": return "audio/wav" + case "m4a", "aac": return "audio/mp4" + case "mp4": return "video/mp4" + case "wma": return "audio/x-ms-wma" + default: return "application/octet-stream" + } + } + + private func connectWebSocket(channelId: String) { + closeSocket() + guard let url = webSocketURL(channelId: channelId) else { + status = "Bad WebSocket URL" + return + } + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + + let task = URLSession.shared.webSocketTask(with: request) + webSocket = task + task.resume() + status = "Connected" + addDebug("ws connect \(channelId)") + reconnectAttempts = 0 + + receiveTask = Task { [weak self, task] in + while !Task.isCancelled { + do { + let message = try await task.receive() + await MainActor.run { + self?.handleWebSocketMessage(message) + } + } catch { + await MainActor.run { + self?.handleSocketError(error) + } + break + } + } + } + } + + private func closeSocket() { + receiveTask?.cancel() + receiveTask = nil + reconnectTask?.cancel() + reconnectTask = nil + webSocket?.cancel(with: .goingAway, reason: nil) + webSocket = nil + } + + private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { + let data: Data + switch message { + case .string(let text): + data = Data(text.utf8) + case .data(let payload): + data = payload + @unknown default: + addDebug("ws unknown message") + return + } + + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + addDebug("ws bad json") + return + } + + if let type = object["type"] as? String, !type.isEmpty { + handleTypedSocketMessage(type, object: object, data: data) + return + } + + do { + let state = try decoder.decode(ChannelState.self, from: data) + handleChannelState(state) + } catch { + addDebug("ws state decode failed") + } + } + + private func handleTypedSocketMessage(_ type: String, object: [String: Any], data: Data) { + switch type { + case "channel_list": + if let channelData = try? JSONSerialization.data(withJSONObject: object["channels"] ?? []), + let nextChannels = try? decoder.decode([ChannelInfo].self, from: channelData) { + channels = nextChannels + } + case "switched": + currentChannelId = object["channelId"] as? String + status = "Tuned" + case "kick": + status = "Kicked" + addDebug("kick \(object["reason"] as? String ?? "")") + case "error": + status = object["message"] as? String ?? "Server error" + addDebug("ws error \(status)") + default: + addDebug("ws event \(type)") + } + } + + private func handleSocketError(_ error: Error) { + guard !intentionalDisconnect, sourceMode == .radio else { return } + status = "Disconnected" + addDebug("ws fail: \(error.localizedDescription)") + scheduleReconnect() + } + + private func scheduleReconnect() { + guard let channelId = currentChannelId else { return } + reconnectTask?.cancel() + let delaySeconds = min(3 * (reconnectAttempts + 1), 30) + reconnectAttempts += 1 + addDebug("reconnect in \(delaySeconds)s") + reconnectTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) + await MainActor.run { + guard let self, self.sourceMode == .radio, !self.intentionalDisconnect else { return } + self.connectWebSocket(channelId: channelId) + } + if let self { + await self.loadChannelState(channelId) + } + } + } + + private func sendSocket(_ dictionary: [String: Any]) { + guard let webSocket else { + status = "Socket not connected" + scheduleReconnect() + return + } + guard let data = try? JSONSerialization.data(withJSONObject: dictionary), + let payload = String(data: data, encoding: .utf8) else { + return + } + webSocket.send(.string(payload)) { [weak self] error in + if let error { + Task { @MainActor in + self?.addDebug("ws send failed: \(error.localizedDescription)") + } + } + } + } + + private func handleChannelState(_ state: ChannelState) { + guard sourceMode == .radio else { return } + reconnectAttempts = 0 + currentChannelId = state.channelId.isEmpty ? currentChannelId : state.channelId + channelName = state.channelName.isEmpty ? "Room" : state.channelName + paused = state.paused + playbackMode = state.playbackMode.isEmpty ? playbackMode : state.playbackMode + currentIndex = state.currentIndex + listeners = state.listeners + if let stateQueue = state.queue { + queue = stateQueue + queueLoaded = true + } + serverTimestampMs = max(0, Int64(state.currentTimestamp * 1000)) + stateMonotonicTime = ProcessInfo.processInfo.systemUptime + + guard let track = state.track else { + currentTrackId = nil + trackTitle = "No track" + trackDuration = 0 + player.pause() + tick() + return + } + + trackTitle = track.title + trackDuration = track.duration + let targetMs = expectedPosition() + + if currentTrackId != track.id { + currentTrackId = track.id + replacePlayerItem(track: track, positionMs: targetMs) + if state.paused { + player.pause() + } else { + player.play() + } + tick() + return + } + + let drift = playerPosition() - targetMs + if abs(drift) >= 2000 { + seekPlayer(toMs: targetMs) + addDebug("drift correction \(drift)ms") + } + + if state.paused, player.timeControlStatus == .playing { + player.pause() + } else if !state.paused, player.timeControlStatus != .playing { + player.play() + } + tick() + } + + private func replacePlayerItem(track: Track, positionMs: Int64) { + guard let url = trackURL(track.id) else { + status = "Bad track URL" + return + } + var headers = ["User-Agent": userAgent] + if !cookieHeader.isEmpty { + headers["Cookie"] = cookieHeader + } + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + let item = AVPlayerItem(asset: asset) + player.replaceCurrentItem(with: item) + seekPlayer(toMs: positionMs) + } + + private func seekPlayer(toMs ms: Int64) { + let seconds = Double(max(0, ms)) / 1000 + player.seek( + to: CMTime(seconds: seconds, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + private func tick() { + playerPositionMs = playerPosition() + expectedPositionMs = expectedPosition() + driftMs = sourceMode == .radio && currentTrackId != nil ? playerPositionMs - expectedPositionMs : 0 + isPlaying = player.timeControlStatus == .playing + playbackState = playbackStateLabel() + + if sourceMode == .radio, currentTrackId != nil, !paused, abs(driftMs) >= 2000 { + seekPlayer(toMs: expectedPositionMs) + addDebug("tick drift correction \(driftMs)ms") + } + + if sourceMode == .library { + paused = !isPlaying + if let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 { + trackDuration = duration + } + } + } + + private func expectedPosition() -> Int64 { + guard sourceMode == .radio, stateMonotonicTime > 0 else { + return playerPosition() + } + if paused { + return serverTimestampMs + } + let elapsedMs = Int64((ProcessInfo.processInfo.systemUptime - stateMonotonicTime) * 1000) + return serverTimestampMs + max(0, elapsedMs) + } + + private func playerPosition() -> Int64 { + let seconds = player.currentTime().seconds + guard seconds.isFinite, seconds >= 0 else { return 0 } + return Int64(seconds * 1000) + } + + private func handlePlaybackEnded() { + guard sourceMode == .library else { return } + guard currentTrackId != lastLibraryEndTrackId else { return } + lastLibraryEndTrackId = currentTrackId + switch playbackMode { + case "repeat-one": + seekPlayer(toMs: 0) + player.play() + lastLibraryEndTrackId = nil + case "repeat-all", "shuffle": + if !libraryTracks.isEmpty { + playLibraryTrack(libraryTracks[nextLibraryIndex()]) + } + default: + paused = true + tick() + } + } + + private func jumpRadio(delta: Int) { + guard !queue.isEmpty else { return } + let target = (currentIndex + delta + queue.count) % queue.count + sendSocket(["action": "jump", "index": target]) + status = "Jump sent" + } + + private func nextLibraryIndex() -> Int { + guard !libraryTracks.isEmpty else { return 0 } + if playbackMode == "shuffle", libraryTracks.count > 1 { + return randomLibraryIndex() + } + return localLibraryIndex < 0 ? 0 : (localLibraryIndex + 1) % libraryTracks.count + } + + private func randomLibraryIndex() -> Int { + guard libraryTracks.count > 1 else { return 0 } + var next = Int.random(in: 0.. Bool { + if authState == .signedIn, currentUser?.isGuest == false { return true } + status = message + addDebug("auth required") + return false + } + + private func startTicker() { + tickerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 500_000_000) + await MainActor.run { + self?.tick() + } + } + } + } + + private func configureAudioSession() { + #if os(iOS) + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + addDebug("audio session failed") + } + #endif + } + + private func playbackStateLabel() -> String { + switch player.timeControlStatus { + case .paused: + return player.currentItem == nil ? "idle" : "paused" + case .waitingToPlayAtSpecifiedRate: + return "buffering" + case .playing: + return "playing" + @unknown default: + return "unknown" + } + } + + private func modeLabel(_ mode: String) -> String { + switch mode { + case "once": return "ONCE" + case "repeat-all": return "LOOP ALL" + case "repeat-one": return "LOOP ONE" + case "shuffle": return "SHUFFLE" + default: return mode.uppercased() + } + } + + private func captureCookie(_ response: HTTPURLResponse) { + for (key, value) in response.allHeaderFields { + guard String(describing: key).lowercased() == "set-cookie" else { continue } + let raw = String(describing: value) + if let cookie = raw + .components(separatedBy: ";") + .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("blastoise_session=") }) { + let clean = cookie.trimmingCharacters(in: .whitespaces) + cookieHeader = clean == "blastoise_session=" ? "" : clean + } + } + } + + private func normalizedBaseURL() -> String { + var value = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + value = defaultServer + } + if !value.contains("://") { + value = "http://\(value)" + } + return value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private func webSocketURL(channelId: String) -> URL? { + guard var components = URLComponents(string: normalizedBaseURL()) else { return nil } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/api/channels/\(encodePath(channelId))/ws" + return components.url + } + + private func trackURL(_ trackId: String) -> URL? { + URL(string: normalizedBaseURL() + "/api/tracks/\(encodePath(trackId))") + } + + private func encodePath(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: ":/?#[]@!$&'()*+,;=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func addDebug(_ message: String) { + let stamp = Date().formatted(.dateTime.hour().minute().second()) + debugEvents.append("\(stamp) \(message)") + while debugEvents.count > 12 { + debugEvents.removeFirst() + } + } +} diff --git a/ios/BlastoisePing/BlastoisePing/UI/Theme.swift b/ios/BlastoisePing/BlastoisePing/UI/Theme.swift new file mode 100644 index 0000000..d580d18 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/UI/Theme.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct Theme { + static let background = Color(red: 0.055, green: 0.052, blue: 0.067) + static let panel = Color(red: 0.112, green: 0.105, blue: 0.135) + static let panel2 = Color(red: 0.170, green: 0.157, blue: 0.205) + static let stroke = Color(red: 0.475, green: 0.425, blue: 0.545) + static let text = Color(red: 0.965, green: 0.930, blue: 0.760) + static let muted = Color(red: 0.640, green: 0.585, blue: 0.710) + static let accent = Color(red: 1.000, green: 0.812, blue: 0.176) + static let ready = Color(red: 0.350, green: 0.820, blue: 1.000) + static let amber = Color(red: 1.000, green: 0.570, blue: 0.240) + static let red = Color(red: 1.000, green: 0.310, blue: 0.340) + + static let corner: CGFloat = 0 + static let smallCorner: CGFloat = 0 + + static func pixel(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + .custom("PixelifySans-Regular", size: size).weight(weight) + } + + static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + pixel(size, weight: weight).monospacedDigit() + } + + static let bodyFont = pixel(16) + static let headlineFont = pixel(19, weight: .semibold) + static let captionFont = pixel(13) + static let microFont = mono(11, weight: .semibold) + static func display(_ size: CGFloat) -> Font { pixel(size, weight: .bold) } +} + +extension View { + func panel() -> some View { + self + .padding(14) + .background(Theme.panel) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(Theme.stroke, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + func rowStyle(isActive: Bool = false) -> some View { + self + .padding(10) + .background(isActive ? Theme.panel2.opacity(1.0) : Theme.panel2.opacity(0.76)) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(isActive ? Theme.accent : Theme.stroke.opacity(0.38), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + func textFieldStyle() -> some View { + self + .padding(12) + .foregroundStyle(Theme.text) + .background(Theme.panel2) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(Theme.stroke, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} + +func formatTime(_ ms: Int64) -> String { + let total = max(0, Int(ms / 1000)) + return "\(total / 60):" + String(format: "%02d", total % 60) +} + +func formatDuration(_ duration: TimeInterval) -> String { + guard duration.isFinite, duration > 0 else { return "--:--" } + return formatTime(Int64(duration * 1000)) +} diff --git a/ios/BlastoisePing/BlastoisePing/Views/AuthView.swift b/ios/BlastoisePing/BlastoisePing/Views/AuthView.swift new file mode 100644 index 0000000..3ce4cc3 --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Views/AuthView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct AuthView: View { + @ObservedObject var model: AppModel + @Binding var username: String + @Binding var password: String + @FocusState private var focused: Field? + + private enum Field { + case server + case username + case password + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Text("BLASTOISE") + .font(Theme.display(40)) + .foregroundStyle(Theme.text) + Text("Tune into a shared room, stream the queue, and keep your local player in sync.") + .foregroundStyle(Theme.muted) + } + + VStack(alignment: .leading, spacing: 12) { + Label("Server", systemImage: "server.rack") + .foregroundStyle(Theme.text) + .font(Theme.headlineFont) + field("http://host:3001", text: $model.serverURL, field: .server) + + HStack(spacing: 10) { + Button { + model.serverURL = "http://mhsgroove.peterino.com:3001" + } label: { + Label("Default", systemImage: "radio") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + model.serverURL = "http://localhost:3001" + } label: { + Label("Local", systemImage: "desktopcomputer") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .panel() + + VStack(alignment: .leading, spacing: 12) { + Label("Account", systemImage: "person.crop.circle") + .foregroundStyle(Theme.text) + .font(Theme.headlineFont) + field("username", text: $username, field: .username) + SecureField("password", text: $password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focused, equals: .password) + .textFieldStyle() + + HStack(spacing: 10) { + Button { + focused = nil + Task { await model.signIn(username: username, password: password) } + } label: { + Label("Sign In", systemImage: "arrow.right.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + + Button { + focused = nil + Task { await model.signUp(username: username, password: password) } + } label: { + Label("Sign Up", systemImage: "person.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .panel() + + StatusStrip(model: model) + } + .padding(18) + .frame(maxWidth: 640, alignment: .topLeading) + } + } + + private func field(_ placeholder: String, text: Binding, field: Field) -> some View { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(field == .server ? .URL : .default) + .focused($focused, equals: field) + .textFieldStyle() + } +} diff --git a/ios/BlastoisePing/BlastoisePing/Views/Components.swift b/ios/BlastoisePing/BlastoisePing/Views/Components.swift new file mode 100644 index 0000000..d06494f --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Views/Components.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct DebugFooterView: View { + @ObservedObject var model: AppModel + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(model.authState == .signedIn ? Theme.ready : Theme.amber) + .frame(width: 8, height: 8) + Text(model.status) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 2) + } +} + +struct StatusStrip: View { + @ObservedObject var model: AppModel + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(model.authState == .checking ? Theme.amber : model.authState == .signedIn ? Theme.ready : Theme.red) + .frame(width: 10, height: 10) + Text(model.status) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + Spacer() + } + .panel() + } +} + +struct PanelTitle: View { + private let title: String + private let icon: String + + init(_ title: String, icon: String) { + self.title = title + self.icon = icon + } + + var body: some View { + Label(title, systemImage: icon) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + } +} + +struct EmptyLine: View { + private let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} + +struct TrackLine: View { + let track: Track + let isActive: Bool + let subtitle: String + @ViewBuilder let actions: () -> Actions + + var body: some View { + HStack(spacing: 10) { + Rectangle() + .fill(isActive ? Theme.ready : Theme.amber) + .frame(width: 4) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(Theme.pixel(16, weight: .semibold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + Text(subtitle) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + .lineLimit(1) + } + + Spacer(minLength: 8) + actions() + } + .rowStyle(isActive: isActive) + } +} diff --git a/ios/BlastoisePing/BlastoisePing/Views/HeaderView.swift b/ios/BlastoisePing/BlastoisePing/Views/HeaderView.swift new file mode 100644 index 0000000..d05415a --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Views/HeaderView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct HeaderView: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(spacing: 10) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("BLASTOISE") + .font(Theme.display(28)) + .foregroundStyle(Theme.text) + Text(model.currentUser?.username ?? "signed out") + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + } + + Spacer() + + Button { + Task { await model.connectToServer() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 38, height: 36) + } + .buttonStyle(.bordered) + + Button(role: .destructive) { + Task { await model.logout() } + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + .frame(width: 38, height: 36) + } + .buttonStyle(.bordered) + } + + HStack(spacing: 8) { + Image(systemName: "server.rack") + .foregroundStyle(Theme.amber) + TextField("server", text: $model.serverURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(Theme.mono(12)) + .foregroundStyle(Theme.text) + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + .padding(14) + .background(Theme.background) + } +} diff --git a/ios/BlastoisePing/BlastoisePing/Views/Panels.swift b/ios/BlastoisePing/BlastoisePing/Views/Panels.swift new file mode 100644 index 0000000..433732f --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Views/Panels.swift @@ -0,0 +1,431 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct RoomsPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Rooms", icon: "radio") + if model.channels.isEmpty { + EmptyLine("No rooms loaded") + } else { + ForEach(model.channels) { channel in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(channel.name) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + if channel.isDefault { + Text("DEFAULT") + .font(Theme.microFont) + .foregroundStyle(Theme.background) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.amber) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + } + } + Text(channel.description.isEmpty ? "\(channel.trackCount) tracks" : channel.description) + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + Text("\(channel.listenerCount) listener(s)") + .font(Theme.mono(12)) + .foregroundStyle(Theme.ready) + } + Spacer() + Button { + Task { await model.joinChannel(channel.id) } + } label: { + Image(systemName: model.currentChannelId == channel.id ? "checkmark.circle.fill" : "dot.radiowaves.left.and.right") + .frame(width: 44, height: 38) + } + .buttonStyle(.borderedProminent) + .tint(model.currentChannelId == channel.id ? Theme.ready : Theme.accent) + } + .rowStyle(isActive: model.currentChannelId == channel.id) + } + } + } + .panel() + } +} + +struct QueuePanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Queue", icon: "list.bullet") + if model.queue.isEmpty { + EmptyLine(model.queueLoaded ? "Queue is empty" : "Queue not loaded") + } else { + ForEach(Array(model.queue.prefix(80).enumerated()), id: \.offset) { index, track in + TrackLine( + track: track, + isActive: index == model.currentIndex, + subtitle: "#\(index + 1) \(formatDuration(track.duration))" + ) { + Button { + model.jumpToQueueIndex(index) + } label: { + Image(systemName: "play.fill") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + + Button(role: .destructive) { + Task { await model.removeQueueIndex(index) } + } label: { + Image(systemName: "trash") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + } +} + +struct PeoplePanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("People", icon: "person.2") + if model.listeners.isEmpty { + EmptyLine("No listener names in this room yet") + } else { + ForEach(model.listeners, id: \.self) { listener in + HStack { + Image(systemName: listener == model.currentUser?.username ? "person.fill.checkmark" : "person.fill") + .foregroundStyle(listener == model.currentUser?.username ? Theme.ready : Theme.muted) + Text(listener) + .foregroundStyle(Theme.text) + Spacer() + if listener == model.currentUser?.username { + Text("YOU") + .font(Theme.microFont) + .foregroundStyle(Theme.ready) + } + } + .rowStyle(isActive: listener == model.currentUser?.username) + } + } + } + .panel() + } +} + +struct LibraryPanel: View { + @ObservedObject var model: AppModel + @State private var query = "" + @State private var fetchURL = "" + @State private var fileImporterPresented = false + + var matches: [Track] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let base = model.libraryTracks + if trimmed.isEmpty { + return Array(base.prefix(80)) + } + return Array(base.filter { + $0.title.lowercased().contains(trimmed) || + $0.filename.lowercased().contains(trimmed) || + ($0.artist ?? "").lowercased().contains(trimmed) + }.prefix(80)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Library", icon: "music.note.list") + importTools + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.muted) + TextField("Search tracks", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Theme.text) + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + + if !model.libraryLoaded { + EmptyLine("Loading library") + } else if matches.isEmpty { + EmptyLine("No matching tracks") + } else { + ForEach(matches) { track in + TrackLine( + track: track, + isActive: model.sourceMode == .library && model.currentTrackId == track.id, + subtitle: track.artist ?? track.filename + ) { + Button { + model.playLibraryTrack(track) + } label: { + Image(systemName: "play.fill") + .frame(width: 38, height: 34) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + + Menu { + Button("Add to Queue") { + Task { await model.queueTrack(track, playNext: false) } + } + Button("Play Next") { + Task { await model.queueTrack(track, playNext: true) } + } + } label: { + Image(systemName: "plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + .fileImporter( + isPresented: $fileImporterPresented, + allowedContentTypes: [.audio, .movie], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + Task { await model.uploadFiles(urls) } + case .failure(let error): + model.importStatus = "File picker failed: \(error.localizedDescription)" + } + } + } + + private var importTools: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Button { + fileImporterPresented = true + } label: { + Label(model.isUploading ? "Uploading" : "Upload Files", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + .disabled(model.isUploading) + + Button { + Task { await model.loadLibrary() } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 42, height: 34) + } + .buttonStyle(.bordered) + .accessibilityLabel("Reload Library") + } + + HStack(spacing: 8) { + Image(systemName: "link") + .foregroundStyle(Theme.muted) + TextField("Fetch from website URL", text: $fetchURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .foregroundStyle(Theme.text) + Button { + Task { await model.fetchFromWebsite(fetchURL) } + } label: { + Image(systemName: model.isFetching ? "hourglass" : "arrow.down.circle") + .frame(width: 40, height: 34) + } + .buttonStyle(.bordered) + .disabled(model.isFetching) + .accessibilityLabel("Fetch URL") + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + + if let playlist = model.pendingFetchPlaylist { + VStack(alignment: .leading, spacing: 8) { + Text("Playlist found") + .font(Theme.mono(12, weight: .bold)) + .foregroundStyle(Theme.amber) + Text("\(playlist.title) ยท \(playlist.count) items") + .font(Theme.pixel(16, weight: .semibold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + HStack(spacing: 8) { + Button { + Task { await model.confirmFetchPlaylist() } + } label: { + Label("Queue Playlist", systemImage: "checkmark.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Theme.ready) + .disabled(model.isFetching) + + Button { + model.cancelFetchPlaylist() + } label: { + Label("Cancel", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .padding(10) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + + if !model.importStatus.isEmpty { + Text(model.importStatus) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .lineLimit(2) + } + } + } +} + +struct PlaylistsPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PanelTitle("Playlists", icon: "rectangle.stack") + if model.allPlaylists.isEmpty { + EmptyLine(model.playlistsLoaded ? "No playlists" : "Loading playlists") + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.allPlaylists.prefix(40)) { playlist in + let isSelected = model.selectedPlaylistId == playlist.id + Button { + Task { await model.loadPlaylist(playlist.id) } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(playlist.name) + .font(Theme.pixel(16, weight: .bold)) + .foregroundStyle(isSelected ? Theme.background : Theme.text) + .lineLimit(1) + Text("\(playlist.trackIds.count) tracks") + .font(Theme.mono(12)) + .foregroundStyle(isSelected ? Theme.background.opacity(0.72) : Theme.muted) + } + .frame(width: 150, alignment: .leading) + .padding(10) + } + .buttonStyle(.plain) + .background(isSelected ? Theme.accent : Theme.panel2) + .overlay( + RoundedRectangle(cornerRadius: Theme.corner) + .stroke(isSelected ? Theme.text : Theme.stroke.opacity(0.38), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } + } + } + } + + if let playlist = model.selectedPlaylist { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(playlist.name) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + Text(playlist.ownerName.isEmpty ? "\(playlist.trackIds.count) tracks" : "by \(playlist.ownerName)") + .font(Theme.captionFont) + .foregroundStyle(Theme.muted) + } + Spacer() + Button { + Task { await model.addPlaylistToQueue(playlist, playNext: false) } + } label: { + Image(systemName: "text.badge.plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + Button { + Task { await model.addPlaylistToQueue(playlist, playNext: true) } + } label: { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + + ForEach(Array(playlist.trackIds.prefix(80).enumerated()), id: \.offset) { index, trackId in + let track = model.track(for: trackId) ?? Track(id: trackId, filename: trackId, title: trackId, duration: 0) + TrackLine( + track: track, + isActive: model.currentTrackId == track.id, + subtitle: "#\(index + 1)" + ) { + Button { + Task { await model.queueTrack(track, playNext: false) } + } label: { + Image(systemName: "plus") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + Button { + Task { await model.queueTrack(track, playNext: true) } + } label: { + Image(systemName: "arrow.up.to.line") + .frame(width: 38, height: 34) + } + .buttonStyle(.bordered) + } + } + } + } + .panel() + } +} + +struct DebugPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + PanelTitle("Diagnostics", icon: "waveform.path.ecg") + debugRow("Server", model.serverURL) + debugRow("Auth", model.authState.rawValue) + debugRow("User", model.currentUser?.username ?? "-") + debugRow("Room", model.currentChannelId ?? "-") + debugRow("Track", model.currentTrackId ?? "-") + debugRow("Expected", "\(model.expectedPositionMs)ms") + debugRow("Player", "\(model.playerPositionMs)ms") + debugRow("Drift", "\(model.driftMs)ms") + Divider().overlay(Theme.stroke) + ForEach(model.debugEvents, id: \.self) { event in + Text(event) + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .panel() + } + + private func debugRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .foregroundStyle(Theme.muted) + .frame(width: 78, alignment: .leading) + Text(value) + .foregroundStyle(Theme.text) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .font(Theme.mono(12)) + } +} diff --git a/ios/BlastoisePing/BlastoisePing/Views/PlayerDeckView.swift b/ios/BlastoisePing/BlastoisePing/Views/PlayerDeckView.swift new file mode 100644 index 0000000..198371e --- /dev/null +++ b/ios/BlastoisePing/BlastoisePing/Views/PlayerDeckView.swift @@ -0,0 +1,126 @@ +import SwiftUI + +struct PlayerDeckView: View { + @ObservedObject var model: AppModel + + var progress: Double { + guard model.trackDuration > 0 else { return 0 } + return min(1, max(0, Double(model.playerPositionMs) / (model.trackDuration * 1000))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(model.sourceMode.rawValue) + .font(Theme.mono(12, weight: .bold)) + .foregroundStyle(Theme.accent) + Text(model.channelName) + .font(Theme.headlineFont) + .foregroundStyle(Theme.text) + Text(model.trackTitle) + .font(Theme.pixel(22, weight: .bold)) + .foregroundStyle(Theme.text) + .lineLimit(2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(model.playbackMode.uppercased()) + .font(Theme.mono(12)) + .foregroundStyle(Theme.amber) + Text(model.playbackState.uppercased()) + .font(Theme.mono(12)) + .foregroundStyle(model.isPlaying ? Theme.ready : Theme.muted) + } + } + + VStack(alignment: .leading, spacing: 8) { + ProgressView(value: progress) + .tint(Theme.ready) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.smallCorner)) + + HStack { + Text(formatTime(model.playerPositionMs)) + Spacer() + Text(formatDuration(model.trackDuration)) + } + .font(Theme.mono(12)) + .foregroundStyle(Theme.muted) + } + + HStack(spacing: 8) { + iconButton("backward.end.fill") { model.previous() } + iconButton("gobackward.15") { model.seekBy(seconds: -15) } + Button { + model.togglePlay() + } label: { + Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") + .font(Theme.pixel(24, weight: .bold)) + .frame(width: 58, height: 48) + } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + iconButton("goforward.15") { model.seekBy(seconds: 15) } + iconButton("forward.end.fill") { model.next() } + } + + HStack(spacing: 8) { + actionButton("Mode", icon: "repeat") { + Task { await model.cyclePlaybackMode() } + } + actionButton("Queue", icon: "text.badge.plus") { + Task { await model.queueCurrent(playNext: false) } + } + actionButton("Next", icon: "text.line.first.and.arrowtriangle.forward") { + Task { await model.queueCurrent(playNext: true) } + } + actionButton("Stop", icon: "power") { + model.stopAndExit() + } + } + + HStack(spacing: 12) { + meter("DRIFT", "\(model.driftMs)ms", model.sourceMode == .radio && abs(model.driftMs) > 1800 ? Theme.amber : Theme.ready) + meter("ROOMS", "\(model.channels.count)", Theme.text) + meter("QUEUE", "\(model.queue.count)", Theme.text) + } + } + .panel() + } + + private func iconButton(_ systemName: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + } + + private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: icon) + .labelStyle(.iconOnly) + .frame(maxWidth: .infinity, minHeight: 38) + } + .buttonStyle(.bordered) + .accessibilityLabel(title) + } + + private func meter(_ label: String, _ value: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(Theme.microFont) + .foregroundStyle(Theme.muted) + Text(value) + .font(Theme.mono(13, weight: .bold)) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Theme.panel2) + .clipShape(RoundedRectangle(cornerRadius: Theme.corner)) + } +} diff --git a/ios/BlastoisePing/README.md b/ios/BlastoisePing/README.md index 211f6cc..5635cc1 100644 --- a/ios/BlastoisePing/README.md +++ b/ios/BlastoisePing/README.md @@ -12,6 +12,15 @@ Native SwiftUI sketch for the Blastoise/MusicRoom server. - Supports local library playback, queue/play-next actions, queue jumps/removes, and playback mode cycling. - Uses one compact broadcast-console theme. +## Code Layout + +- `BlastoisePingApp.swift` - app entrypoint. +- `ContentView.swift` - signed-in/signed-out shell and tab routing. +- `Models/AppTypes.swift` - API response models and shared enums. +- `State/AppModel.swift` - app state, server requests, WebSocket sync, uploads, and playback coordination. +- `UI/Theme.swift` - pixel-art palette, typography, reusable view chrome, and time formatting. +- `Views/` - focused SwiftUI screens and reusable row/panel components. + ## Open ```bash