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.
 
 
Music/docs/superpowers/specs/2026-05-26-remote-mode-desi...

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.