Compare commits
No commits in common. "integration" and "dev/android" have entirely different histories.
integratio
...
dev/androi
|
|
@ -33,26 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.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/
|
tmp/
|
||||||
library_cache.db
|
library_cache.db
|
||||||
musicroom.db
|
musicroom.db
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,176 +0,0 @@
|
||||||
# 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
585
docs/buildme.md
|
|
@ -1,585 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
// !$*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 */;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct BlastoisePingApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,63 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
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
|
|
@ -1,77 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# 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.
|
|
||||||
Loading…
Reference in New Issue