You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
270 lines
11 KiB
270 lines
11 KiB
# Remote Mode — Design Spec
|
|
|
|
## Overview
|
|
|
|
Add a Host/Remote mode to the Music app so a MacBook can control playback on a Mac Mini over the local network. The remote sees the full library and controls playback, but audio plays on the host. The remote is read-only — no playlist or library modifications for v1.
|
|
|
|
## Architecture
|
|
|
|
Two roles the app can operate in, one at a time:
|
|
|
|
- **Host:** Runs a network server, advertises via Bonjour, serves its database, accepts playback commands, streams playback state.
|
|
- **Remote:** Discovers hosts via Bonjour, downloads the host's database, sends playback commands, displays synced playback state.
|
|
|
|
```
|
|
┌──────────────────────┐ ┌──────────────────────┐
|
|
│ MAC MINI │ │ MACBOOK │
|
|
│ (Host) │ │ (Remote) │
|
|
│ │ │ │
|
|
│ Existing App │ │ Existing App │
|
|
│ ┌────────────────┐ │ │ ┌────────────────┐ │
|
|
│ │ AudioService │ │◄────────│ │ RemoteClient │ │
|
|
│ │ PlayerViewModel│ │ WebSocket│ │ │ │
|
|
│ │ DatabaseService│──┼────────►│ │ Local DB copy │ │
|
|
│ └────────────────┘ │ HTTP │ └────────────────┘ │
|
|
│ ┌────────────────┐ │ │ │
|
|
│ │ HostServer │ │ │ Reuses all existing │
|
|
│ │ - HTTP (DB) │ │ │ ViewModels & Views │
|
|
│ │ - WebSocket │ │ │ for browsing │
|
|
│ │ - Bonjour │ │ │ │
|
|
│ └────────────────┘ │ │ │
|
|
└──────────────────────┘ └──────────────────────┘
|
|
```
|
|
|
|
### Playback Abstraction
|
|
|
|
A `PlaybackController` protocol abstracts where playback happens:
|
|
|
|
- `LocalPlaybackController` — wraps the existing `AudioService` + `PlayerViewModel` logic. This is what the app uses today.
|
|
- `RemotePlaybackController` — sends commands over WebSocket to the host, receives state updates, and updates the `PlayerViewModel` accordingly.
|
|
|
|
Views and ViewModels call the same methods (`play`, `pause`, `next`, `seek`, etc.) regardless of which controller is active. The active controller decides whether that's local audio or a network command.
|
|
|
|
## Host Server
|
|
|
|
New service: `HostServer`.
|
|
|
|
### Bonjour Advertisement
|
|
|
|
- Uses `NWListener` with service type `_musicremote._tcp`.
|
|
- Service name is the computer's local name.
|
|
- Automatically discoverable on the local network when hosting is enabled.
|
|
|
|
### HTTP — Database Download
|
|
|
|
- When a remote connects, its first request is `GET /db`.
|
|
- The host reads `db.sqlite` from its Application Support directory and streams it as a binary response.
|
|
- Typically a few MB, under a second on WiFi.
|
|
|
|
### WebSocket — Command & State Channel
|
|
|
|
After the DB download, the remote establishes a WebSocket connection for bidirectional communication.
|
|
|
|
**Remote → Host (commands):**
|
|
|
|
| Command | Payload |
|
|
|---------|---------|
|
|
| `play` | `trackId`, `queueIds` (array of track IDs) |
|
|
| `pause` | — |
|
|
| `resume` | — |
|
|
| `next` | — |
|
|
| `previous` | — |
|
|
| `seek` | `position` (seconds) |
|
|
| `setVolume` | `level` (0.0–1.0) |
|
|
| `toggleShuffle` | — |
|
|
| `refreshDB` | — |
|
|
|
|
**Host → Remote (events):**
|
|
|
|
| Event | Payload |
|
|
|-------|---------|
|
|
| `playbackState` | `trackId`, `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` |
|
|
| `dbReady` | — (sent after refreshDB, signals new DB is available for download) |
|
|
| `error` | `message` (human-readable) |
|
|
|
|
### State Update Frequency
|
|
|
|
- Immediate on discrete events: play, pause, track change, volume change, shuffle toggle.
|
|
- Every ~1 second while playing for progress bar position.
|
|
- The remote interpolates locally between updates for smooth scrubber movement.
|
|
|
|
### Connection Limits
|
|
|
|
Single remote connection at a time for v1. A second connection attempt is rejected with a clear error.
|
|
|
|
## Remote Client
|
|
|
|
New service: `RemoteClient`.
|
|
|
|
### Discovery
|
|
|
|
- Uses `NWBrowser` to scan for `_musicremote._tcp` services.
|
|
- Presents discovered hosts by computer name in the connection sheet.
|
|
- Resolves the selected endpoint to get IP/port.
|
|
|
|
### Connection Flow
|
|
|
|
1. Connect to the host's HTTP endpoint.
|
|
2. Download `db.sqlite`, save to `Application Support/Music/remote_db.sqlite`.
|
|
3. Open the downloaded DB with `DatabaseService` in read-only mode.
|
|
4. Establish the WebSocket connection.
|
|
5. App transitions to remote mode — existing ViewModels reload from the downloaded DB.
|
|
|
|
### Command Forwarding
|
|
|
|
In remote mode, the `RemotePlaybackController` intercepts all playback calls and sends them as WebSocket commands instead of calling the local `AudioService`.
|
|
|
|
### State Sync
|
|
|
|
The remote listens for `playbackState` messages and updates the `PlayerViewModel`:
|
|
- Current track is looked up by ID from the local DB copy.
|
|
- `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` are set directly.
|
|
- SwiftUI observation triggers UI updates automatically.
|
|
|
|
### DB Refresh
|
|
|
|
A "Refresh Library" action sends `refreshDB`, the host signals `dbReady`, the remote re-downloads the DB and reloads the ViewModels.
|
|
|
|
### Disconnection
|
|
|
|
On disconnect (user-initiated or connection drop), the app returns to local mode. The temporary remote DB file is deleted.
|
|
|
|
## Message Protocol
|
|
|
|
JSON over WebSocket. Swift `Codable` enums for type safety.
|
|
|
|
```json
|
|
// Remote → Host
|
|
{"type": "play", "payload": {"trackId": 42, "queueIds": [42, 43, 44, 45]}}
|
|
{"type": "pause"}
|
|
{"type": "resume"}
|
|
{"type": "next"}
|
|
{"type": "previous"}
|
|
{"type": "seek", "payload": {"position": 65.3}}
|
|
{"type": "setVolume", "payload": {"level": 0.75}}
|
|
{"type": "toggleShuffle"}
|
|
{"type": "refreshDB"}
|
|
|
|
// Host → Remote
|
|
{"type": "playbackState", "payload": {
|
|
"trackId": 42,
|
|
"isPlaying": true,
|
|
"currentTime": 65.3,
|
|
"duration": 210.0,
|
|
"volume": 0.75,
|
|
"isShuffled": false
|
|
}}
|
|
{"type": "dbReady"}
|
|
{"type": "error", "payload": {"message": "Track file not found"}}
|
|
```
|
|
|
|
### Handshake
|
|
|
|
On WebSocket connect, host and client exchange a handshake message with app version. Version mismatches are caught early and logged.
|
|
|
|
### Keep-Alive
|
|
|
|
WebSocket ping/pong at 5-second intervals. If 3 consecutive pings go unanswered, the connection is declared lost.
|
|
|
|
## UI Changes
|
|
|
|
### Menu Bar
|
|
|
|
- **"Enable Host Mode"** — toggle menu item. Starts/stops the `HostServer`.
|
|
- **"Connect to Remote..."** — opens the connection sheet.
|
|
|
|
### Connection Sheet (Remote Side)
|
|
|
|
Modal sheet showing:
|
|
- List of discovered Bonjour hosts (computer name + connectivity indicator).
|
|
- "Connect" button for the selected host.
|
|
- Progress indicator during DB download.
|
|
- Error state with retry if connection fails.
|
|
|
|
### Remote Mode Indicators
|
|
|
|
When connected as a remote:
|
|
- A persistent banner/badge showing "Connected to [host name]" with a disconnect button.
|
|
- Playlist creation/editing UI disabled (greyed out context menus, hidden "New Playlist").
|
|
- "Open Music Folder..." menu item disabled.
|
|
- "Refresh Library" action available (triggers DB re-download).
|
|
|
|
### Host Mode Indicators
|
|
|
|
When hosting:
|
|
- Status indicator showing "Hosting" or "Hosting · [remote name] connected".
|
|
|
|
### Unchanged
|
|
|
|
Track table, player controls, search bar, home view, playlist bar — all work as-is against the local DB copy and the `PlaybackController` abstraction.
|
|
|
|
## Observability
|
|
|
|
### Structured Logging
|
|
|
|
`os.Logger` with subsystem `com.music.remote` and two categories: `host` and `client`. Logs are filterable in Console.app.
|
|
|
|
| Level | Examples |
|
|
|-------|---------|
|
|
| Info | "Host started on port 8432", "Remote connected: Laurent's MacBook", "DB download complete (2.4 MB, 340ms)" |
|
|
| Debug | Command send/receive, state update cycle, Bonjour browse events, connection lifecycle transitions |
|
|
| Error | Connection refused, DB read failure, WebSocket decode failure, unexpected disconnect with reason |
|
|
|
|
### Connection State Machine
|
|
|
|
Every state transition is logged and drives user-visible status:
|
|
|
|
```
|
|
Disconnected → Discovering → Found Host → Downloading DB → Connecting WebSocket → Connected
|
|
↑ │
|
|
└──── Connection Lost ◄───────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
User-visible status messages: "Searching for hosts...", "Connecting to [name]...", "Downloading library...", "Connected to [name]", "Connection lost — Reconnect?"
|
|
|
|
### Error Messages
|
|
|
|
Every error includes:
|
|
- A clean, human-readable summary for the user (shown in UI).
|
|
- The underlying `NWError` description in the log for debugging.
|
|
|
|
Examples: "Host refused connection", "Download timed out after 10s", "Network changed", "Host stopped hosting".
|
|
|
|
### Diagnostics
|
|
|
|
- Version handshake on connect catches protocol mismatches early.
|
|
- WebSocket keep-alive detects stale connections within 15 seconds.
|
|
- All incoming commands logged at debug level on the host for traceability.
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
- `RemoteCommand` / `HostEvent` Codable round-trip for every message type.
|
|
- `RemotePlaybackController` sends correct WebSocket messages for each action.
|
|
- Connection state machine: valid transitions succeed, invalid transitions are rejected.
|
|
- `HostServer` command dispatch: incoming commands map to correct `PlayerViewModel` calls.
|
|
|
|
### Integration Tests
|
|
|
|
- Loopback connection: `HostServer` + `RemoteClient` in the same process over localhost — full flow from DB download through command/response round-trip.
|
|
- DB download integrity: downloaded DB matches source schema and row counts.
|
|
- State sync accuracy: play a track on host, verify remote receives correct `playbackState` values.
|
|
|
|
Real network connections in integration tests — no mocks for the networking layer.
|
|
|
|
### Manual Test Scenarios
|
|
|
|
- Happy path: enable host → connect remote → browse → play → verify audio on host, UI synced on remote.
|
|
- Kill host app mid-playback → remote shows "Connection lost" cleanly.
|
|
- Disconnect WiFi on remote → reconnect flow works.
|
|
- Scan new folder on host while remote connected → remote can refresh and see new tracks.
|
|
- Attempt playlist creation on remote → properly disabled.
|
|
|
|
## Scope & Constraints
|
|
|
|
- **v1 only:** Single remote, read-only, no authentication, local network only.
|
|
- **No changes to existing playback logic:** The `HostServer` wraps `PlayerViewModel` and `AudioService`, it doesn't modify them.
|
|
- **No dependencies added:** All networking uses Apple's Network.framework.
|
|
- **Existing UI untouched:** Only additions are menu items, connection sheet, and status indicators.
|
|
- **Play counts track on the host:** Since the host is playing the audio, play count increments happen on the host's database. The remote's local DB copy is a read-only snapshot and is not written to.
|
|
|