# Remote Mode Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add Host/Remote mode so the app on a MacBook can control playback on a Mac Mini over the local network. **Architecture:** PlayerViewModel becomes the single source of truth for all playback state — Views never read from AudioService directly. In local mode, PlayerViewModel syncs state from AudioService via a callback. In remote mode, it syncs from the network. This eliminates all `if isRemoteMode` branching from the View layer. Networking uses Network.framework with Bonjour discovery, HTTP for one-shot DB download, and NDJSON (newline-delimited JSON) over TCP for the bidirectional command channel. **Tech Stack:** Network.framework (NWListener, NWConnection, NWBrowser), Bonjour/mDNS, os.Logger, GRDB (existing), Swift Codable for JSON protocol. **Architectural note:** The spec describes a `PlaybackController` protocol with separate implementations. This plan achieves the same goal (Views unchanged between modes) more simply: PlayerViewModel handles both modes internally with clear `if/else` routing. The protocol added a layer of indirection that obscured what was happening, and ContentView still ended up needing `isRemoteMode` checks for reading state. This approach eliminates all of that. --- ## File Structure ### New Files | File | Responsibility | |------|---------------| | `Music/Remote/RemoteProtocol.swift` | `RemoteCommand` and `HostEvent` Codable enums, protocol version constant, `PlaybackStatePayload`, `HandshakeMessage` | | `Music/Remote/RemoteLogger.swift` | `os.Logger` wrappers with `host` and `client` categories under `com.music.remote` subsystem | | `Music/Remote/ConnectionState.swift` | Connection state machine enum with transition validation and user-visible messages | | `Music/Remote/NDJSONTransport.swift` | Newline-delimited JSON framing over `NWConnection` — line-buffered reads, typed sends. Shared by host and client. | | `Music/Remote/HostServer.swift` | `NWListener`-based server: Bonjour advertisement, HTTP DB serving, command channel via `NDJSONTransport`, command dispatch to `PlayerViewModel` | | `Music/Remote/RemoteClient.swift` | `NWBrowser`-based client: Bonjour discovery, DB download, command channel via `NDJSONTransport`, state receiving | | `Music/Views/ConnectionSheet.swift` | SwiftUI sheet for discovering and connecting to hosts | | `MusicTests/RemoteProtocolTests.swift` | Codable round-trip tests for all message types | | `MusicTests/ConnectionStateTests.swift` | State machine transition tests | | `MusicTests/NDJSONTransportTests.swift` | Line-buffered framing tests | | `MusicTests/PlayerViewModelRemoteTests.swift` | PlayerViewModel remote mode tests: command forwarding, state syncing | | `MusicTests/HostServerIntegrationTests.swift` | Loopback integration: DB download, command/state round-trip | ### Modified Files | File | Changes | |------|---------| | `Music/Services/AudioService.swift` | Add `onPlaybackStateChanged` callback, fire on time updates and discrete state changes | | `Music/Services/DatabaseService.swift` | Add `fetchTracksByIds(_:)` method for efficient ID-based lookups | | `Music/ViewModels/PlayerViewModel.swift` | Add `isPlaying`, `currentTime`, `duration`, `volume` properties. Add `enterRemoteMode`/`exitRemoteMode`. Route all playback actions through local audio or remote commands. | | `Music/ContentView.swift` | Replace all `audio.*` reads with `player.*`. Remove `audio` property. Add `networkStatus` for banner/indicators. | | `Music/Views/PlaylistBarView.swift` | Add `isRemoteMode` to disable context menus | | `Music/MusicApp.swift` | Add `HostServer`, `RemoteClient`, menu items, DB swap on connect/disconnect | --- ### Task 1: Message Protocol — `RemoteCommand` and `HostEvent` **Files:** - Create: `Music/Remote/RemoteProtocol.swift` - Test: `MusicTests/RemoteProtocolTests.swift` - [ ] **Step 1: Write failing tests for RemoteCommand encoding/decoding** ```swift // MusicTests/RemoteProtocolTests.swift import Testing import Foundation @testable import Music struct RemoteProtocolTests { // Encodes each RemoteCommand to JSON and decodes it back, // verifying the round-trip preserves the value exactly. @Test func playCommandRoundTrip() throws { let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func pauseCommandRoundTrip() throws { let cmd = RemoteCommand.pause let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func resumeCommandRoundTrip() throws { let cmd = RemoteCommand.resume let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func nextCommandRoundTrip() throws { let cmd = RemoteCommand.next let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func previousCommandRoundTrip() throws { let cmd = RemoteCommand.previous let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func seekCommandRoundTrip() throws { let cmd = RemoteCommand.seek(position: 65.3) let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func setVolumeCommandRoundTrip() throws { let cmd = RemoteCommand.setVolume(level: 0.75) let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func toggleShuffleCommandRoundTrip() throws { let cmd = RemoteCommand.toggleShuffle let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func refreshDBCommandRoundTrip() throws { let cmd = RemoteCommand.refreshDB let data = try JSONEncoder().encode(cmd) let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) #expect(decoded == cmd) } @Test func playbackStateEventRoundTrip() throws { let event = HostEvent.playbackState(PlaybackStatePayload( trackId: 42, isPlaying: true, currentTime: 65.3, duration: 210.0, volume: 0.75, isShuffled: false )) let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(HostEvent.self, from: data) #expect(decoded == event) } @Test func dbReadyEventRoundTrip() throws { let event = HostEvent.dbReady let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(HostEvent.self, from: data) #expect(decoded == event) } @Test func errorEventRoundTrip() throws { let event = HostEvent.error(message: "Track file not found") let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(HostEvent.self, from: data) #expect(decoded == event) } @Test func handshakeRoundTrip() throws { let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.0.0") let data = try JSONEncoder().encode(msg) let decoded = try JSONDecoder().decode(HandshakeMessage.self, from: data) #expect(decoded == msg) } // Decodes from known JSON strings to verify wire format matches the spec. @Test func playCommandDecodesFromWireFormat() throws { let json = """ {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} """ let decoded = try JSONDecoder().decode(RemoteCommand.self, from: Data(json.utf8)) #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) } @Test func playbackStateDecodesFromWireFormat() throws { let json = """ {"type":"playbackState","payload":{"trackId":42,"isPlaying":true,"currentTime":65.3,"duration":210.0,"volume":0.75,"isShuffled":false}} """ let decoded = try JSONDecoder().decode(HostEvent.self, from: Data(json.utf8)) #expect(decoded == .playbackState(PlaybackStatePayload( trackId: 42, isPlaying: true, currentTime: 65.3, duration: 210.0, volume: 0.75, isShuffled: false ))) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` Expected: Compilation failure — `RemoteCommand`, `HostEvent`, etc. not defined. - [ ] **Step 3: Implement the protocol types** ```swift // Music/Remote/RemoteProtocol.swift import Foundation let RemoteProtocolVersion: Int = 1 nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { var trackId: Int64? var isPlaying: Bool var currentTime: Double var duration: Double var volume: Float var isShuffled: Bool } nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { var protocolVersion: Int var appVersion: String } nonisolated enum RemoteCommand: Codable, Equatable, Sendable { case play(trackId: Int64, queueIds: [Int64]) case pause case resume case next case previous case seek(position: Double) case setVolume(level: Float) case toggleShuffle case refreshDB private enum CodingKeys: String, CodingKey { case type, payload } private enum CommandType: String, Codable { case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB } private struct PlayPayload: Codable { let trackId: Int64; let queueIds: [Int64] } private struct SeekPayload: Codable { let position: Double } private struct VolumePayload: Codable { let level: Float } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .play(let trackId, let queueIds): try container.encode(CommandType.play, forKey: .type) try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) case .pause: try container.encode(CommandType.pause, forKey: .type) case .resume: try container.encode(CommandType.resume, forKey: .type) case .next: try container.encode(CommandType.next, forKey: .type) case .previous: try container.encode(CommandType.previous, forKey: .type) case .seek(let position): try container.encode(CommandType.seek, forKey: .type) try container.encode(SeekPayload(position: position), forKey: .payload) case .setVolume(let level): try container.encode(CommandType.setVolume, forKey: .type) try container.encode(VolumePayload(level: level), forKey: .payload) case .toggleShuffle: try container.encode(CommandType.toggleShuffle, forKey: .type) case .refreshDB: try container.encode(CommandType.refreshDB, forKey: .type) } } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(CommandType.self, forKey: .type) switch type { case .play: let p = try container.decode(PlayPayload.self, forKey: .payload) self = .play(trackId: p.trackId, queueIds: p.queueIds) case .pause: self = .pause case .resume: self = .resume case .next: self = .next case .previous: self = .previous case .seek: let p = try container.decode(SeekPayload.self, forKey: .payload) self = .seek(position: p.position) case .setVolume: let p = try container.decode(VolumePayload.self, forKey: .payload) self = .setVolume(level: p.level) case .toggleShuffle: self = .toggleShuffle case .refreshDB: self = .refreshDB } } } nonisolated enum HostEvent: Codable, Equatable, Sendable { case playbackState(PlaybackStatePayload) case dbReady case error(message: String) private enum CodingKeys: String, CodingKey { case type, payload } private enum EventType: String, Codable { case playbackState, dbReady, error } private struct ErrorPayload: Codable { let message: String } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .playbackState(let payload): try container.encode(EventType.playbackState, forKey: .type) try container.encode(payload, forKey: .payload) case .dbReady: try container.encode(EventType.dbReady, forKey: .type) case .error(let message): try container.encode(EventType.error, forKey: .type) try container.encode(ErrorPayload(message: message), forKey: .payload) } } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(EventType.self, forKey: .type) switch type { case .playbackState: self = .playbackState(try container.decode(PlaybackStatePayload.self, forKey: .payload)) case .dbReady: self = .dbReady case .error: self = .error(message: try container.decode(ErrorPayload.self, forKey: .payload).message) } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` Expected: All 15 tests pass. - [ ] **Step 5: Commit** ```bash git add Music/Remote/RemoteProtocol.swift MusicTests/RemoteProtocolTests.swift git commit -m "feat(remote): add RemoteCommand and HostEvent protocol types with tests" ``` --- ### Task 2: Remote Logger **Files:** - Create: `Music/Remote/RemoteLogger.swift` - [ ] **Step 1: Implement the logger** ```swift // Music/Remote/RemoteLogger.swift import os enum RemoteLogger { static let host = Logger(subsystem: "com.music.remote", category: "host") static let client = Logger(subsystem: "com.music.remote", category: "client") } ``` - [ ] **Step 2: Commit** ```bash git add Music/Remote/RemoteLogger.swift git commit -m "feat(remote): add structured os.Logger for host and client" ``` --- ### Task 3: Connection State Machine **Files:** - Create: `Music/Remote/ConnectionState.swift` - Test: `MusicTests/ConnectionStateTests.swift` - [ ] **Step 1: Write failing tests** ```swift // MusicTests/ConnectionStateTests.swift import Testing import Foundation @testable import Music struct ConnectionStateTests { // Verifies that each valid forward transition is allowed. @Test func validTransitionsSucceed() { #expect(ConnectionState.disconnected.canTransition(to: .discovering) == true) #expect(ConnectionState.discovering.canTransition(to: .foundHost("Mac Mini")) == true) #expect(ConnectionState.foundHost("Mac Mini").canTransition(to: .downloadingDB) == true) #expect(ConnectionState.downloadingDB.canTransition(to: .connectingCommandChannel) == true) #expect(ConnectionState.connectingCommandChannel.canTransition(to: .connected("Mac Mini")) == true) #expect(ConnectionState.connected("Mac Mini").canTransition(to: .connectionLost("Network changed")) == true) #expect(ConnectionState.connectionLost("Network changed").canTransition(to: .discovering) == true) } // Verifies that skipping states is rejected. @Test func invalidTransitionsRejected() { #expect(ConnectionState.disconnected.canTransition(to: .connected("Mac Mini")) == false) #expect(ConnectionState.discovering.canTransition(to: .connected("Mac Mini")) == false) #expect(ConnectionState.connected("Mac Mini").canTransition(to: .discovering) == false) } // Any state can transition to disconnected (user can always disconnect). @Test func anyStateCanDisconnect() { #expect(ConnectionState.discovering.canTransition(to: .disconnected) == true) #expect(ConnectionState.foundHost("X").canTransition(to: .disconnected) == true) #expect(ConnectionState.downloadingDB.canTransition(to: .disconnected) == true) #expect(ConnectionState.connectingCommandChannel.canTransition(to: .disconnected) == true) #expect(ConnectionState.connected("X").canTransition(to: .disconnected) == true) } // Verifies the human-readable status message for each state. @Test func userVisibleDescriptions() { #expect(ConnectionState.disconnected.userMessage == nil) #expect(ConnectionState.discovering.userMessage == "Searching for hosts...") #expect(ConnectionState.foundHost("Mac Mini").userMessage == "Found Mac Mini") #expect(ConnectionState.downloadingDB.userMessage == "Downloading library...") #expect(ConnectionState.connectingCommandChannel.userMessage == "Connecting...") #expect(ConnectionState.connected("Mac Mini").userMessage == "Connected to Mac Mini") #expect(ConnectionState.connectionLost("Network changed").userMessage == "Connection lost — Network changed") } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` Expected: Compilation failure. - [ ] **Step 3: Implement the state machine** ```swift // Music/Remote/ConnectionState.swift import Foundation nonisolated enum ConnectionState: Equatable, Sendable { case disconnected case discovering case foundHost(String) case downloadingDB case connectingCommandChannel case connected(String) case connectionLost(String) var userMessage: String? { switch self { case .disconnected: nil case .discovering: "Searching for hosts..." case .foundHost(let name): "Found \(name)" case .downloadingDB: "Downloading library..." case .connectingCommandChannel: "Connecting..." case .connected(let name): "Connected to \(name)" case .connectionLost(let reason): "Connection lost — \(reason)" } } var isConnected: Bool { if case .connected = self { return true } return false } func canTransition(to next: ConnectionState) -> Bool { if next == .disconnected { return true } switch (self, next) { case (.disconnected, .discovering), (.discovering, .foundHost), (.foundHost, .downloadingDB), (.downloadingDB, .connectingCommandChannel), (.connectingCommandChannel, .connected), (.connected, .connectionLost), (.connectionLost, .discovering): return true default: return false } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` Expected: All 4 tests pass. - [ ] **Step 5: Commit** ```bash git add Music/Remote/ConnectionState.swift MusicTests/ConnectionStateTests.swift git commit -m "feat(remote): add ConnectionState machine with transition validation" ``` --- ### Task 4: NDJSONTransport — Shared Line-Buffered Framing **Files:** - Create: `Music/Remote/NDJSONTransport.swift` - Test: `MusicTests/NDJSONTransportTests.swift` This class handles the newline-delimited JSON framing over a raw TCP connection. Both `HostServer` and `RemoteClient` use it for their command channels, eliminating duplicated line-buffering logic. - [ ] **Step 1: Write failing tests** ```swift // MusicTests/NDJSONTransportTests.swift import Testing import Foundation @testable import Music struct NDJSONTransportTests { // Verifies that processReceivedData correctly splits newline-delimited // input into individual lines, handling partial lines across calls. @Test func splitsLinesCorrectly() { var lines: [String] = [] let transport = NDJSONLineBuffer { lines.append($0) } transport.feed(Data("{\"type\":\"pause\"}\n{\"type\":\"resume\"}\n".utf8)) #expect(lines == ["{\"type\":\"pause\"}", "{\"type\":\"resume\"}"]) } // Verifies that a line split across two TCP reads is reassembled. @Test func handlesPartialLines() { var lines: [String] = [] let transport = NDJSONLineBuffer { lines.append($0) } transport.feed(Data("{\"type\":\"pa".utf8)) #expect(lines.isEmpty) transport.feed(Data("use\"}\n".utf8)) #expect(lines == ["{\"type\":\"pause\"}"]) } // Verifies empty lines are ignored. @Test func ignoresEmptyLines() { var lines: [String] = [] let transport = NDJSONLineBuffer { lines.append($0) } transport.feed(Data("\n\n{\"type\":\"next\"}\n\n".utf8)) #expect(lines == ["{\"type\":\"next\"}"]) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` Expected: Compilation failure. - [ ] **Step 3: Implement NDJSONTransport** ```swift // Music/Remote/NDJSONTransport.swift import Foundation import Network final class NDJSONLineBuffer: @unchecked Sendable { private var buffer = "" private let onLine: (String) -> Void init(onLine: @escaping (String) -> Void) { self.onLine = onLine } func feed(_ data: Data) { guard let text = String(data: data, encoding: .utf8) else { return } buffer += text while let newlineIndex = buffer.firstIndex(of: "\n") { let line = String(buffer[buffer.startIndex.. Void)? var onClose: (() -> Void)? init(connection: NWConnection, logger: os.Logger) { self.connection = connection self.logger = logger self.lineBuffer = NDJSONLineBuffer { [weak self] line in self?.onLine?(line) } } func send(_ message: T) { do { var data = try JSONEncoder().encode(message) data.append(contentsOf: "\n".utf8) connection.send(content: data, completion: .contentProcessed { [logger] error in if let error { logger.error("Send error: \(error.localizedDescription)") } }) } catch { logger.error("Encode error: \(error.localizedDescription)") } } func startReceiving() { receiveNext() } func close() { connection.cancel() } private func receiveNext() { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in Task { @MainActor in guard let self else { return } if let data { self.lineBuffer.feed(data) } if isComplete { self.logger.info("Connection closed by peer") self.onClose?() } else if let error { self.logger.error("Receive error: \(error.localizedDescription)") self.onClose?() } else { self.receiveNext() } } } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` Expected: All 3 tests pass. - [ ] **Step 5: Commit** ```bash git add Music/Remote/NDJSONTransport.swift MusicTests/NDJSONTransportTests.swift git commit -m "feat(remote): add NDJSONTransport for line-buffered JSON framing over TCP" ``` --- ### Task 5: DatabaseService.fetchTracksByIds **Files:** - Modify: `Music/Services/DatabaseService.swift` - Modify: `MusicTests/DatabaseServiceTests.swift` - [ ] **Step 1: Write failing test** Append to `MusicTests/DatabaseServiceTests.swift`: ```swift // Inserts 5 tracks, fetches 3 by ID, verifies only those 3 are returned // in the order of the requested IDs. @Test func fetchTracksByIds() throws { let db = try DatabaseService(inMemory: true) var tracks = (0..<5).map { i in Track.fixture(fileURL: "/track\(i).mp3", title: "Track \(i)") } for i in tracks.indices { try db.insert(&tracks[i]) } let ids: [Int64] = [tracks[2].id!, tracks[0].id!, tracks[4].id!] let result = try db.fetchTracksByIds(ids) #expect(result.count == 3) #expect(result[0].id == tracks[2].id) #expect(result[1].id == tracks[0].id) #expect(result[2].id == tracks[4].id) } ``` - [ ] **Step 2: Run test to verify it fails** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` Expected: Compilation failure — `fetchTracksByIds` not defined. - [ ] **Step 3: Implement the method** Add to `DatabaseService.swift` in the `// MARK: - Read` section: ```swift func fetchTracksByIds(_ ids: [Int64]) throws -> [Track] { guard !ids.isEmpty else { return [] } let tracks = try dbPool.read { db in let placeholders = databaseQuestionMarks(count: ids.count) return try Track.fetchAll( db, sql: "SELECT * FROM tracks WHERE id IN (\(placeholders))", arguments: StatementArguments(ids) ) } let trackMap = Dictionary(uniqueKeysWithValues: tracks.compactMap { t in t.id.map { ($0, t) } }) return ids.compactMap { trackMap[$0] } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift git commit -m "feat(remote): add DatabaseService.fetchTracksByIds for efficient ID-based lookups" ``` --- ### Task 6: Refactor PlayerViewModel to Own Playback State This is the key architectural change. PlayerViewModel becomes the single source of truth for ALL playback state. Views will read from PlayerViewModel instead of AudioService. **Files:** - Modify: `Music/Services/AudioService.swift` - Modify: `Music/ViewModels/PlayerViewModel.swift` - Modify: `MusicTests/PlayerViewModelTests.swift` - [ ] **Step 1: Add `onPlaybackStateChanged` callback to AudioService** In `AudioService.swift`, add a new callback alongside `onTrackFinished`: ```swift var onPlaybackStateChanged: (() -> Void)? ``` Fire it from the periodic time observer (inside the existing closure, after updating `currentTime` and `duration`): ```swift self.onPlaybackStateChanged?() ``` Also fire it at the end of `play(url:)` (after `isPlaying = true`), `pause()` (after `isPlaying = false`), `resume()` (after `isPlaying = true`), and `stop()` (after setting all state). Also fire in the `endObserver` callback (after `isPlaying = false`). - [ ] **Step 2: Add playback state properties to PlayerViewModel** In `PlayerViewModel.swift`, add new stored properties: ```swift var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var volume: Float = 0.65 ``` Add a private helper and the sync callback in `init`: ```swift private var remoteClient: RemoteClient? var trackResolver: ((Int64) -> Track?)? private var isRemote: Bool { remoteClient != nil } ``` At the end of `init`, add: ```swift audio.onPlaybackStateChanged = { [weak self] in self?.syncFromAudio() } ``` Add the sync method: ```swift private func syncFromAudio() { guard !isRemote else { return } isPlaying = audio.isPlaying if !audio.isScrubbing { currentTime = audio.currentTime } duration = audio.duration checkHalfway() } ``` - [ ] **Step 3: Route playback actions through PlayerViewModel** Add these methods to `PlayerViewModel`: ```swift func togglePlayPause() { if isPlaying { pause() } else { resume() } } func pause() { isPlaying = false if isRemote { remoteClient!.sendCommand(.pause) } else { audio.pause() } } func resume() { isPlaying = true if isRemote { remoteClient!.sendCommand(.resume) } else { audio.resume() } } func seek(to position: Double) { currentTime = position if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.seek(to: position) } } func setVolume(_ level: Float) { volume = level if isRemote { remoteClient!.sendCommand(.setVolume(level: level)) } else { audio.volume = level } } func beginScrubbing() { if !isRemote { audio.beginScrubbing() } } func scrub(to position: Double) { currentTime = position if !isRemote { audio.scrub(to: position) } } func endScrubbing(at position: Double) { currentTime = position if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) } } func stop() { isPlaying = false currentTime = 0 duration = 0 currentTrack = nil currentIndex = nil if !isRemote { audio.stop() } } ``` Update the existing `play(_ track:)` method to also update the new state properties: ```swift func play(_ track: Track) { currentTrack = track currentIndex = queue.firstIndex(where: { $0.id == track.id }) halfwayReported = false isPlaying = true currentTime = 0 if let client = remoteClient { guard let trackId = track.id else { return } client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id))) } else { guard let url = URL(string: track.fileURL) else { return } audio.play(url: url) } } ``` Update `next()`: ```swift func next() { if isRemote { remoteClient!.sendCommand(.next) return } guard let idx = currentIndex else { return } let nextIdx = idx + 1 if nextIdx < queue.count { play(queue[nextIdx]) } else { stop() } } ``` Update `previous()`: ```swift func previous() { if isRemote { remoteClient!.sendCommand(.previous) return } guard let idx = currentIndex else { return } let prevIdx = max(0, idx - 1) play(queue[prevIdx]) } ``` Update `toggleShuffle()`: ```swift func toggleShuffle() { isShuffled.toggle() if isRemote { remoteClient!.sendCommand(.toggleShuffle) return } if isShuffled { queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) } else { queue = originalQueue } if let current = currentTrack { currentIndex = queue.firstIndex(where: { $0.id == current.id }) } } ``` Update `trackDidFinish()` to guard against remote mode: ```swift private func trackDidFinish() { guard !isRemote else { return } if let track = currentTrack, let trackId = track.id, !halfwayReported { let newCount = track.playCount + 1 try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) } next() } ``` - [ ] **Step 4: Add remote mode entry/exit methods** ```swift func enterRemoteMode(client: RemoteClient) { audio.stop() remoteClient = client currentTrack = nil currentIndex = nil isPlaying = false currentTime = 0 duration = 0 queue = [] originalQueue = [] } func exitRemoteMode() { remoteClient = nil trackResolver = nil currentTrack = nil currentIndex = nil isPlaying = false currentTime = 0 duration = 0 queue = [] originalQueue = [] } func applyRemoteState(_ state: PlaybackStatePayload) { guard isRemote else { return } isPlaying = state.isPlaying currentTime = state.currentTime duration = state.duration volume = state.volume isShuffled = state.isShuffled if let trackId = state.trackId, currentTrack?.id != trackId { currentTrack = trackResolver?(trackId) } else if state.trackId == nil { currentTrack = nil } } ``` - [ ] **Step 5: Run existing PlayerViewModel tests** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/PlayerViewModelTests 2>&1 | tail -20` Expected: All 6 existing tests pass — they test queue/track logic, not playback state. - [ ] **Step 6: Commit** ```bash git add Music/Services/AudioService.swift Music/ViewModels/PlayerViewModel.swift git commit -m "refactor: make PlayerViewModel single source of truth for all playback state" ``` --- ### Task 7: Update ContentView to Read from PlayerViewModel This removes ContentView's direct dependency on AudioService. No behavior change — just rewiring where state is read from. **Files:** - Modify: `Music/ContentView.swift` - Modify: `Music/MusicApp.swift` - [ ] **Step 1: Remove `audio` property from ContentView and replace all references** In `ContentView.swift`: Remove the `audio` property: ```swift // DELETE: var audio: AudioService ``` Replace `playerControls`: ```swift private var playerControls: some View { PlayerControlsView( currentTrack: player.currentTrack, isPlaying: player.isPlaying, currentTime: player.currentTime, duration: player.duration, volume: player.volume, isShuffled: player.isShuffled, onPlayPause: { if player.currentTrack == nil { let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks if let first = trackList.first { player.setQueue(trackList) player.play(first) } } else { player.togglePlayPause() } }, onNext: { player.next() }, onPrevious: { player.previous() }, onSeek: { player.seek(to: $0) }, onScrubStart: { player.beginScrubbing() }, onScrub: { player.scrub(to: $0) }, onScrubEnd: { player.endScrubbing(at: $0) }, onVolumeChange: { player.setVolume($0) }, onShuffleToggle: { player.toggleShuffle() }, onNowPlayingTap: { scrollToPlayingTrigger = UUID() } ) } ``` Remove the `.onChange(of: audio.currentTime)` handler — `checkHalfway` is now called internally by `syncFromAudio()`: ```swift // DELETE: .onChange(of: audio.currentTime) { _, _ in // player.checkHalfway() // } ``` Update `installKeyboardMonitor` — remove `audio` from capture list, replace `audio.togglePlayPause()` with `player.togglePlayPause()`: ```swift keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [player, library, playlist] event in guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else { return event } guard let responder = NSApp.keyWindow?.firstResponder, !(responder is NSTextView) else { return event } switch event.keyCode { case 49: // space if player.currentTrack == nil { let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks if let first = trackList.first { player.setQueue(trackList) player.play(first) } } else { player.togglePlayPause() } return nil case 123: // left arrow player.previous() return nil case 124: // right arrow player.next() return nil default: return event } } ``` - [ ] **Step 2: Update MusicApp ContentView call to remove `audio` parameter** In `MusicApp.swift`, remove the `audio: audioService` parameter from the `ContentView(...)` call. - [ ] **Step 3: Build and run existing tests** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Executed|FAIL)'` Expected: All tests pass. This is a pure refactor — no behavior change. - [ ] **Step 4: Commit** ```bash git add Music/ContentView.swift Music/MusicApp.swift git commit -m "refactor: remove AudioService from ContentView — all playback state via PlayerViewModel" ``` --- ### Task 8: HostServer — Bonjour + HTTP + Command Channel **Files:** - Create: `Music/Remote/HostServer.swift` - Test: `MusicTests/HostServerIntegrationTests.swift` - [ ] **Step 1: Write failing integration test for DB download** ```swift // MusicTests/HostServerIntegrationTests.swift import Testing import Foundation import Network @testable import Music @MainActor struct HostServerIntegrationTests { // Starts a HostServer, connects via TCP, sends GET /db, // verifies the response is valid SQLite data. @Test(.timeLimit(.minutes(1))) func dbDownloadReturnsValidSQLite() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let dbPath = tempDir.appendingPathComponent("db.sqlite").path let db = try DatabaseService(path: dbPath) var track = Track.fixture(fileURL: "/test.mp3") try db.insert(&track) let server = HostServer(dbPath: dbPath) try server.start() let port = server.actualPort! let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db") let header = String(data: responseData.prefix(16), encoding: .utf8) ?? "" #expect(header.hasPrefix("SQLite format 3")) let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path try responseData.write(to: URL(fileURLWithPath: downloadedPath)) let downloadedDb = try DatabaseService(path: downloadedPath) #expect(try downloadedDb.trackCount() == 1) server.stop() try? FileManager.default.removeItem(at: tempDir) } // Connects to /cmd, sends a pause command, verifies a playbackState event comes back. @Test(.timeLimit(.minutes(1))) func commandChannelRoundTrip() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let dbPath = tempDir.appendingPathComponent("db.sqlite").path _ = try DatabaseService(path: dbPath) let audio = AudioService() let player = PlayerViewModel(audio: audio, db: nil) let server = HostServer(dbPath: dbPath) server.configure(player: player, db: nil) try server.start() let port = server.actualPort! // Connect and send GET /cmd to establish command channel. let connection = try await connectCommandChannel(host: "127.0.0.1", port: port) // Send a pause command. let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause) var lineData = pauseCmd lineData.append(contentsOf: "\n".utf8) connection.send(content: lineData, completion: .contentProcessed { _ in }) // Wait for a playbackState response. let responseLine = try await receiveOneLine(on: connection) let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8)) if case .playbackState(let payload) = event { #expect(payload.isPlaying == false) } else { Issue.record("Expected playbackState, got \(event)") } connection.cancel() server.stop() try? FileManager.default.removeItem(at: tempDir) } // MARK: - Helpers private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { try await withCheckedThrowingContinuation { continuation in let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp ) connection.stateUpdateHandler = { state in if case .ready = state { let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) connection.receiveMessage { data, _, _, error in if let error { continuation.resume(throwing: error) } else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { continuation.resume(returning: Data(data[range.upperBound...])) } else { continuation.resume(returning: data ?? Data()) } connection.cancel() } } else if case .failed(let error) = state { continuation.resume(throwing: error) } } connection.start(queue: .main) } } private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection { try await withCheckedThrowingContinuation { continuation in let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp ) connection.stateUpdateHandler = { state in if case .ready = state { let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) // Read the 200 OK response. connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in continuation.resume(returning: connection) } } else if case .failed(let error) = state { continuation.resume(throwing: error) } } connection.start(queue: .main) } } private func receiveOneLine(on connection: NWConnection) async throws -> String { try await withCheckedThrowingContinuation { continuation in connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in if let error { continuation.resume(throwing: error) } else { let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text) } } } } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` Expected: Compilation failure. - [ ] **Step 3: Implement HostServer** ```swift // Music/Remote/HostServer.swift import Foundation import Network import Observation @MainActor @Observable final class HostServer { var isHosting = false var connectedRemoteName: String? private(set) var actualPort: UInt16? private let dbPath: String private var listener: NWListener? private var commandTransport: NDJSONTransport? private var player: PlayerViewModel? private var db: DatabaseService? private var stateTimer: Timer? init(dbPath: String) { self.dbPath = dbPath } func configure(player: PlayerViewModel, db: DatabaseService?) { self.player = player self.db = db } func start() throws { let params = NWParameters.tcp params.includePeerToPeer = true let listener = try NWListener(using: params) listener.service = NWListener.Service(name: Host.current().localizedName, type: "_musicremote._tcp") listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in switch state { case .ready: if let port = listener.port?.rawValue { self?.actualPort = port RemoteLogger.host.info("Host started on port \(port)") } case .failed(let error): RemoteLogger.host.error("Listener failed: \(error.localizedDescription)") self?.stop() default: break } } } listener.newConnectionHandler = { [weak self] connection in Task { @MainActor in self?.handleNewConnection(connection) } } listener.start(queue: .main) self.listener = listener isHosting = true } func stop() { stateTimer?.invalidate() stateTimer = nil commandTransport?.close() commandTransport = nil connectedRemoteName = nil listener?.cancel() listener = nil actualPort = nil isHosting = false RemoteLogger.host.info("Host stopped") } // MARK: - Connection Handling private func handleNewConnection(_ connection: NWConnection) { connection.stateUpdateHandler = { [weak self] state in Task { @MainActor in switch state { case .ready: self?.receiveInitialRequest(on: connection) case .failed(let error): RemoteLogger.host.error("Connection failed: \(error.localizedDescription)") connection.cancel() case .cancelled: if self?.commandTransport != nil { self?.commandTransport = nil self?.connectedRemoteName = nil self?.stateTimer?.invalidate() self?.stateTimer = nil } default: break } } } connection.start(queue: .main) } private func receiveInitialRequest(on connection: NWConnection) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, error in Task { @MainActor in guard let self, let data, let request = String(data: data, encoding: .utf8) else { if let error { RemoteLogger.host.error("Receive error: \(error.localizedDescription)") } connection.cancel() return } RemoteLogger.host.debug("Request: \(request.prefix(80))") if request.hasPrefix("GET /db") { self.serveDatabase(on: connection) } else if request.hasPrefix("GET /cmd") { self.setupCommandChannel(on: connection) } else { self.sendHTTP(status: "404 Not Found", body: nil, on: connection, close: true) } } } } // MARK: - HTTP DB Download private func serveDatabase(on connection: NWConnection) { do { let data = try Data(contentsOf: URL(fileURLWithPath: dbPath)) RemoteLogger.host.info("Serving database: \(data.count) bytes") sendHTTP(status: "200 OK", body: data, contentType: "application/octet-stream", on: connection, close: true) } catch { RemoteLogger.host.error("Failed to read database: \(error.localizedDescription)") sendHTTP(status: "500 Internal Server Error", body: nil, on: connection, close: true) } } private func sendHTTP(status: String, body: Data?, contentType: String = "text/plain", on connection: NWConnection, close: Bool) { let bodyLen = body?.count ?? 0 let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyLen)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n" var response = Data(header.utf8) if let body { response.append(body) } connection.send(content: response, completion: .contentProcessed { _ in if close { connection.cancel() } }) } // MARK: - Command Channel private func setupCommandChannel(on connection: NWConnection) { if commandTransport != nil { RemoteLogger.host.info("Rejecting second remote — already connected") sendHTTP(status: "409 Conflict", body: nil, on: connection, close: true) return } let response = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nConnection: keep-alive\r\n\r\n" connection.send(content: Data(response.utf8), completion: .contentProcessed { [weak self] _ in Task { @MainActor in guard let self else { return } let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.host) transport.onLine = { [weak self] line in self?.handleCommandLine(line) } transport.onClose = { [weak self] in Task { @MainActor in RemoteLogger.host.info("Remote disconnected") self?.commandTransport = nil self?.connectedRemoteName = nil self?.stateTimer?.invalidate() self?.stateTimer = nil } } transport.startReceiving() self.commandTransport = transport self.connectedRemoteName = "Remote" self.startStateUpdates() RemoteLogger.host.info("Command channel established") } }) } private func handleCommandLine(_ line: String) { guard let data = line.data(using: .utf8) else { return } if let handshake = try? JSONDecoder().decode(HandshakeMessage.self, from: data) { RemoteLogger.host.info("Handshake: protocol v\(handshake.protocolVersion), app v\(handshake.appVersion)") if handshake.protocolVersion != RemoteProtocolVersion { RemoteLogger.host.error("Protocol mismatch: host v\(RemoteProtocolVersion), remote v\(handshake.protocolVersion)") commandTransport?.send(HostEvent.error(message: "Protocol version mismatch")) } connectedRemoteName = handshake.appVersion return } do { let command = try JSONDecoder().decode(RemoteCommand.self, from: data) RemoteLogger.host.debug("Command: \(line.prefix(80))") executeCommand(command) } catch { RemoteLogger.host.error("Decode failed: \(error.localizedDescription)") } } private func executeCommand(_ command: RemoteCommand) { guard let player else { return } switch command { case .play(let trackId, let queueIds): guard let db else { commandTransport?.send(HostEvent.error(message: "No database available")) return } do { let tracks = try db.fetchTracksByIds(queueIds) guard let track = tracks.first(where: { $0.id == trackId }) else { commandTransport?.send(HostEvent.error(message: "Track not found")) return } player.setQueue(tracks) player.play(track) } catch { RemoteLogger.host.error("Failed to resolve tracks: \(error.localizedDescription)") commandTransport?.send(HostEvent.error(message: "Failed to load tracks")) } case .pause: player.pause() case .resume: player.resume() case .next: player.next() case .previous: player.previous() case .seek(let position): player.seek(to: position) case .setVolume(let level): player.setVolume(level) case .toggleShuffle: player.toggleShuffle() case .refreshDB: RemoteLogger.host.info("DB refresh requested") commandTransport?.send(HostEvent.dbReady) } sendCurrentState() } private func sendCurrentState() { guard let player, let transport = commandTransport else { return } transport.send(HostEvent.playbackState(PlaybackStatePayload( trackId: player.currentTrack?.id, isPlaying: player.isPlaying, currentTime: player.currentTime, duration: player.duration, volume: player.volume, isShuffled: player.isShuffled ))) } private func startStateUpdates() { stateTimer?.invalidate() stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.sendCurrentState() } } } } ``` - [ ] **Step 4: Run integration tests** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` Expected: Both tests pass. - [ ] **Step 5: Commit** ```bash git add Music/Remote/HostServer.swift MusicTests/HostServerIntegrationTests.swift git commit -m "feat(remote): add HostServer with Bonjour, HTTP DB download, and NDJSON command channel" ``` --- ### Task 9: RemoteClient — Discovery, DB Download, Command Channel **Files:** - Create: `Music/Remote/RemoteClient.swift` - [ ] **Step 1: Implement RemoteClient** ```swift // Music/Remote/RemoteClient.swift import Foundation import Network import Observation @MainActor @Observable final class RemoteClient { var connectionState = ConnectionState.disconnected var discoveredHosts: [(name: String, endpoint: NWEndpoint)] = [] var onPlaybackState: ((PlaybackStatePayload) -> Void)? var onDBReady: (() -> Void)? private var browser: NWBrowser? private var commandTransport: NDJSONTransport? private var hostEndpoint: NWEndpoint? private var pingTimer: Timer? private var missedPings = 0 // MARK: - Discovery func startDiscovery() { transition(to: .discovering) discoveredHosts = [] let params = NWParameters() params.includePeerToPeer = true let browser = NWBrowser(for: .bonjour(type: "_musicremote._tcp", domain: nil), using: params) browser.stateUpdateHandler = { [weak self] state in Task { @MainActor in if case .failed(let error) = state { RemoteLogger.client.error("Browser failed: \(error.localizedDescription)") self?.transition(to: .disconnected) } } } browser.browseResultsChangedHandler = { [weak self] results, _ in Task { @MainActor in self?.discoveredHosts = results.compactMap { result in if case .service(let name, _, _, _) = result.endpoint { return (name: name, endpoint: result.endpoint) } return nil } RemoteLogger.client.info("Discovered \(results.count) host(s)") } } browser.start(queue: .main) self.browser = browser } func stopDiscovery() { browser?.cancel() browser = nil discoveredHosts = [] if case .discovering = connectionState { transition(to: .disconnected) } } // MARK: - Connect / Disconnect func connect(to host: (name: String, endpoint: NWEndpoint)) { transition(to: .foundHost(host.name)) browser?.cancel() browser = nil let connection = NWConnection(to: host.endpoint, using: .tcp) connection.stateUpdateHandler = { [weak self] state in Task { @MainActor in switch state { case .ready: self?.hostEndpoint = host.endpoint self?.downloadDatabase(on: connection, hostName: host.name) case .failed(let error): RemoteLogger.client.error("Connection failed: \(error.localizedDescription)") self?.transition(to: .connectionLost(error.localizedDescription)) default: break } } } connection.start(queue: .main) } func disconnect() { pingTimer?.invalidate() pingTimer = nil commandTransport?.close() commandTransport = nil browser?.cancel() browser = nil discoveredHosts = [] transition(to: .disconnected) RemoteLogger.client.info("Disconnected") try? FileManager.default.removeItem(atPath: Self.remoteDBPath) } func sendCommand(_ command: RemoteCommand) { guard let transport = commandTransport else { RemoteLogger.client.error("No command channel — cannot send") return } transport.send(command) RemoteLogger.client.debug("Sent: \(String(describing: command))") } // MARK: - DB Download static var remoteDBPath: String { let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first!.appendingPathComponent("Music", isDirectory: true) return appSupport.appendingPathComponent("remote_db.sqlite").path } private func downloadDatabase(on connection: NWConnection, hostName: String) { transition(to: .downloadingDB) let startTime = Date() let request = "GET /db HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in if let error { Task { @MainActor in RemoteLogger.client.error("DB request failed: \(error.localizedDescription)") self?.transition(to: .connectionLost(error.localizedDescription)) } return } connection.receiveMessage { [weak self] data, _, _, error in Task { @MainActor in self?.handleDBResponse(data: data, error: error, startTime: startTime, hostName: hostName) } } }) } private func handleDBResponse(data: Data?, error: NWError?, startTime: Date, hostName: String) { if let error { RemoteLogger.client.error("DB download error: \(error.localizedDescription)") transition(to: .connectionLost(error.localizedDescription)) return } guard var data else { RemoteLogger.client.error("DB download returned no data") transition(to: .connectionLost("No data received")) return } if let range = data.range(of: Data("\r\n\r\n".utf8)) { data = Data(data[range.upperBound...]) } let elapsed = Date().timeIntervalSince(startTime) RemoteLogger.client.info("DB downloaded (\(String(format: "%.1f", Double(data.count) / 1024)) KB, \(String(format: "%.0f", elapsed * 1000))ms)") do { let url = URL(fileURLWithPath: Self.remoteDBPath) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url) } catch { RemoteLogger.client.error("Failed to save remote DB: \(error.localizedDescription)") transition(to: .connectionLost("Failed to save database")) return } connectCommandChannel(hostName: hostName) } // MARK: - Command Channel private func connectCommandChannel(hostName: String) { transition(to: .connectingCommandChannel) guard let endpoint = hostEndpoint else { transition(to: .connectionLost("No host endpoint")) return } let connection = NWConnection(to: endpoint, using: .tcp) connection.stateUpdateHandler = { [weak self] state in Task { @MainActor in switch state { case .ready: let request = "GET /cmd HTTP/1.1\r\nHost: local\r\nConnection: keep-alive\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in Task { @MainActor in self?.commandChannelReady(connection: connection, hostName: hostName) } } case .failed(let error): RemoteLogger.client.error("Command channel failed: \(error.localizedDescription)") self?.transition(to: .connectionLost(error.localizedDescription)) default: break } } } connection.start(queue: .main) } private func commandChannelReady(connection: NWConnection, hostName: String) { let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.client) transport.onLine = { [weak self] line in self?.handleEventLine(line) } transport.onClose = { [weak self] in Task { @MainActor in self?.commandTransport = nil self?.transition(to: .connectionLost("Host closed connection")) self?.pingTimer?.invalidate() self?.pingTimer = nil } } transport.startReceiving() self.commandTransport = transport transition(to: .connected(hostName)) let handshake = HandshakeMessage( protocolVersion: RemoteProtocolVersion, appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" ) transport.send(handshake) startPingTimer() } private func handleEventLine(_ line: String) { guard !line.isEmpty, let data = line.data(using: .utf8) else { return } do { let event = try JSONDecoder().decode(HostEvent.self, from: data) switch event { case .playbackState(let payload): missedPings = 0 onPlaybackState?(payload) case .dbReady: RemoteLogger.client.info("Host signals DB ready for re-download") onDBReady?() case .error(let message): RemoteLogger.client.error("Host error: \(message)") } } catch { RemoteLogger.client.error("Decode failed: \(error.localizedDescription)") } } // MARK: - Keep-alive private func startPingTimer() { missedPings = 0 pingTimer?.invalidate() pingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in Task { @MainActor in guard let self else { return } self.missedPings += 1 if self.missedPings >= 3 { RemoteLogger.client.error("No response from host for 15s — connection lost") self.transition(to: .connectionLost("Host not responding")) self.commandTransport?.close() self.commandTransport = nil self.pingTimer?.invalidate() self.pingTimer = nil } } } } // MARK: - State private func transition(to newState: ConnectionState) { let oldState = connectionState if oldState == newState { return } guard oldState.canTransition(to: newState) else { RemoteLogger.client.error("Invalid transition: \(String(describing: oldState)) → \(String(describing: newState))") return } connectionState = newState RemoteLogger.client.info("State: \(newState.userMessage ?? "disconnected")") } } ``` - [ ] **Step 2: Build and verify** Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` Expected: Build succeeds. - [ ] **Step 3: Commit** ```bash git add Music/Remote/RemoteClient.swift git commit -m "feat(remote): add RemoteClient with Bonjour discovery, DB download, NDJSON command channel" ``` --- ### Task 10: NetworkStatus Model A small value type that encapsulates what ContentView needs to know about host/remote mode. Keeps networking details out of the View. **Files:** - Create: `Music/Remote/NetworkStatus.swift` - [ ] **Step 1: Implement NetworkStatus** ```swift // Music/Remote/NetworkStatus.swift import Foundation struct NetworkStatus { enum Mode { case hosting(connectedRemote: String?) case remote(hostName: String) } var mode: Mode var onDisconnect: (() -> Void)? var onRefreshLibrary: (() -> Void)? var isRemoteMode: Bool { if case .remote = mode { return true } return false } } ``` - [ ] **Step 2: Commit** ```bash git add Music/Remote/NetworkStatus.swift git commit -m "feat(remote): add NetworkStatus model for View-layer network state" ``` --- ### Task 11: Wire Host/Remote into MusicApp **Files:** - Modify: `Music/MusicApp.swift` - [ ] **Step 1: Add state properties** Add to `MusicApp`: ```swift @State private var hostServer: HostServer? @State private var remoteClient = RemoteClient() @State private var showConnectionSheet = false ``` - [ ] **Step 2: Add menu items** Replace the `.commands { ... }` block: ```swift .commands { CommandGroup(after: .newItem) { Button("Open Music Folder...") { pickFolder() } .keyboardShortcut("o") .disabled(remoteClient.connectionState.isConnected) Button("New Playlist...") { showNewPlaylistAlert = true } .keyboardShortcut("n") .disabled(remoteClient.connectionState.isConnected) Divider() Toggle("Enable Host Mode", isOn: Binding( get: { hostServer?.isHosting ?? false }, set: { $0 ? startHosting() : hostServer?.stop() } )) .disabled(remoteClient.connectionState.isConnected) Button("Connect to Remote...") { showConnectionSheet = true remoteClient.startDiscovery() } .disabled(hostServer?.isHosting ?? false) } } ``` - [ ] **Step 3: Add onChange for remote connection state and DB swap** Add to the `Group` in `body`, after `.frame(...)`: ```swift .onChange(of: remoteClient.connectionState) { _, newState in if case .connected = newState { enterRemoteMode() } else if newState == .disconnected { exitRemoteMode() } } .sheet(isPresented: $showConnectionSheet) { ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet) } ``` - [ ] **Step 4: Add remote mode enter/exit methods** ```swift private func enterRemoteMode() { guard let player = playerVM else { return } do { let remoteDb = try DatabaseService(path: RemoteClient.remoteDBPath) self.libraryVM = LibraryViewModel(db: remoteDb) self.playlistVM = PlaylistViewModel(db: remoteDb) player.enterRemoteMode(client: remoteClient) player.trackResolver = { [weak self] trackId in self?.libraryVM?.tracks.first(where: { $0.id == trackId }) } remoteClient.onPlaybackState = { [weak player] state in player?.applyRemoteState(state) } } catch { print("Failed to load remote DB: \(error)") remoteClient.disconnect() } } private func exitRemoteMode() { playerVM?.exitRemoteMode() remoteClient.onPlaybackState = nil guard let db = dbService else { return } self.libraryVM = LibraryViewModel(db: db) self.playlistVM = PlaylistViewModel(db: db) try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath) } ``` - [ ] **Step 5: Add startHosting method** ```swift private func startHosting() { guard let db = dbService, let player = playerVM else { return } let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first!.appendingPathComponent("Music", isDirectory: true) let dbPath = appSupport.appendingPathComponent("db.sqlite").path let server = HostServer(dbPath: dbPath) server.configure(player: player, db: db) do { try server.start() hostServer = server } catch { print("Failed to start host: \(error)") } } ``` - [ ] **Step 6: Update ContentView call to pass networkStatus** Compute `networkStatus` and pass it: ```swift ContentView( library: library, player: player, scanner: scanner, playlist: playlist, shazam: shazamService, db: db, showNewPlaylistAlert: $showNewPlaylistAlert, networkStatus: computeNetworkStatus() ) ``` ```swift private func computeNetworkStatus() -> NetworkStatus? { if remoteClient.connectionState.isConnected { let hostName: String if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" } return NetworkStatus( mode: .remote(hostName: hostName), onDisconnect: { [remoteClient] in remoteClient.disconnect() }, onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) } ) } if let server = hostServer, server.isHosting { return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName)) } return nil } ``` - [ ] **Step 7: Build and verify** Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` Expected: Build succeeds. - [ ] **Step 8: Commit** ```bash git add Music/MusicApp.swift git commit -m "feat(remote): wire HostServer and RemoteClient into MusicApp with menu items and DB swap" ``` --- ### Task 12: ConnectionSheet UI **Files:** - Create: `Music/Views/ConnectionSheet.swift` - [ ] **Step 1: Implement ConnectionSheet** ```swift // Music/Views/ConnectionSheet.swift import SwiftUI struct ConnectionSheet: View { var remoteClient: RemoteClient @Binding var isPresented: Bool var body: some View { VStack(spacing: 16) { Text("Connect to Host") .font(.headline) if let message = remoteClient.connectionState.userMessage { HStack(spacing: 8) { if !remoteClient.connectionState.isConnected && remoteClient.connectionState != .disconnected { ProgressView().controlSize(.small) } Text(message) .font(.subheadline) .foregroundStyle(.secondary) } } if case .connectionLost(let reason) = remoteClient.connectionState { VStack(spacing: 8) { Text(reason).font(.caption).foregroundStyle(.red) Button("Retry") { remoteClient.startDiscovery() } } } if remoteClient.discoveredHosts.isEmpty && remoteClient.connectionState == .discovering { VStack(spacing: 8) { ProgressView() Text("Looking for hosts on your network...") .font(.caption).foregroundStyle(.secondary) } .frame(height: 100) } else { List(remoteClient.discoveredHosts, id: \.name) { host in HStack { Image(systemName: "desktopcomputer").foregroundStyle(.secondary) Text(host.name) Spacer() Button("Connect") { remoteClient.connect(to: host) } .buttonStyle(.borderedProminent).controlSize(.small) } .padding(.vertical, 4) } .frame(minHeight: 100, maxHeight: 200) } Button("Cancel") { remoteClient.stopDiscovery() isPresented = false } .keyboardShortcut(.cancelAction) } .padding(20) .frame(width: 380) .onChange(of: remoteClient.connectionState) { _, newState in if case .connected = newState { isPresented = false } } } } ``` - [ ] **Step 2: Commit** ```bash git add Music/Views/ConnectionSheet.swift git commit -m "feat(remote): add ConnectionSheet for discovering and connecting to hosts" ``` --- ### Task 13: ContentView — Banner, Disable Writes, Host Indicator **Files:** - Modify: `Music/ContentView.swift` - Modify: `Music/Views/PlaylistBarView.swift` - [ ] **Step 1: Add networkStatus property to ContentView** Replace the `audio` property with: ```swift var networkStatus: NetworkStatus? ``` - [ ] **Step 2: Add network status banners** Insert right after the opening `VStack(spacing: 0) {` in `body`: ```swift if let status = networkStatus { switch status.mode { case .remote(let hostName): HStack(spacing: 8) { Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 10)).foregroundStyle(.blue) Text("Connected to \(hostName)") .font(.system(size: 11, weight: .medium)).foregroundStyle(.blue) Spacer() Button("Refresh") { status.onRefreshLibrary?() } .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary) Button("Disconnect") { status.onDisconnect?() } .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red) } .padding(.horizontal, 12).padding(.vertical, 4) .background(Color.blue.opacity(0.08)) case .hosting(let remoteName): HStack(spacing: 8) { Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 10)).foregroundStyle(.green) Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting") .font(.system(size: 11, weight: .medium)).foregroundStyle(.green) Spacer() } .padding(.horizontal, 12).padding(.vertical, 4) .background(Color.green.opacity(0.08)) } } ``` - [ ] **Step 3: Disable playlist context menus in remote mode** In `PlaylistBarView.swift`, add property: ```swift var isRemoteMode: Bool = false ``` Wrap the context menu content: ```swift .contextMenu { if !isRemoteMode { Button("Rename...") { onRename(item) } if let smart = item as? SmartPlaylist { Button("Edit Search Query...") { onEditQuery(smart) } } Button("Delete") { onDelete(item) } } } ``` - [ ] **Step 4: Pass isRemoteMode to PlaylistBarView** In `ContentView.swift`, update the `PlaylistBarView(...)` call: ```swift PlaylistBarView( playlists: playlist.allPlaylists, selectedItem: showHome ? nil : playlist.selectedItem, isHomeSelected: showHome, isRemoteMode: networkStatus?.isRemoteMode ?? false, // ... rest unchanged ``` - [ ] **Step 5: Build and verify** Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` Expected: Build succeeds. - [ ] **Step 6: Commit** ```bash git add Music/ContentView.swift Music/Views/PlaylistBarView.swift git commit -m "feat(remote): add network status banners, disable write actions in remote mode" ``` --- ### Task 14: Run All Tests **Files:** None — verification only. - [ ] **Step 1: Run the full test suite** Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Test Suite|Test Case|Executed|FAIL)'` Expected: All tests pass — existing tests unchanged, new tests (RemoteProtocol, ConnectionState, NDJSONTransport, HostServerIntegration) pass. - [ ] **Step 2: Fix any failures and commit** --- ### Task 15: Manual Testing - [ ] **Step 1: Test Host Mode** 1. Launch app → File → "Enable Host Mode" → verify toggle works 2. Open Console.app, filter "com.music.remote" → verify "Host started on port XXXX" 3. Verify green "Hosting" banner appears 4. Toggle off → verify "Host stopped" and banner disappears - [ ] **Step 2: Test Remote Connection** 1. On second machine, launch app → File → "Connect to Remote..." 2. Verify host appears in list → click "Connect" 3. Verify DB downloads, blue "Connected to [name]" banner appears 4. Verify library shows host's tracks - [ ] **Step 3: Test Remote Playback** 1. Double-click a track → audio plays on host, not remote 2. Play/pause, next, previous, seek, volume, shuffle all work 3. Now-playing indicator updates on remote - [ ] **Step 4: Test Error Handling** 1. Click "Disconnect" → returns to local mode cleanly 2. Kill host app mid-playback → "Connection lost" appears on remote 3. Check Console.app for clear diagnostic logs at every step