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 existingAudioService+PlayerViewModellogic. This is what the app uses today.RemotePlaybackController— sends commands over WebSocket to the host, receives state updates, and updates thePlayerViewModelaccordingly.
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
NWListenerwith 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.sqlitefrom 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
NWBrowserto scan for_musicremote._tcpservices. - Presents discovered hosts by computer name in the connection sheet.
- Resolves the selected endpoint to get IP/port.
Connection Flow
- Connect to the host's HTTP endpoint.
- Download
db.sqlite, save toApplication Support/Music/remote_db.sqlite. - Open the downloaded DB with
DatabaseServicein read-only mode. - Establish the WebSocket connection.
- 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,isShuffledare 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.
// 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
NWErrordescription 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/HostEventCodable round-trip for every message type.RemotePlaybackControllersends correct WebSocket messages for each action.- Connection state machine: valid transitions succeed, invalid transitions are rejected.
HostServercommand dispatch: incoming commands map to correctPlayerViewModelcalls.
Integration Tests
- Loopback connection:
HostServer+RemoteClientin 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
playbackStatevalues.
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
HostServerwrapsPlayerViewModelandAudioService, 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.