Compare commits

...

5 Commits

Author SHA1 Message Date
peterino cd8c1814ca Merge pull request 'Update Android default server URL' (#18) from dev/android into integration
Reviewed-on: #18
2026-06-10 05:09:08 +00:00
Peter Li adc450f14f reference api brief 2026-06-07 19:59:32 -07:00
Peter Li d184c6a663 updating reference server 2026-06-07 19:48:13 -07:00
Peter Li 091e54c599 ios 2026-06-07 19:45:38 -07:00
Peter Li 910b25e7c7 blastoise ios 2026-06-07 17:49:42 -07:00
18 changed files with 5257 additions and 0 deletions

20
.gitignore vendored
View File

@ -33,6 +33,26 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
# Xcode
DerivedData/
ios/**/build/
*.xcuserstate
*.xcscmblueprint
*.xccheckout
*.moved-aside
xcuserdata/
*.xcresult
*.xcarchive
*.app
*.appex
*.dSYM
*.dSYM.zip
*.ipa
# Swift Package Manager / Xcode package scratch
.build/
.swiftpm/
tmp/
library_cache.db
musicroom.db

1527
docs/api-reference-full.md Normal file

File diff suppressed because it is too large Load Diff

176
docs/api-reference.md Normal file
View File

@ -0,0 +1,176 @@
# Blastoise API Reference
Blastoise is a synchronized music server. The server owns channel time and
queues; clients play audio locally.
```text
Reference HTTP: http://mhsgroove.peterino.com:3001
Reference WS: ws://mhsgroove.peterino.com:3001
Local HTTP: http://localhost:3001
Local WS: ws://localhost:3001
```
Auth is an HttpOnly cookie named `blastoise_session`. Same-origin browser apps
can use normal `fetch`. Separate-origin browser apps need a same-origin proxy
or CORS with credentials. Native apps must store `Set-Cookie` and send it as
`Cookie` on HTTP and WebSocket requests.
Full details: [api-reference-full.md](./api-reference-full.md)
## Golden Rule
Use `track.id` for every machine operation:
```text
GET /api/tracks/:trackId
```
`track.id` is a content hash like `sha256:...`. `filename` and `title` are only
for display. Queue entries, playlists, cache keys, direct links, and audio URLs
should all use `track.id`.
## Core Shapes
```ts
type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";
type Track = {
id: string; filename: string; title: string | null;
artist?: string | null; album?: string | null; duration: number;
replayGainDb?: number | null; replayPeak?: number | null; available?: boolean;
};
type ChannelInfo = {
id: string; name: string; description: string; trackCount: number;
listenerCount: number; listeners: string[]; isDefault: boolean;
createdBy: number | null;
};
type ChannelState = {
track: Track | null; currentTimestamp: number; channelName: string;
channelId: string; description: string; paused: boolean; currentIndex: number;
listenerCount: number; isDefault: boolean; playbackMode: PlaybackMode;
queue?: Track[];
};
type Playlist = {
id: string; name: string; description: string; ownerId: number;
ownerName?: string; isPublic: boolean; shareToken: string | null;
trackIds: string[]; createdAt: number; updatedAt: number;
};
```
`ChannelState.queue` is optional. It appears on WebSocket connect, queue
changes, and periodic refreshes. Keep the last known queue when omitted.
## Startup
```text
GET /api/status
GET /api/auth/me
GET /api/library
GET /api/channels
WS /api/channels/:channelId/ws
```
Choose a channel: saved channel, else `isDefault`, else first channel.
## Endpoints
| Area | Endpoints |
|---|---|
| Status | `GET /api/status` |
| Auth | `POST /api/auth/signup`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/kick-others` |
| Channels | `GET /api/channels`, `POST /api/channels`, `GET/PATCH/DELETE /api/channels/:id` |
| Playback control | `POST /api/channels/:id/jump`, `POST /api/channels/:id/seek`, `POST /api/channels/:id/mode` |
| Queue | `PATCH /api/channels/:id/queue` |
| Library/audio | `GET /api/library`, `GET /api/tracks/:trackId`, `POST /api/upload` |
| Playlists | `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/:id`, `PATCH /api/playlists/:id/tracks` |
| Sharing | `POST/DELETE /api/playlists/:id/share`, `GET/POST /api/playlists/shared/:token` |
| URL import | `POST /api/fetch`, `POST /api/fetch/confirm`, `GET /api/fetch`, `DELETE /api/fetch/:itemId`, `DELETE /api/fetch` |
Common bodies:
```json
{ "username": "test", "password": "testuser" }
{ "name": "Channel or playlist name", "description": "optional" }
{ "mode": "repeat-all" }
{ "index": 3 }
{ "timestamp": 45.5 }
```
Queue and playlist track mutation:
```json
{ "set": ["sha256:a", "sha256:b"] }
{ "add": ["sha256:c"], "insertAt": 2 }
{ "remove": [3, 4] }
{ "move": [5, 6], "to": 1 }
```
Remove/move use positions, not track IDs. Duplicate tracks are allowed.
Audio supports range requests:
```text
Range: bytes=0-999999
```
## WebSocket
Connect to:
```text
ws://mhsgroove.peterino.com:3001/api/channels/:channelId/ws
```
Client messages:
```json
{ "action": "switch", "channelId": "abc123" }
{ "action": "pause" }
{ "action": "unpause" }
{ "action": "seek", "timestamp": 45.5 }
{ "action": "jump", "index": 3 }
```
Server messages:
```json
{ "type": "channel_list", "channels": [] }
{ "type": "switched", "channelId": "abc123" }
{ "type": "kick", "reason": "Kicked by another session" }
{ "type": "toast", "message": "Added: Song", "toastType": "info" }
{ "type": "scan_progress", "scanning": true, "processed": 1, "total": 20 }
{ "type": "fetch_progress", "id": "job", "status": "downloading", "progress": 50 }
```
Any message without `type` is a `ChannelState`.
Guests can listen and switch channels, but cannot control playback or mutate
queues. Unauthorized WebSocket control messages are ignored.
## Sync Algorithm
On every `ChannelState`:
1. Store the state and `performance.now()`.
2. If `state.queue` exists, replace the local queue cache.
3. If `state.track` is null, pause and clear the player.
4. If `state.track.id` changed, set `audio.src` to `/api/tracks/:trackId` and
seek to `state.currentTimestamp`.
5. If same track and drift is `>= 2s`, seek to `state.currentTimestamp`.
6. If `state.paused`, pause. Otherwise call `audio.play()`.
7. Between WebSocket updates, estimate time as
`state.currentTimestamp + elapsedSeconds`, unless paused.
The server is the source of truth.
## Gotchas
- Some errors are JSON `{ "error": "..." }`; some are plain text. Handle both.
- `GET /api/channels/:id` does not include the queue. WebSocket connect does.
- `POST /api/playlists/shared/:token` copies a playlist; there is no `/copy`.
- Cache by `track.id`, never by filename.
- The server does not decode audio. Clients are synchronized local players.

585
docs/buildme.md Normal file
View File

@ -0,0 +1,585 @@
# Build Me A Blastoise Frontend
This is a pasteable build brief for an LLM or coding agent. It tells the agent
how to build a frontend for a Blastoise music server without needing to read the
server code.
Reference the short API contract in:
```text
docs/api-reference.md
```
Use the full reference for edge cases:
```text
docs/api-reference-full.md
```
## Paste This Prompt Into Your LLM
```text
You are building a frontend for Blastoise, a synchronized music streaming
server. Build the actual app, not a landing page.
Use the Blastoise API documented in docs/api-reference.md. The server owns
channel state and time. The client owns UI, local audio playback, local caching,
and drift correction.
Reference server for testing:
- HTTP base URL: http://mhsgroove.peterino.com:3001
- WebSocket base URL: ws://mhsgroove.peterino.com:3001
Core rule:
- Always identify tracks by track.id.
- Always play audio from /api/tracks/:trackId.
- filename and title are display fields only.
Build an app with:
- Auth screen: login, signup, and guest mode when /api/status says guests are
allowed.
- Channel list: load /api/channels, show listener counts, connect to a channel
WebSocket, support switching channels.
- Now playing player: show current track, time, duration, play/pause, seek,
previous/next, playback mode.
- Library: list tracks from /api/library, search/filter, click a track to play
locally, add tracks to queue.
- Queue: render the current channel queue, highlight currentIndex, add/remove,
move/reorder when the user has control permission.
- Playlists: list /api/playlists, show playlist details, add playlists/tracks
to queue, create/edit/delete owned playlists.
- Optional URL import UI if /api/status reports ytdlp.enabled and
ytdlp.available.
Do not assume the WebSocket always includes queue. It includes queue on connect,
after queue changes, and periodic refreshes. Keep the last known queue until a
new queue arrives.
Do not use alert() or prompt(). Use inline inputs, modals, toasts, or standard
UI components.
Auth uses an HttpOnly cookie named blastoise_session. If this app is served
from the same origin as the server, browser fetch calls can use relative URLs.
If this app is hosted separately, either proxy API requests through the same
origin or add CORS/credentials support to the server.
Implement robust API helpers that handle JSON errors and plain text errors.
Some Blastoise endpoints return JSON error objects, while some return plain
text.
Synced playback algorithm:
1. Connect to WS /api/channels/:channelId/ws.
2. When a normal ChannelState message arrives, store it with performance.now().
3. If state.queue exists, replace the local queue cache.
4. If state.track is null, pause and clear the player.
5. If track.id changed, set audio.src to /api/tracks/:trackId, seek to
state.currentTimestamp, then play unless state.paused.
6. If track.id is the same and abs(audio.currentTime - state.currentTimestamp)
>= 2, seek to state.currentTimestamp.
7. If state.paused, pause locally. If not paused, play locally.
8. Between WebSocket updates, estimate server time as
state.currentTimestamp + elapsedSeconds since receipt, unless paused.
Control actions:
- Send WebSocket { action: "pause" } and { action: "unpause" } for play/pause.
- Send WebSocket { action: "seek", timestamp } for seek.
- Send WebSocket { action: "jump", index } for queue jumps.
- Send WebSocket { action: "switch", channelId } to switch channels.
- Use REST PATCH /api/channels/:channelId/queue for add/remove/move/set queue.
- Use REST POST /api/channels/:channelId/mode for playback mode.
Use track.id for local caching. If you build caching, store complete audio blobs
in IndexedDB under track.id. Range requests to /api/tracks/:trackId are
supported.
Make the interface responsive. Desktop can use panels for Channels, Library,
Queue, and Playlists. Mobile should use tabs or a single-panel navigation.
```
## Implementation Order
Follow this order. It keeps the project useful from the first milestone and
prevents sync bugs from getting buried under UI.
### Step 1: Create The API Client
Build a small wrapper around `fetch`.
Requirements:
- Use relative URLs when the frontend is same-origin.
- Allow an `API_BASE` override for native or separately hosted builds.
- Send `credentials: "include"` for browser fetch calls.
- Parse successful JSON.
- On errors, try JSON first, then fall back to text.
- Expose helpers for JSON, form upload, and raw audio URLs.
Recommended shape:
```ts
const API_BASE = "";
async function apiJson(path: string, options: RequestInit = {}) {
const res = await fetch(API_BASE + path, {
credentials: "include",
...options,
headers: {
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...(options.headers || {}),
},
});
const text = await res.text();
let data: any = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const message =
typeof data === "object" && data
? data.error || data.message || `HTTP ${res.status}`
: data || `HTTP ${res.status}`;
throw new Error(message);
}
return data;
}
function trackUrl(trackId: string) {
return `${API_BASE}/api/tracks/${encodeURIComponent(trackId)}`;
}
```
Native apps should store `Set-Cookie` from login/signup/me and send it as
`Cookie` in later HTTP and WebSocket requests.
### Step 2: Load Status And Session
On app start:
```text
GET /api/status
GET /api/auth/me
```
Use `/api/status` to decide whether to show:
- guest mode,
- signup,
- URL import.
Use `/api/auth/me` to get the user and effective permissions. When guests are
enabled, this call can create a guest session.
Auth actions:
```text
POST /api/auth/login { username, password }
POST /api/auth/signup { username, password }
POST /api/auth/logout
```
After login, signup, logout, or guest creation, reload:
```text
GET /api/auth/me
GET /api/library
GET /api/channels
GET /api/playlists
```
### Step 3: Load Library And Channels
Load:
```text
GET /api/library
GET /api/channels
```
Store tracks in two forms:
```ts
const library: Track[] = [];
const tracksById = new Map<string, Track>();
```
Pick the channel:
1. Last saved channel ID if still present.
2. The channel with `isDefault: true`.
3. The first channel.
Then connect the WebSocket.
### Step 4: Build WebSocket State Handling
Connect:
```ts
function wsUrl(channelId: string) {
const base = API_BASE || window.location.origin;
const url = new URL(base);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `/api/channels/${encodeURIComponent(channelId)}/ws`;
return url.toString();
}
```
Handle message types:
```ts
function onSocketMessage(data: any) {
if (data.type === "channel_list") {
setChannels(data.channels);
return;
}
if (data.type === "switched") {
setCurrentChannelId(data.channelId);
return;
}
if (data.type === "kick") {
disconnectAndShowLoginOrToast(data.reason);
return;
}
if (data.type === "toast") {
showToast(data.message, data.toastType);
return;
}
if (data.type === "scan_progress") {
updateScanProgress(data);
return;
}
if (typeof data.type === "string" && data.type.startsWith("fetch_")) {
updateFetchTask(data);
return;
}
applyChannelState(data);
}
```
Reconnect while the user wants sync. Use a short delay such as 2 or 3 seconds.
### Step 5: Implement The Player Correctly
Keep this state:
```ts
let channelState: ChannelState | null = null;
let channelStateReceivedAt = 0;
let currentTrackId: string | null = null;
let queue: Track[] = [];
```
Apply state:
```ts
async function applyChannelState(state: ChannelState) {
channelState = state;
channelStateReceivedAt = performance.now();
if (state.queue) queue = state.queue;
if (!state.track) {
audio.pause();
currentTrackId = null;
return;
}
const target = state.currentTimestamp;
const nextTrackId = state.track.id;
if (nextTrackId !== currentTrackId) {
currentTrackId = nextTrackId;
audio.src = getPlayableUrl(nextTrackId);
audio.currentTime = target;
} else if (Math.abs(audio.currentTime - target) >= 2) {
audio.currentTime = target;
}
if (state.paused) {
audio.pause();
} else {
audio.play().catch(() => showClickToPlay());
}
}
```
Estimate current synced time for progress UI:
```ts
function syncedTime() {
if (!channelState?.track) return 0;
if (channelState.paused) return channelState.currentTimestamp;
return channelState.currentTimestamp + (performance.now() - channelStateReceivedAt) / 1000;
}
```
Use the audio element's actual `currentTime` while audio is playing, but use
`syncedTime()` while waiting to play, paused, reconnecting, or rendering remote
state.
### Step 6: Add Controls
Use WebSocket for simple channel controls:
```ts
ws.send(JSON.stringify({ action: "pause" }));
ws.send(JSON.stringify({ action: "unpause" }));
ws.send(JSON.stringify({ action: "seek", timestamp }));
ws.send(JSON.stringify({ action: "jump", index }));
ws.send(JSON.stringify({ action: "switch", channelId }));
```
Use REST for queue mutation:
```text
PATCH /api/channels/:channelId/queue
```
Bodies:
```json
{ "add": ["sha256:track"], "insertAt": 3 }
{ "remove": [2] }
{ "move": [5], "to": 1 }
{ "set": ["sha256:a", "sha256:b"] }
```
Use REST for playback mode:
```text
POST /api/channels/:channelId/mode
{ "mode": "shuffle" }
```
If a control returns `403`, show a permission toast. Guests can listen but
cannot control.
### Step 7: Render Library, Queue, And Local Playback
Library:
- Render `/api/library`.
- Search over title, filename, artist, and album.
- Add selected tracks to queue with `PATCH /api/channels/:id/queue`.
- Play a track locally by setting the audio source to `/api/tracks/:trackId`
and disconnecting or marking the player unsynced.
Queue:
- Render the last known `queue`.
- Highlight `currentIndex`.
- Jump by index.
- Remove by index.
- Reorder by index.
- Remember that duplicate track IDs can exist in the queue. Queue operations
that remove or move tracks must use positions, not IDs.
Local playback:
- It is okay to let users preview/play a single track outside channel sync.
- Keep this mode visually distinct from synced playback.
- Offer a "sync" button to reconnect to the selected channel.
### Step 8: Add Playlists
Load:
```text
GET /api/playlists
```
Render two lists:
- `mine`
- `shared`
Details:
```text
GET /api/playlists/:playlistId
```
Join `playlist.trackIds` with `tracksById` from the library to render track
titles.
Common actions:
```text
POST /api/playlists
PATCH /api/playlists/:id
DELETE /api/playlists/:id
PATCH /api/playlists/:id/tracks
POST /api/playlists/:id/share
DELETE /api/playlists/:id/share
POST /api/playlists/shared/:token
```
To add a playlist to queue:
```json
{ "add": ["sha256:a", "sha256:b"] }
```
To play next:
```json
{ "add": ["sha256:a", "sha256:b"], "insertAt": currentIndex + 1 }
```
### Step 9: Add Upload And URL Import
Upload:
```text
POST /api/upload
multipart/form-data field: file
```
Accepted file extensions:
```text
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
```
URL import is optional. Show it only when:
```ts
status.ytdlp?.enabled && status.ytdlp?.available
```
Flow:
```text
POST /api/fetch { url }
```
If response is `type: "single"`, show a queued/download task.
If response is `type: "playlist"`, show a confirmation modal, then:
```text
POST /api/fetch/confirm { playlistTitle, items }
```
Poll:
```text
GET /api/fetch
```
Listen for WebSocket progress messages:
```text
fetch_progress
fetch_complete
fetch_error
fetch_cancelled
```
### Step 10: Add Optional Local Caching
Caching is not needed for a valid frontend, but it is one of Blastoise's best
features.
Use IndexedDB:
```ts
interface CachedTrack {
id: string;
blob: Blob;
contentType: string;
}
```
Rules:
- Key by `track.id`.
- Never key by filename.
- Prefer cached blob URLs for playback.
- Fall back to `/api/tracks/:trackId`.
- Use range requests to prefetch seek segments if you want a buffer bar.
- Revoke blob URLs when replacing or deleting cached blobs.
Simple mode:
1. When a user plays a track, fetch the full file in the background.
2. Store it in IndexedDB under `track.id`.
3. Next time, play from `URL.createObjectURL(blob)`.
Advanced mode:
1. Divide each track into virtual segments.
2. Use `Range: bytes=start-end` requests to fill missing segments.
3. When all segments are present, download and persist the full blob.
### Step 11: Validate The App
Manual smoke test:
1. Start the server with `bun run server.ts`.
2. Open the frontend.
3. Load status and auth state.
4. Continue as guest or log in with the test user if configured.
5. Load library and channels.
6. Connect to the default channel WebSocket.
7. Confirm first WebSocket state includes `queue`.
8. Confirm audio source uses `/api/tracks/:trackId`.
9. Seek locally after a state update and confirm drift correction snaps back.
10. Pause/unpause from one client and confirm another client follows.
11. Add a track to queue and confirm both clients receive a state with `queue`.
12. Switch channels and confirm the server sends `switched`.
13. Test mobile layout.
Permission smoke test:
1. Use a guest session.
2. Confirm listening works.
3. Try pause/seek/jump.
4. Confirm the UI reports lack of permission or no-ops gracefully.
Playlist smoke test:
1. Create a playlist as a non-guest user.
2. Add tracks to it.
3. Add the playlist to queue.
4. Make it public or generate a share token.
5. Load it through the shared endpoint.
## Common Pitfalls
| Symptom | Likely Cause |
|---|---|
| Audio 404s | The app used `filename` instead of `track.id` in `/api/tracks/:id`. |
| Queue disappears after a state update | The client replaced queue with `undefined`; WebSocket queue is optional. |
| Sync slowly drifts | The client only uses local audio time and does not correct against server timestamps. |
| Guests can see controls that do nothing | Guests cannot control playback even if they can listen. |
| Queue remove deletes the wrong duplicate | The UI removed by track ID instead of queue position. |
| Login works in same-origin dev but not hosted frontend | Cookie auth needs same-origin, a reverse proxy, or CORS with credentials. |
| Shared playlist copy fails | The route is `POST /api/playlists/shared/:token`, with no `/copy` suffix. |
| Native WebSocket connects as guest after login | The client did not send the stored session cookie in the WebSocket request. |
## Minimal Viable Scope
If you want the smallest useful Blastoise frontend, build only:
- `GET /api/auth/me`
- `GET /api/library`
- `GET /api/channels`
- `WS /api/channels/:id/ws`
- `GET /api/tracks/:trackId`
- WebSocket actions: `switch`, `pause`, `unpause`, `seek`, `jump`
That is enough to make a synchronized player.

View File

@ -0,0 +1,380 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlastoisePing.app; sourceTree = BUILT_PRODUCTS_DIR; };
1A2B3C4D5E6F700000000101 /* BlastoisePingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlastoisePingApp.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000103 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000105 /* pixelify_sans.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Fonts/pixelify_sans.ttf; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000110 /* AppTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTypes.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000111 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000112 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000113 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000114 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDeckView.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000116 /* Panels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F700000000117 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1A2B3C4D5E6F700000000200 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A2B3C4D5E6F700000000300 = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000301 /* BlastoisePing */,
1A2B3C4D5E6F700000000302 /* Products */,
);
sourceTree = "<group>";
};
1A2B3C4D5E6F700000000301 /* BlastoisePing */ = {
isa = PBXGroup;
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 = "<group>";
};
1A2B3C4D5E6F700000000303 /* Models */ = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000110 /* AppTypes.swift */,
);
path = Models;
sourceTree = "<group>";
};
1A2B3C4D5E6F700000000304 /* State */ = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000111 /* AppModel.swift */,
);
path = State;
sourceTree = "<group>";
};
1A2B3C4D5E6F700000000305 /* UI */ = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000112 /* Theme.swift */,
);
path = UI;
sourceTree = "<group>";
};
1A2B3C4D5E6F700000000306 /* Views */ = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000113 /* AuthView.swift */,
1A2B3C4D5E6F700000000114 /* HeaderView.swift */,
1A2B3C4D5E6F700000000115 /* PlayerDeckView.swift */,
1A2B3C4D5E6F700000000116 /* Panels.swift */,
1A2B3C4D5E6F700000000117 /* Components.swift */,
);
path = Views;
sourceTree = "<group>";
};
1A2B3C4D5E6F700000000302 /* Products */ = {
isa = PBXGroup;
children = (
1A2B3C4D5E6F700000000100 /* BlastoisePing.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1A2B3C4D5E6F700000000400 /* BlastoisePing */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */;
buildPhases = (
1A2B3C4D5E6F700000000500 /* Sources */,
1A2B3C4D5E6F700000000600 /* Resources */,
1A2B3C4D5E6F700000000200 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = BlastoisePing;
productName = BlastoisePing;
productReference = 1A2B3C4D5E6F700000000100 /* BlastoisePing.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1A2B3C4D5E6F700000000800 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
1A2B3C4D5E6F700000000400 = {
CreatedOnToolsVersion = 15.4;
};
};
};
buildConfigurationList = 1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 1A2B3C4D5E6F700000000300;
productRefGroup = 1A2B3C4D5E6F700000000302 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1A2B3C4D5E6F700000000400 /* BlastoisePing */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1A2B3C4D5E6F700000000600 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A2B3C4D5E6F700000000004 /* pixelify_sans.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1A2B3C4D5E6F700000000500 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
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;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1A2B3C4D5E6F700000000900 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
1A2B3C4D5E6F700000000901 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
1A2B3C4D5E6F700000000902 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = BlastoisePing/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
1A2B3C4D5E6F700000000903 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = BlastoisePing/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.peterino.blastoiseping;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1A2B3C4D5E6F700000000700 /* Build configuration list for PBXProject "BlastoisePing" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C4D5E6F700000000900 /* Debug */,
1A2B3C4D5E6F700000000901 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1A2B3C4D5E6F700000000701 /* Build configuration list for PBXNativeTarget "BlastoisePing" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A2B3C4D5E6F700000000902 /* Debug */,
1A2B3C4D5E6F700000000903 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 1A2B3C4D5E6F700000000800 /* Project object */;
}

View File

@ -0,0 +1,10 @@
import SwiftUI
@main
struct BlastoisePingApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -0,0 +1,126 @@
import SwiftUI
struct ContentView: View {
@StateObject private var model = AppModel()
@State private var username = ""
@State private var password = ""
@State private var selectedTab: MainTab = .rooms
var body: some View {
NavigationStack {
ZStack {
Theme.background.ignoresSafeArea()
if model.authState == .signedIn {
mainApp
} else {
AuthView(
model: model,
username: $username,
password: $password
)
}
}
.navigationTitle("Blastoise")
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarBackground(Theme.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.font(Theme.bodyFont)
.buttonBorderShape(.roundedRectangle(radius: Theme.corner))
}
.onChange(of: model.authState) { _, authState in
if authState == .signedIn {
password = ""
}
}
}
private var mainApp: some View {
ScrollView {
VStack(spacing: 14) {
HeaderView(model: model)
PlayerDeckView(model: model)
tabStrip
selectedPanel
DebugFooterView(model: model)
}
.padding(.horizontal, 14)
.padding(.bottom, 18)
}
}
private var tabStrip: some View {
HStack(spacing: 8) {
ForEach(MainTab.allCases) { tab in
Button {
selectedTab = tab
if tab == .library {
Task { await model.loadLibraryIfNeeded() }
} else if tab == .playlists {
Task { await model.loadPlaylistsIfNeeded() }
}
} label: {
Label(tab.title, systemImage: tab.icon)
.labelStyle(.iconOnly)
.frame(width: 44, height: 40)
.background(selectedTab == tab ? Theme.accent : Theme.panel2)
.foregroundStyle(selectedTab == tab ? Theme.background : Theme.text)
.clipShape(RoundedRectangle(cornerRadius: Theme.corner))
}
.accessibilityLabel(tab.title)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var selectedPanel: some View {
switch selectedTab {
case .rooms:
RoomsPanel(model: model)
case .queue:
QueuePanel(model: model)
case .people:
PeoplePanel(model: model)
case .library:
LibraryPanel(model: model)
case .playlists:
PlaylistsPanel(model: model)
case .debug:
DebugPanel(model: model)
}
}
}
private enum MainTab: String, CaseIterable, Identifiable {
case rooms
case queue
case people
case library
case playlists
case debug
var id: String { rawValue }
var title: String {
switch self {
case .rooms: return "Rooms"
case .queue: return "Queue"
case .people: return "People"
case .library: return "Library"
case .playlists: return "Lists"
case .debug: return "Debug"
}
}
var icon: String {
switch self {
case .rooms: return "radio"
case .queue: return "list.bullet"
case .people: return "person.2"
case .library: return "music.note.list"
case .playlists: return "rectangle.stack"
case .debug: return "waveform.path.ecg"
}
}
}

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Blastoise</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Blastoise Ping can check a server running on your local network.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIAppFonts</key>
<array>
<string>pixelify_sans.ttf</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -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]?
}

File diff suppressed because it is too large Load Diff

View File

@ -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))
}

View File

@ -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<String>, field: Field) -> some View {
TextField(placeholder, text: text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(field == .server ? .URL : .default)
.focused($focused, equals: field)
.textFieldStyle()
}
}

View File

@ -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<Actions: View>: 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)
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -0,0 +1,30 @@
# Blastoise iOS Sketch
Native SwiftUI sketch for the Blastoise/MusicRoom server.
## What It Does
- Defaults to `http://mhsgroove.peterino.com:3001`.
- Signs in or signs up with the server.
- Loads rooms, queue state, people, library, and playlists.
- Connects to a room WebSocket and streams `/api/tracks/:id` through `AVPlayer`.
- Applies server timestamp sync and drift correction.
- 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
open ios/BlastoisePing/BlastoisePing.xcodeproj
```
The app currently allows arbitrary HTTP loads in `Info.plist` so it can reach the existing plain-HTTP test server and local development servers. Narrow that before any public distribution.