# 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.