# Music Streaming 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 internet-based HLS music streaming so a client app can browse the host's library and play audio remotely over HTTPS, exposed through a Cloudflare Tunnel. **Architecture:** A `PlaybackProvider` protocol abstracts local/remote/streaming playback so `PlayerViewModel` is role-agnostic. An `AppRole` enum (`local`, `remoteHost`, `remoteClient`, `streamHost`, `streamClient`) drives which capabilities are active. A `MusicShared` local Swift package holds wire protocol types, HLS manifest generation, and constants for future iOS reuse. The host runs a Hummingbird HTTP server serving HLS-segmented MP3s; the client uses `AVPlayer` with HLS URLs. **Tech Stack:** Swift, SwiftUI, Hummingbird 2.x (HTTP server), HummingbirdWebSocket (WebSocket), AVFoundation (HLS playback + segment extraction), Cloudflare Tunnel, URLSession --- ## File Structure ### MusicShared/ (new local Swift package at repo root) ``` MusicShared/ ├── Package.swift ├── Sources/MusicShared/ │ ├── RemoteProtocol.swift ← moved from Music/Remote/RemoteProtocol.swift │ ├── AppRole.swift ← new: role enum │ ├── HLSManifestGenerator.swift ← new: .m3u8 generation (pure logic) │ ├── StreamingRoutes.swift ← new: route path constants │ ├── StreamingConstants.swift ← new: segment duration, port, etc. │ └── APIModels.swift ← new: AuthResponse, DBMetadata DTOs └── Tests/MusicSharedTests/ ├── RemoteProtocolTests.swift ← moved from MusicTests/RemoteProtocolTests.swift └── HLSManifestGeneratorTests.swift ← new ``` ### Music/ (app target — new files) ``` Music/ ├── Protocols/ │ ├── PlaylistRepresentable.swift (existing, unchanged) │ └── PlaybackProvider.swift ← new: playback abstraction protocol ├── Providers/ │ ├── LocalPlaybackProvider.swift ← new: wraps AudioService │ ├── RemotePlaybackProvider.swift ← new: sends commands over NDJSON (extracted from PlayerViewModel) │ └── StreamingPlaybackProvider.swift ← new: AVPlayer + HLS URLs ├── Streaming/ │ ├── StreamingServer.swift ← new: Hummingbird HTTP + WebSocket server │ ├── HLSSegmenter.swift ← new: AVAssetReader segment extraction │ ├── TunnelManager.swift ← new: cloudflared process management │ └── StreamingClient.swift ← new: HTTP client + WebSocket for streaming mode ``` ### Music/ (app target — modified files) ``` Music/ ├── MusicApp.swift ← modified: role switching, streaming server/client wiring ├── ViewModels/ │ └── PlayerViewModel.swift ← modified: use PlaybackProvider instead of direct AudioService ├── Remote/ │ ├── RemoteProtocol.swift ← deleted (moved to MusicShared) │ ├── NetworkStatus.swift ← modified: add streaming modes │ └── HostServer.swift ← modified: import MusicShared │ └── RemoteClient.swift ← modified: import MusicShared ├── ContentView.swift ← modified: streaming UI indicators ``` ### MusicTests/ (test target — modified) ``` MusicTests/ ├── RemoteProtocolTests.swift ← deleted (moved to MusicSharedTests) ├── PlayerViewModelTests.swift ← modified: use PlaybackProvider ├── HLSSegmenterTests.swift ← new ├── StreamingServerTests.swift ← new ├── TunnelManagerTests.swift ← new ``` --- ## Task 1: Create MusicShared Package and Move RemoteProtocol **Files:** - Create: `MusicShared/Package.swift` - Move: `Music/Remote/RemoteProtocol.swift` → `MusicShared/Sources/MusicShared/RemoteProtocol.swift` - Move: `MusicTests/RemoteProtocolTests.swift` → `MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift` - Modify: `Music/Remote/HostServer.swift` (add `import MusicShared`) - Modify: `Music/Remote/RemoteClient.swift` (add `import MusicShared`) - Modify: `Music/ViewModels/PlayerViewModel.swift` (add `import MusicShared`) - Modify: `Music/Remote/NDJSONTransport.swift` (add `import MusicShared` if it references protocol types) - Modify: `Music.xcodeproj/project.pbxproj` (remove old file refs, add package dependency) - [ ] **Step 1: Create the MusicShared package directory and Package.swift** ```bash mkdir -p MusicShared/Sources/MusicShared mkdir -p MusicShared/Tests/MusicSharedTests ``` Create `MusicShared/Package.swift`: ```swift // swift-tools-version: 5.10 import PackageDescription let package = Package( name: "MusicShared", platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library(name: "MusicShared", targets: ["MusicShared"]), ], targets: [ .target(name: "MusicShared"), .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), ] ) ``` - [ ] **Step 2: Move RemoteProtocol.swift to MusicShared** ```bash cp Music/Remote/RemoteProtocol.swift MusicShared/Sources/MusicShared/RemoteProtocol.swift ``` Edit the copied file: add `public` access to all types, properties, initializers, and the `RemoteProtocolVersion` constant. Every type and member that is used outside the package must be `public`. `MusicShared/Sources/MusicShared/RemoteProtocol.swift`: ```swift import Foundation // MARK: - Protocol Version public nonisolated let RemoteProtocolVersion: Int = 1 // MARK: - Supporting Types public nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { public var trackId: Int64? public var isPlaying: Bool public var currentTime: Double public var duration: Double public var volume: Float public var isShuffled: Bool public init(trackId: Int64? = nil, isPlaying: Bool, currentTime: Double, duration: Double, volume: Float, isShuffled: Bool) { self.trackId = trackId self.isPlaying = isPlaying self.currentTime = currentTime self.duration = duration self.volume = volume self.isShuffled = isShuffled } } public nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { public var protocolVersion: Int public var appVersion: String public init(protocolVersion: Int, appVersion: String) { self.protocolVersion = protocolVersion self.appVersion = appVersion } } // MARK: - RemoteCommand public nonisolated enum RemoteCommand: 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 } extension RemoteCommand: Codable { private enum TypeKey: String, Codable { case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB } private enum CodingKeys: String, CodingKey { case type, payload } private struct PlayPayload: Codable { var trackId: Int64 var queueIds: [Int64] } private struct SeekPayload: Codable { var position: Double } private struct VolumePayload: Codable { var level: Float } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .play(let trackId, let queueIds): try container.encode(TypeKey.play, forKey: .type) try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) case .pause: try container.encode(TypeKey.pause, forKey: .type) case .resume: try container.encode(TypeKey.resume, forKey: .type) case .next: try container.encode(TypeKey.next, forKey: .type) case .previous: try container.encode(TypeKey.previous, forKey: .type) case .seek(let position): try container.encode(TypeKey.seek, forKey: .type) try container.encode(SeekPayload(position: position), forKey: .payload) case .setVolume(let level): try container.encode(TypeKey.setVolume, forKey: .type) try container.encode(VolumePayload(level: level), forKey: .payload) case .toggleShuffle: try container.encode(TypeKey.toggleShuffle, forKey: .type) case .refreshDB: try container.encode(TypeKey.refreshDB, forKey: .type) } } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(TypeKey.self, forKey: .type) switch type { case .play: let payload = try container.decode(PlayPayload.self, forKey: .payload) self = .play(trackId: payload.trackId, queueIds: payload.queueIds) case .pause: self = .pause case .resume: self = .resume case .next: self = .next case .previous: self = .previous case .seek: let payload = try container.decode(SeekPayload.self, forKey: .payload) self = .seek(position: payload.position) case .setVolume: let payload = try container.decode(VolumePayload.self, forKey: .payload) self = .setVolume(level: payload.level) case .toggleShuffle: self = .toggleShuffle case .refreshDB: self = .refreshDB } } } // MARK: - HostEvent public nonisolated enum HostEvent: Equatable, Sendable { case playbackState(PlaybackStatePayload) case dbReady case error(message: String) } extension HostEvent: Codable { private enum TypeKey: String, Codable { case playbackState, dbReady, error } private enum CodingKeys: String, CodingKey { case type, payload } private struct ErrorPayload: Codable { var message: String } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .playbackState(let payload): try container.encode(TypeKey.playbackState, forKey: .type) try container.encode(payload, forKey: .payload) case .dbReady: try container.encode(TypeKey.dbReady, forKey: .type) case .error(let message): try container.encode(TypeKey.error, forKey: .type) try container.encode(ErrorPayload(message: message), forKey: .payload) } } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(TypeKey.self, forKey: .type) switch type { case .playbackState: let payload = try container.decode(PlaybackStatePayload.self, forKey: .payload) self = .playbackState(payload) case .dbReady: self = .dbReady case .error: let payload = try container.decode(ErrorPayload.self, forKey: .payload) self = .error(message: payload.message) } } } ``` - [ ] **Step 3: Move RemoteProtocolTests.swift to MusicSharedTests** ```bash cp MusicTests/RemoteProtocolTests.swift MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift ``` Edit the copied test file: change `@testable import Music` to `@testable import MusicShared`. Remove the `@MainActor` annotation if present (MusicShared types are `nonisolated`). - [ ] **Step 4: Run MusicShared tests to verify the move** ```bash cd MusicShared && swift test ``` Expected: all RemoteProtocol tests pass. - [ ] **Step 5: Integrate MusicShared into the Xcode project** In Xcode: 1. Drag the `MusicShared/` folder into the project navigator (at the root level). 2. Xcode detects it as a local Swift package automatically. 3. Select the Music app target → General → "Frameworks, Libraries, and Embedded Content" → click `+` → select `MusicShared` library. 4. Delete the original `Music/Remote/RemoteProtocol.swift` from the project (Move to Trash). 5. Delete `MusicTests/RemoteProtocolTests.swift` from the project (Move to Trash). - [ ] **Step 6: Add `import MusicShared` to all files that reference protocol types** Add `import MusicShared` to the top of these files: - `Music/Remote/HostServer.swift` - `Music/Remote/RemoteClient.swift` - `Music/Remote/NDJSONTransport.swift` - `Music/ViewModels/PlayerViewModel.swift` - [ ] **Step 7: Build the Xcode project to verify everything compiles** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 8: Commit** ```bash git add MusicShared/ Music/Remote/ Music/ViewModels/PlayerViewModel.swift MusicTests/ Music.xcodeproj git commit -m "refactor: extract MusicShared package, move RemoteProtocol" ``` --- ## Task 2: Add AppRole Enum to MusicShared **Files:** - Create: `MusicShared/Sources/MusicShared/AppRole.swift` - [ ] **Step 1: Create AppRole.swift** `MusicShared/Sources/MusicShared/AppRole.swift`: ```swift import Foundation public enum AppRole: String, Codable, CaseIterable, Sendable { case local case remoteHost case remoteClient case streamHost case streamClient } extension AppRole { public var isHost: Bool { self == .remoteHost || self == .streamHost } public var isClient: Bool { self == .remoteClient || self == .streamClient } public var isLocal: Bool { self == .local } public var usesLocalAudio: Bool { self == .local || self == .remoteHost || self == .streamClient } public var isReadOnlyLibrary: Bool { self == .remoteClient || self == .streamClient } public var needsNetworkServer: Bool { self == .remoteHost || self == .streamHost } public var isStreaming: Bool { self == .streamHost || self == .streamClient } } ``` - [ ] **Step 2: Build MusicShared to verify** ```bash cd MusicShared && swift build ``` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add MusicShared/Sources/MusicShared/AppRole.swift git commit -m "feat: add AppRole enum to MusicShared" ``` --- ## Task 3: Add StreamingConstants, Routes, and APIModels to MusicShared **Files:** - Create: `MusicShared/Sources/MusicShared/StreamingConstants.swift` - Create: `MusicShared/Sources/MusicShared/StreamingRoutes.swift` - Create: `MusicShared/Sources/MusicShared/APIModels.swift` - [ ] **Step 1: Create StreamingConstants.swift** ```swift import Foundation public enum StreamingConstants: Sendable { public static let defaultPort: Int = 8420 public static let segmentDuration: Double = 6.0 public static let protocolVersion: Int = 1 } ``` - [ ] **Step 2: Create StreamingRoutes.swift** ```swift import Foundation public enum StreamingRoutes: Sendable { public static let auth = "/auth" public static let db = "/db" public static let ws = "/ws" public static func trackManifest(trackId: Int64) -> String { "/tracks/\(trackId)/stream.m3u8" } public static func trackSegment(trackId: Int64, index: Int) -> String { "/tracks/\(trackId)/segments/\(index).mp3" } public static func trackManifestPattern() -> String { "/tracks/:trackId/stream.m3u8" } public static func trackSegmentPattern() -> String { "/tracks/:trackId/segments/:index" } } ``` - [ ] **Step 3: Create APIModels.swift** ```swift import Foundation public struct AuthResponse: Codable, Equatable, Sendable { public var hostName: String public var protocolVersion: Int public init(hostName: String, protocolVersion: Int) { self.hostName = hostName self.protocolVersion = protocolVersion } } public struct DBMetadata: Codable, Equatable, Sendable { public var checksum: String public var trackCount: Int public init(checksum: String, trackCount: Int) { self.checksum = checksum self.trackCount = trackCount } } ``` - [ ] **Step 4: Build MusicShared to verify** ```bash cd MusicShared && swift build ``` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add MusicShared/Sources/MusicShared/StreamingConstants.swift \ MusicShared/Sources/MusicShared/StreamingRoutes.swift \ MusicShared/Sources/MusicShared/APIModels.swift git commit -m "feat: add StreamingConstants, Routes, and APIModels to MusicShared" ``` --- ## Task 4: Add HLSManifestGenerator (TDD) **Files:** - Create: `MusicShared/Sources/MusicShared/HLSManifestGenerator.swift` - Create: `MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift` - [ ] **Step 1: Write the failing tests** `MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift`: ```swift import Testing @testable import MusicShared struct HLSManifestGeneratorTests { // Generates a manifest for a 16-second track with 6s segments. // Expects 3 segments: 6s, 6s, 4s (remainder). @Test func generatesCorrectManifestForTypicalTrack() { let manifest = HLSManifestGenerator.manifest( trackId: 42, duration: 16.0, segmentDuration: 6.0 ) #expect(manifest.contains("#EXTM3U")) #expect(manifest.contains("#EXT-X-VERSION:3")) #expect(manifest.contains("#EXT-X-TARGETDURATION:6")) #expect(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")) #expect(manifest.contains("#EXTINF:6.000,")) #expect(manifest.contains("#EXTINF:4.000,")) #expect(manifest.contains("segments/0.mp3")) #expect(manifest.contains("segments/1.mp3")) #expect(manifest.contains("segments/2.mp3")) #expect(!manifest.contains("segments/3.mp3")) #expect(manifest.contains("#EXT-X-ENDLIST")) } // A track whose duration is an exact multiple of the segment duration. // Expects no short final segment. @Test func exactMultipleOfSegmentDuration() { let manifest = HLSManifestGenerator.manifest( trackId: 1, duration: 12.0, segmentDuration: 6.0 ) let segmentCount = manifest.components(separatedBy: "#EXTINF:6.000,").count - 1 #expect(segmentCount == 2) #expect(!manifest.contains("segments/2.mp3")) } // A very short track (shorter than one segment). // Expects a single segment with the track's full duration. @Test func veryShortTrack() { let manifest = HLSManifestGenerator.manifest( trackId: 7, duration: 2.5, segmentDuration: 6.0 ) #expect(manifest.contains("#EXTINF:2.500,")) #expect(manifest.contains("segments/0.mp3")) #expect(!manifest.contains("segments/1.mp3")) } // Segment count helper returns the correct number of segments. @Test func segmentCountCalculation() { #expect(HLSManifestGenerator.segmentCount(duration: 16.0, segmentDuration: 6.0) == 3) #expect(HLSManifestGenerator.segmentCount(duration: 12.0, segmentDuration: 6.0) == 2) #expect(HLSManifestGenerator.segmentCount(duration: 2.5, segmentDuration: 6.0) == 1) #expect(HLSManifestGenerator.segmentCount(duration: 6.0, segmentDuration: 6.0) == 1) } // Time range for a given segment index returns correct start and duration. @Test func segmentTimeRange() { // Track: 16s, segment: 6s → segments at 0-6, 6-12, 12-16 let range0 = HLSManifestGenerator.segmentTimeRange( index: 0, trackDuration: 16.0, segmentDuration: 6.0 ) #expect(range0.start == 0.0) #expect(range0.duration == 6.0) let range2 = HLSManifestGenerator.segmentTimeRange( index: 2, trackDuration: 16.0, segmentDuration: 6.0 ) #expect(range2.start == 12.0) #expect(range2.duration == 4.0) } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd MusicShared && swift test 2>&1 | grep -E "(FAIL|error:|Build)" ``` Expected: build error — `HLSManifestGenerator` not found. - [ ] **Step 3: Implement HLSManifestGenerator** `MusicShared/Sources/MusicShared/HLSManifestGenerator.swift`: ```swift import Foundation public enum HLSManifestGenerator: Sendable { public struct TimeRange: Equatable, Sendable { public var start: Double public var duration: Double } public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double) -> String { let count = segmentCount(duration: duration, segmentDuration: segmentDuration) let targetDuration = Int(segmentDuration.rounded(.up)) var lines: [String] = [ "#EXTM3U", "#EXT-X-VERSION:3", "#EXT-X-TARGETDURATION:\(targetDuration)", "#EXT-X-MEDIA-SEQUENCE:0", ] for i in 0.. Int { guard duration > 0, segmentDuration > 0 else { return 0 } return Int((duration / segmentDuration).rounded(.up)) } public static func segmentTimeRange(index: Int, trackDuration: Double, segmentDuration: Double) -> TimeRange { let start = Double(index) * segmentDuration let remaining = trackDuration - start let duration = min(segmentDuration, remaining) return TimeRange(start: start, duration: duration) } } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash cd MusicShared && swift test ``` Expected: all HLSManifestGenerator tests pass. - [ ] **Step 5: Commit** ```bash git add MusicShared/Sources/MusicShared/HLSManifestGenerator.swift \ MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift git commit -m "feat: add HLSManifestGenerator with TDD tests" ``` --- ## Task 5: Define PlaybackProvider Protocol **Files:** - Create: `Music/Protocols/PlaybackProvider.swift` This protocol abstracts the three playback modes so `PlayerViewModel` never checks which mode it's in. - [ ] **Step 1: Create PlaybackProvider.swift** `Music/Protocols/PlaybackProvider.swift`: ```swift import Foundation @MainActor protocol PlaybackProvider: AnyObject { var isPlaying: Bool { get } var currentTime: Double { get } var duration: Double { get } var volume: Float { get } var isScrubbing: Bool { get } var onTrackFinished: (() -> Void)? { get set } var onPlaybackStateChanged: (() -> Void)? { get set } /// Resolve a Track to the URL the provider should play. /// Local providers return the file URL; streaming providers return the HLS URL. func urlForTrack(_ track: Track) -> URL? func play(url: URL) func pause() func resume() func togglePlayPause() func seek(to position: Double) func setVolume(_ level: Float) func stop() func beginScrubbing() func scrub(to position: Double) func endScrubbing(at position: Double) } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add Music/Protocols/PlaybackProvider.swift Music.xcodeproj git commit -m "feat: add PlaybackProvider protocol" ``` --- ## Task 6: Conform AudioService to PlaybackProvider **Files:** - Modify: `Music/Services/AudioService.swift` `AudioService` already has every method and property that `PlaybackProvider` requires except `urlForTrack(_:)`. We declare the conformance and add the missing method. - [ ] **Step 1: Add protocol conformance and urlForTrack to AudioService** In `Music/Services/AudioService.swift`, change the class declaration at line 11 from: ```swift final class AudioService { ``` to: ```swift final class AudioService: PlaybackProvider { ``` Add this method anywhere in the class body: ```swift func urlForTrack(_ track: Track) -> URL? { URL(string: track.fileURL) } ``` - [ ] **Step 2: Build to verify conformance** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED — AudioService now satisfies all PlaybackProvider requirements. - [ ] **Step 3: Commit** ```bash git add Music/Services/AudioService.swift git commit -m "feat: conform AudioService to PlaybackProvider" ``` --- ## Task 7: Create RemotePlaybackProvider **Files:** - Create: `Music/Providers/RemotePlaybackProvider.swift` This extracts the remote-command-sending logic that currently lives inside `PlayerViewModel` into a dedicated `PlaybackProvider` conformer. In remote mode, commands are forwarded over the network; playback state comes from `applyRemoteState()`. - [ ] **Step 1: Create RemotePlaybackProvider.swift** `Music/Providers/RemotePlaybackProvider.swift`: ```swift import Foundation import Observation import MusicShared @Observable final class RemotePlaybackProvider: PlaybackProvider { var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var volume: Float = 0.65 private(set) var isScrubbing = false var onTrackFinished: (() -> Void)? var onPlaybackStateChanged: (() -> Void)? private weak var commandSender: RemoteCommandSender? init(commandSender: RemoteCommandSender) { self.commandSender = commandSender } func urlForTrack(_ track: Track) -> URL? { nil // Remote mode doesn't use URLs — commands are sent via sendPlayCommand } func play(url: URL) { // Remote mode uses sendPlayCommand(trackId:queueIds:) instead } func sendPlayCommand(trackId: Int64, queueIds: [Int64]) { commandSender?.sendCommand(.play(trackId: trackId, queueIds: queueIds)) } func pause() { isPlaying = false commandSender?.sendCommand(.pause) onPlaybackStateChanged?() } func resume() { isPlaying = true commandSender?.sendCommand(.resume) onPlaybackStateChanged?() } func togglePlayPause() { if isPlaying { pause() } else { resume() } } func seek(to position: Double) { currentTime = position commandSender?.sendCommand(.seek(position: position)) } func setVolume(_ level: Float) { volume = level commandSender?.sendCommand(.setVolume(level: level)) } func stop() { isPlaying = false currentTime = 0 duration = 0 onPlaybackStateChanged?() } func beginScrubbing() { isScrubbing = true } func scrub(to position: Double) { currentTime = position } func endScrubbing(at position: Double) { currentTime = position isScrubbing = false commandSender?.sendCommand(.seek(position: position)) } func sendNext() { commandSender?.sendCommand(.next) } func sendPrevious() { commandSender?.sendCommand(.previous) } func sendToggleShuffle() { commandSender?.sendCommand(.toggleShuffle) } func applyRemoteState(_ state: PlaybackStatePayload) { isPlaying = state.isPlaying currentTime = state.currentTime duration = state.duration volume = state.volume onPlaybackStateChanged?() } } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add Music/Providers/RemotePlaybackProvider.swift Music.xcodeproj git commit -m "feat: add RemotePlaybackProvider" ``` --- ## Task 8: Refactor PlayerViewModel to Use PlaybackProvider **Files:** - Modify: `Music/ViewModels/PlayerViewModel.swift` - Modify: `MusicTests/PlayerViewModelTests.swift` This is the key refactor. `PlayerViewModel` drops its direct `AudioService` reference and instead holds a `PlaybackProvider`. The `enterRemoteMode`/`exitRemoteMode` methods are replaced by `setProvider(_:)`. - [ ] **Step 1: Rewrite PlayerViewModel to use PlaybackProvider** Replace the contents of `Music/ViewModels/PlayerViewModel.swift`: ```swift import Foundation import Observation import MusicShared protocol RemoteCommandSender: AnyObject { func sendCommand(_ command: RemoteCommand) } @Observable final class PlayerViewModel { var currentTrack: Track? var currentIndex: Int? var isShuffled = false var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var volume: Float = 0.65 private(set) var queue: [Track] = [] private var originalQueue: [Track] = [] private var provider: PlaybackProvider private let db: DatabaseService? private var halfwayReported = false var trackResolver: ((Int64) -> Track?)? private var remoteProvider: RemotePlaybackProvider? { provider as? RemotePlaybackProvider } init(provider: PlaybackProvider, db: DatabaseService?) { self.provider = provider self.db = db bindProvider() } // MARK: - Provider Management func setProvider(_ newProvider: PlaybackProvider) { provider.stop() provider = newProvider currentTrack = nil currentIndex = nil isPlaying = false currentTime = 0 duration = 0 queue = [] originalQueue = [] halfwayReported = false bindProvider() } private func bindProvider() { provider.onTrackFinished = { [weak self] in self?.trackDidFinish() } provider.onPlaybackStateChanged = { [weak self] in self?.syncFromProvider() } } // MARK: - Provider Sync private func syncFromProvider() { isPlaying = provider.isPlaying if !provider.isScrubbing { currentTime = provider.currentTime } duration = provider.duration volume = provider.volume checkHalfway() } // MARK: - Queue Management func setQueue(_ tracks: [Track]) { originalQueue = tracks if isShuffled { queue = buildShuffledQueue(from: tracks, startingWith: currentTrack) } else { queue = tracks } if let current = currentTrack { currentIndex = queue.firstIndex(where: { $0.id == current.id }) } } // MARK: - Playback Controls func play(_ track: Track) { currentTrack = track currentIndex = queue.firstIndex(where: { $0.id == track.id }) halfwayReported = false isPlaying = true currentTime = 0 if let remote = remoteProvider { guard let trackId = track.id else { return } remote.sendPlayCommand(trackId: trackId, queueIds: queue.compactMap(\.id)) } else { guard let url = provider.urlForTrack(track) else { return } provider.play(url: url) } } func togglePlayPause() { if isPlaying { pause() } else { resume() } } func pause() { isPlaying = false provider.pause() } func resume() { isPlaying = true provider.resume() } func seek(to position: Double) { currentTime = position provider.seek(to: position) } func setVolume(_ level: Float) { volume = level provider.setVolume(level) } func beginScrubbing() { provider.beginScrubbing() } func scrub(to position: Double) { currentTime = position provider.scrub(to: position) } func endScrubbing(at position: Double) { currentTime = position provider.endScrubbing(at: position) } func next() { if let remote = remoteProvider { remote.sendNext() return } guard let idx = currentIndex else { return } let nextIdx = idx + 1 if nextIdx < queue.count { play(queue[nextIdx]) } else { stop() } } func previous() { if let remote = remoteProvider { remote.sendPrevious() return } guard let idx = currentIndex else { return } let prevIdx = max(0, idx - 1) play(queue[prevIdx]) } func toggleShuffle() { isShuffled.toggle() if let remote = remoteProvider { remote.sendToggleShuffle() return } if isShuffled { queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) } else { queue = originalQueue } if let current = currentTrack { currentIndex = queue.firstIndex(where: { $0.id == current.id }) } } func stop() { isPlaying = false currentTime = 0 duration = 0 currentTrack = nil currentIndex = nil provider.stop() } // MARK: - Remote State func applyRemoteState(_ state: PlaybackStatePayload) { guard let remote = remoteProvider else { return } remote.applyRemoteState(state) 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 } } // MARK: - Internal func checkHalfway() { guard !halfwayReported, duration > 0, currentTime >= duration * 0.5, let track = currentTrack, let trackId = track.id else { return } halfwayReported = true let newCount = track.playCount + 1 try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) } private func trackDidFinish() { if let track = currentTrack, let trackId = track.id, !halfwayReported { let newCount = track.playCount + 1 try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) } next() } private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] { var shuffled = tracks.shuffled() if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) { shuffled.remove(at: idx) shuffled.insert(current, at: 0) } return shuffled } } ``` - [ ] **Step 2: Update MusicApp.swift to use new PlayerViewModel init** In `Music/MusicApp.swift`, change line 98 from: ```swift let player = PlayerViewModel(audio: audioService, db: db) ``` to: ```swift let player = PlayerViewModel(provider: audioService, db: db) ``` Update `enterRemoteMode()` (around line 197) — replace: ```swift player.enterRemoteMode(client: remoteClient) ``` with: ```swift let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient) player.setProvider(remoteProvider) ``` Update `exitRemoteMode()` (around line 218) — replace: ```swift playerVM?.exitRemoteMode() ``` with: ```swift playerVM?.setProvider(audioService) ``` - [ ] **Step 3: Update PlayerViewModelTests** Replace `PlayerViewModel(audio: AudioService(), db: nil)` with `PlayerViewModel(provider: AudioService(), db: nil)` in every test. In `MusicTests/PlayerViewModelTests.swift`, change the `makeTracks` helper's test setup. Every occurrence of: ```swift let vm = PlayerViewModel(audio: AudioService(), db: nil) ``` becomes: ```swift let vm = PlayerViewModel(provider: AudioService(), db: nil) ``` - [ ] **Step 4: Build and run tests** ```bash xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 ``` Expected: all PlayerViewModelTests pass, build succeeds. - [ ] **Step 5: Commit** ```bash git add Music/ViewModels/PlayerViewModel.swift Music/MusicApp.swift \ MusicTests/PlayerViewModelTests.swift Music/Providers/ git commit -m "refactor: PlayerViewModel uses PlaybackProvider protocol" ``` --- ## Task 9: Update NetworkStatus for Streaming Modes **Files:** - Modify: `Music/Remote/NetworkStatus.swift` - [ ] **Step 1: Add streaming modes to NetworkStatus.Mode** Replace the contents of `Music/Remote/NetworkStatus.swift`: ```swift import Foundation import MusicShared struct NetworkStatus { enum Mode { case hosting(connectedRemote: String?) case remote(hostName: String) case streamHosting(tunnelURL: String?) case streamClient(hostName: String) } var mode: Mode var onDisconnect: (() -> Void)? var onRefreshLibrary: (() -> Void)? var isRemoteMode: Bool { switch mode { case .remote, .streamClient: return true default: return false } } var isHostMode: Bool { switch mode { case .hosting, .streamHosting: return true default: return false } } var statusMessage: String { switch mode { case .hosting(let remote): if let remote { return "Hosting · \(remote) connected" } return "Hosting" case .remote(let host): return "Connected to \(host)" case .streamHosting(let url): if let url { return "Streaming · \(url)" } return "Streaming server starting..." case .streamClient(let host): return "Streaming from \(host)" } } } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED (existing `computeNetworkStatus()` in MusicApp.swift still compiles since the original cases still exist). - [ ] **Step 3: Commit** ```bash git add Music/Remote/NetworkStatus.swift git commit -m "feat: add streaming modes to NetworkStatus" ``` --- ## Task 10: Add Hummingbird Dependency **Files:** - Modify: `MusicShared/Package.swift` (add Hummingbird as dependency) - Modify: `Music.xcodeproj/project.pbxproj` (Xcode auto-handles via local package) Hummingbird is added to MusicShared so the streaming server types can be imported by both macOS and (eventually) iOS targets. Only the host target will actually use server functionality. - [ ] **Step 1: Update MusicShared/Package.swift to add Hummingbird** Replace `MusicShared/Package.swift`: ```swift // swift-tools-version: 5.10 import PackageDescription let package = Package( name: "MusicShared", platforms: [.macOS(.v14), .iOS(.v17)], products: [ .library(name: "MusicShared", targets: ["MusicShared"]), ], dependencies: [ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.0.0"), ], targets: [ .target( name: "MusicShared", dependencies: [ .product(name: "Hummingbird", package: "hummingbird"), .product(name: "HummingbirdWebSocket", package: "hummingbird-websocket"), ] ), .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), ] ) ``` - [ ] **Step 2: Resolve and build** ```bash cd MusicShared && swift package resolve && swift build ``` Expected: Hummingbird downloads and builds successfully. - [ ] **Step 3: Verify Xcode picks up the dependency** In Xcode, clean build folder and rebuild. The local package's new dependencies should resolve automatically. ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 4: Commit** ```bash git add MusicShared/Package.swift MusicShared/Package.resolved git commit -m "chore: add Hummingbird dependency to MusicShared" ``` --- ## Task 11: Create HLSSegmenter (TDD) **Files:** - Create: `Music/Streaming/HLSSegmenter.swift` - Create: `MusicTests/HLSSegmenterTests.swift` The segmenter uses `AVAssetReader` with time ranges to extract audio segments from MP3 files. It handles VBR files and frame-boundary alignment correctly. - [ ] **Step 1: Write the failing tests** `MusicTests/HLSSegmenterTests.swift`: ```swift import Testing import Foundation @testable import Music // Integration test using a real audio file. // Requires a test MP3 fixture (see Step 3). @MainActor struct HLSSegmenterTests { // Creates a segmenter for a test MP3 file and verifies it reports the correct duration. @Test func readsDurationFromFile() async throws { let url = try TestFixtures.shortMP3URL() let segmenter = try HLSSegmenter(fileURL: url) // The test fixture is ~3 seconds long #expect(segmenter.duration > 2.0) #expect(segmenter.duration < 5.0) } // Extracts the first segment and verifies it returns non-empty data. @Test func extractsFirstSegment() async throws { let url = try TestFixtures.shortMP3URL() let segmenter = try HLSSegmenter(fileURL: url) let data = try await segmenter.segment(at: 0, segmentDuration: 6.0) #expect(!data.isEmpty) } // Requesting a segment index beyond the track duration returns nil. @Test func outOfRangeSegmentReturnsNil() async throws { let url = try TestFixtures.shortMP3URL() let segmenter = try HLSSegmenter(fileURL: url) let data = try await segmenter.segment(at: 999, segmentDuration: 6.0) #expect(data == nil) } } enum TestFixtures { // Returns the URL of a short test MP3 file. // The file is generated once using afconvert if it doesn't exist. static func shortMP3URL() throws -> URL { let tempDir = FileManager.default.temporaryDirectory let url = tempDir.appendingPathComponent("test_fixture.mp3") if !FileManager.default.fileExists(atPath: url.path) { // Generate a 3-second silent MP3 using afconvert via a CAF intermediary let cafURL = tempDir.appendingPathComponent("test_fixture.caf") let sampleRate = 44100 let channels = 1 let durationSamples = sampleRate * 3 let bytesPerSample = 2 let dataSize = durationSamples * channels * bytesPerSample // Create a minimal WAV as input for afconvert let wavURL = tempDir.appendingPathComponent("test_fixture.wav") var wavData = Data() func appendString(_ s: String) { wavData.append(contentsOf: s.utf8) } func appendUInt32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } func appendUInt16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } appendString("RIFF") appendUInt32(UInt32(36 + dataSize)) appendString("WAVE") appendString("fmt ") appendUInt32(16) appendUInt16(1) // PCM appendUInt16(UInt16(channels)) appendUInt32(UInt32(sampleRate)) appendUInt32(UInt32(sampleRate * channels * bytesPerSample)) appendUInt16(UInt16(channels * bytesPerSample)) appendUInt16(UInt16(bytesPerSample * 8)) appendString("data") appendUInt32(UInt32(dataSize)) wavData.append(Data(count: dataSize)) // silence try wavData.write(to: wavURL) // Convert WAV → MP3 using afconvert let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") process.arguments = [wavURL.path, url.path, "-f", "MPE3", "-d", "mp3"] try process.run() process.waitUntilExit() try? FileManager.default.removeItem(at: wavURL) guard FileManager.default.fileExists(atPath: url.path) else { throw NSError(domain: "TestFixtures", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create test MP3"]) } } return url } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash xcodebuild -project Music.xcodeproj -scheme Music test \ -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -10 ``` Expected: build error — `HLSSegmenter` not found. - [ ] **Step 3: Implement HLSSegmenter** `Music/Streaming/HLSSegmenter.swift`: ```swift import AVFoundation import Foundation final class HLSSegmenter: Sendable { let fileURL: URL let duration: Double init(fileURL: URL) throws { self.fileURL = fileURL let asset = AVURLAsset(url: fileURL) let durationCM = asset.duration guard durationCM.isValid, !durationCM.isIndefinite else { throw HLSSegmenterError.invalidDuration } self.duration = durationCM.seconds } func segment(at index: Int, segmentDuration: Double) async throws -> Data? { let startTime = Double(index) * segmentDuration guard startTime < duration else { return nil } let endTime = min(startTime + segmentDuration, duration) let timeRange = CMTimeRange( start: CMTime(seconds: startTime, preferredTimescale: 600), end: CMTime(seconds: endTime, preferredTimescale: 600) ) let asset = AVURLAsset(url: fileURL) guard let track = try await asset.loadTracks(withMediaType: .audio).first else { throw HLSSegmenterError.noAudioTrack } let reader = try AVAssetReader(asset: asset) reader.timeRange = timeRange let outputSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEGLayer3, ] // Try passthrough first (for MP3 sources), fall back to PCM if needed let output: AVAssetReaderOutput let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil) if reader.canAdd(trackOutput) { reader.add(trackOutput) output = trackOutput } else { let pcmOutput = AVAssetReaderTrackOutput(track: track, outputSettings: [ AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 44100, AVNumberOfChannelsKey: 2, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, ]) reader.add(pcmOutput) output = pcmOutput } reader.startReading() var segmentData = Data() while let sampleBuffer = output.copyNextSampleBuffer() { if let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) { let length = CMBlockBufferGetDataLength(blockBuffer) var bytes = [UInt8](repeating: 0, count: length) CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &bytes) segmentData.append(contentsOf: bytes) } } guard reader.status == .completed else { throw HLSSegmenterError.readFailed(reader.error) } return segmentData } } enum HLSSegmenterError: Error { case invalidDuration case noAudioTrack case readFailed(Error?) } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash xcodebuild -project Music.xcodeproj -scheme Music test \ -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -20 ``` Expected: all HLSSegmenterTests pass. - [ ] **Step 5: Commit** ```bash git add Music/Streaming/HLSSegmenter.swift MusicTests/HLSSegmenterTests.swift Music.xcodeproj git commit -m "feat: add HLSSegmenter with AVAssetReader-based extraction" ``` --- ## Task 12: Create StreamingServer **Files:** - Create: `Music/Streaming/StreamingServer.swift` - Create: `MusicTests/StreamingServerTests.swift` The streaming server is a Hummingbird HTTP server that serves: - `GET /auth` — API key validation - `GET /db` — SQLite database download - `GET /tracks/:trackId/stream.m3u8` — HLS manifest - `GET /tracks/:trackId/segments/:index.mp3` — Audio segment - WebSocket at `/ws` — real-time events - [ ] **Step 1: Write integration tests for the streaming server** `MusicTests/StreamingServerTests.swift`: ```swift import Testing import Foundation @testable import Music @testable import MusicShared @MainActor struct StreamingServerTests { static let testAPIKey = "test-key-12345" // Verifies that GET /auth with a valid API key returns 200 and an AuthResponse. @Test func authEndpointAcceptsValidKey() async throws { let server = try makeServer() try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) request.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 200) let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) } // Verifies that GET /auth without a key returns 401. @Test func authEndpointRejectsNoKey() async throws { let server = try makeServer() try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 401) } // Verifies that GET /db returns a non-empty SQLite file. @Test func dbEndpointReturnsDatabaseFile() async throws { let server = try makeServer() try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.db)")!) request.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 200) // SQLite files start with "SQLite format 3\0" #expect(data.count > 16) #expect(String(data: data.prefix(15), encoding: .utf8) == "SQLite format 3") } private func makeServer() throws -> StreamingServer { let db = try DatabaseService(inMemory: true) return StreamingServer( db: db, apiKey: Self.testAPIKey, port: 0 // OS-assigned port ) } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash xcodebuild -project Music.xcodeproj -scheme Music test \ -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -10 ``` Expected: build error — `StreamingServer` not found. - [ ] **Step 3: Implement StreamingServer** `Music/Streaming/StreamingServer.swift`: ```swift import Foundation import Hummingbird import HummingbirdWebSocket import MusicShared import os @MainActor @Observable final class StreamingServer { var isRunning = false private(set) var actualPort: Int? private let db: DatabaseService private let apiKey: String private let requestedPort: Int private var serverTask: Task? private let logger = Logger(subsystem: "com.music.streaming", category: "server") // Cache segmenters by track ID to avoid re-reading duration on every request private var segmenters: [Int64: HLSSegmenter] = [:] init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) { self.db = db self.apiKey = apiKey self.requestedPort = port } func start() async throws { let router = Router() router.middlewares.add(AuthMiddleware(apiKey: apiKey)) router.get(StreamingRoutes.auth) { [weak self] request, context -> Response in guard let self else { return Response(status: .internalServerError) } let hostName = Host.current().localizedName ?? "Music Host" let response = AuthResponse(hostName: hostName, protocolVersion: StreamingConstants.protocolVersion) let data = try JSONEncoder().encode(response) return Response( status: .ok, headers: [.contentType: "application/json"], body: .init(byteBuffer: .init(data: data)) ) } router.get(StreamingRoutes.db) { [weak self] request, context -> Response in guard let self else { return Response(status: .internalServerError) } let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString + ".sqlite") defer { try? FileManager.default.removeItem(at: tempURL) } try self.db.backup(to: tempURL.path) let data = try Data(contentsOf: tempURL) return Response( status: .ok, headers: [.contentType: "application/octet-stream"], body: .init(byteBuffer: .init(data: data)) ) } router.get("tracks/:trackId/stream.m3u8") { [weak self] request, context -> Response in guard let self else { return Response(status: .internalServerError) } guard let trackIdStr = context.parameters.get("trackId"), let trackId = Int64(trackIdStr) else { return Response(status: .badRequest) } let segmenter = try self.getOrCreateSegmenter(trackId: trackId) let manifest = HLSManifestGenerator.manifest( trackId: trackId, duration: segmenter.duration, segmentDuration: StreamingConstants.segmentDuration ) return Response( status: .ok, headers: [ .contentType: "application/vnd.apple.mpegurl", .cacheControl: "no-cache", ], body: .init(byteBuffer: .init(string: manifest)) ) } router.get("tracks/:trackId/segments/:index") { [weak self] request, context -> Response in guard let self else { return Response(status: .internalServerError) } guard let trackIdStr = context.parameters.get("trackId"), let trackId = Int64(trackIdStr), let indexStr = context.parameters.get("index"), let index = Int(indexStr.replacingOccurrences(of: ".mp3", with: "")) else { return Response(status: .badRequest) } let segmenter = try self.getOrCreateSegmenter(trackId: trackId) guard let data = try await segmenter.segment(at: index, segmentDuration: StreamingConstants.segmentDuration) else { return Response(status: .notFound) } return Response( status: .ok, headers: [.contentType: "audio/mpeg"], body: .init(byteBuffer: .init(data: data)) ) } // WebSocket route for real-time events router.ws(StreamingRoutes.ws) { [weak self] request, context in // Auth check for WebSocket upgrade guard let authHeader = request.headers[.authorization], authHeader == "Bearer \(self?.apiKey ?? "")" else { return .dontUpgrade } return .upgrade([:]) } onUpgrade: { [weak self] inbound, outbound, context in guard let self else { return } for try await message in inbound.messages { switch message { case .text(let text): self.handleWSMessage(text, outbound: outbound) case .binary: break } } } let app = Application( router: router, configuration: .init(address: .hostname("127.0.0.1", port: requestedPort)) ) serverTask = Task { try await app.run() } // Give the server a moment to bind try await Task.sleep(for: .milliseconds(200)) actualPort = requestedPort == 0 ? app.port : requestedPort isRunning = true logger.info("Streaming server started on port \(self.actualPort ?? 0)") } func stop() { serverTask?.cancel() serverTask = nil segmenters = [:] actualPort = nil isRunning = false logger.info("Streaming server stopped") } // MARK: - WebSocket private func handleWSMessage(_ text: String, outbound: WebSocketOutboundWriter) { guard let data = text.data(using: .utf8) else { return } let decoder = JSONDecoder() if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) { logger.info("WS handshake: v\(handshake.protocolVersion), app \(handshake.appVersion)") return } if let command = try? decoder.decode(RemoteCommand.self, from: data) { logger.info("WS command: \(String(describing: command))") if case .refreshDB = command { let event = HostEvent.dbReady if let encoded = try? JSONEncoder().encode(event), let str = String(data: encoded, encoding: .utf8) { Task { try? await outbound.write(.text(str)) } } } } } // MARK: - Helpers private func getOrCreateSegmenter(trackId: Int64) throws -> HLSSegmenter { if let cached = segmenters[trackId] { return cached } let tracks = try db.fetchTracksByIds([trackId]) guard let track = tracks.first else { throw StreamingServerError.trackNotFound(trackId) } let fileURL = URL(fileURLWithPath: track.fileURL) let segmenter = try HLSSegmenter(fileURL: fileURL) segmenters[trackId] = segmenter return segmenter } } enum StreamingServerError: Error { case trackNotFound(Int64) } struct AuthMiddleware: MiddlewareProtocol { let apiKey: String func handle( _ request: Request, context: some RequestContext, next: (Request, some RequestContext) async throws -> Response ) async throws -> Response { let authHeader = request.headers[.authorization] guard authHeader == "Bearer \(apiKey)" else { return Response(status: .unauthorized) } return try await next(request, context) } } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash xcodebuild -project Music.xcodeproj -scheme Music test \ -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -20 ``` Expected: all StreamingServerTests pass. - [ ] **Step 5: Commit** ```bash git add Music/Streaming/StreamingServer.swift MusicTests/StreamingServerTests.swift Music.xcodeproj git commit -m "feat: add StreamingServer with Hummingbird HTTP endpoints" ``` --- ## Task 13: Create TunnelManager **Files:** - Create: `Music/Streaming/TunnelManager.swift` Manages the `cloudflared` child process. Supports both quick tunnels (random URL) and named tunnels (stable URL). - [ ] **Step 1: Create TunnelManager.swift** `Music/Streaming/TunnelManager.swift`: ```swift import Foundation import os import Observation @MainActor @Observable final class TunnelManager { enum TunnelMode: String, Codable { case quick case named } enum TunnelState: Equatable { case stopped case starting case running(url: String) case failed(message: String) } var state: TunnelState = .stopped var tunnelURL: String? { if case .running(let url) = state { return url } return nil } private var process: Process? private var outputPipe: Pipe? private let logger = Logger(subsystem: "com.music.streaming", category: "tunnel") static func isCloudflaredInstalled() -> Bool { FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") || FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") } static var cloudflaredPath: String? { if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") { return "/opt/homebrew/bin/cloudflared" } if FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") { return "/usr/local/bin/cloudflared" } return nil } func startQuickTunnel(localPort: Int) throws { guard let path = Self.cloudflaredPath else { state = .failed(message: "cloudflared not found. Install with: brew install cloudflared") return } state = .starting let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = ["tunnel", "--url", "http://localhost:\(localPort)"] let pipe = Pipe() process.standardError = pipe // cloudflared writes URL to stderr pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } Task { @MainActor [weak self] in self?.parseOutput(line) } } process.terminationHandler = { [weak self] proc in Task { @MainActor [weak self] in guard let self else { return } if case .running = self.state { } else { self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)") } self.state = .stopped } } try process.run() self.process = process self.outputPipe = pipe logger.info("Started cloudflared quick tunnel on port \(localPort)") } func startNamedTunnel(tunnelName: String, localPort: Int) throws { guard let path = Self.cloudflaredPath else { state = .failed(message: "cloudflared not found. Install with: brew install cloudflared") return } state = .starting let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName] let pipe = Pipe() process.standardError = pipe pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } Task { @MainActor [weak self] in self?.parseOutput(line) } } process.terminationHandler = { [weak self] _ in Task { @MainActor [weak self] in self?.state = .stopped } } try process.run() self.process = process self.outputPipe = pipe logger.info("Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)") } func stop() { process?.terminate() process = nil outputPipe?.fileHandleForReading.readabilityHandler = nil outputPipe = nil state = .stopped logger.info("Stopped cloudflared tunnel") } private func parseOutput(_ output: String) { // cloudflared prints the tunnel URL to stderr in a line like: // "... https://xxx-yyy-zzz.trycloudflare.com ..." // or for named tunnels, the configured hostname let lines = output.components(separatedBy: .newlines) for line in lines { if let range = line.range(of: "https://[^ ]+", options: .regularExpression) { let url = String(line[range]) state = .running(url: url) logger.info("Tunnel URL: \(url)") } } } } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add Music/Streaming/TunnelManager.swift Music.xcodeproj git commit -m "feat: add TunnelManager for cloudflared process management" ``` --- ## Task 14: Create StreamingPlaybackProvider **Files:** - Create: `Music/Providers/StreamingPlaybackProvider.swift` Uses `AVPlayer` with HLS URLs and injects the API key as a custom HTTP header. This reuses AVPlayer the same way `AudioService` does, but points at remote URLs instead of local files. - [ ] **Step 1: Create StreamingPlaybackProvider.swift** `Music/Providers/StreamingPlaybackProvider.swift`: ```swift import AVFoundation import Foundation import Observation import MusicShared @Observable final class StreamingPlaybackProvider: PlaybackProvider { var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 var volume: Float = 0.65 { didSet { player?.volume = volume } } private(set) var isScrubbing = false var onTrackFinished: (() -> Void)? var onPlaybackStateChanged: (() -> Void)? private var player: AVPlayer? private var timeObserver: Any? private var endObserver: NSObjectProtocol? private var seekInProgress = false private var pendingSeekTime: Double? private let hostURL: String private let apiKey: String init(hostURL: String, apiKey: String) { self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL self.apiKey = apiKey } func urlForTrack(_ track: Track) -> URL? { guard let trackId = track.id else { return nil } return URL(string: "\(hostURL)\(StreamingRoutes.trackManifest(trackId: trackId))") } func play(url: URL) { cleanup() let headers = ["Authorization": "Bearer \(apiKey)"] let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) let item = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: item) player?.volume = volume timeObserver = player?.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self, !self.isScrubbing else { return } self.currentTime = time.seconds if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { self.duration = dur.seconds } self.onPlaybackStateChanged?() } endObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in self?.isPlaying = false self?.currentTime = 0 self?.onPlaybackStateChanged?() self?.onTrackFinished?() } player?.play() isPlaying = true onPlaybackStateChanged?() } func playTrack(id: Int64) { let urlString = "\(hostURL)\(StreamingRoutes.trackManifest(trackId: id))" guard let url = URL(string: urlString) else { return } play(url: url) } func pause() { player?.pause() isPlaying = false onPlaybackStateChanged?() } func resume() { player?.play() isPlaying = true onPlaybackStateChanged?() } func togglePlayPause() { if isPlaying { pause() } else { resume() } } func seek(to time: Double) { let clamped = max(0, min(time, duration)) currentTime = clamped player?.seek( to: CMTime(seconds: clamped, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero ) } func setVolume(_ level: Float) { volume = level } func beginScrubbing() { isScrubbing = true } func scrub(to time: Double) { let clamped = max(0, min(time, duration)) currentTime = clamped // Chase-seek for smooth scrubbing pendingSeekTime = clamped guard !seekInProgress else { return } performPendingSeek() } func endScrubbing(at time: Double) { let clamped = max(0, min(time, duration)) currentTime = clamped pendingSeekTime = nil seekInProgress = false player?.seek( to: CMTime(seconds: clamped, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero ) { [weak self] _ in DispatchQueue.main.async { self?.isScrubbing = false } } } func stop() { cleanup() isPlaying = false currentTime = 0 duration = 0 onPlaybackStateChanged?() } private func performPendingSeek() { guard let time = pendingSeekTime else { return } pendingSeekTime = nil seekInProgress = true player?.seek( to: CMTime(seconds: time, preferredTimescale: 600), toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600), toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600) ) { [weak self] _ in DispatchQueue.main.async { guard let self else { return } self.seekInProgress = false if self.pendingSeekTime != nil { self.performPendingSeek() } } } } private func cleanup() { if let obs = timeObserver { player?.removeTimeObserver(obs) timeObserver = nil } if let obs = endObserver { NotificationCenter.default.removeObserver(obs) endObserver = nil } player?.pause() player = nil } nonisolated deinit {} } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add Music/Providers/StreamingPlaybackProvider.swift Music.xcodeproj git commit -m "feat: add StreamingPlaybackProvider with HLS AVPlayer" ``` --- ## Task 15: Create StreamingClient **Files:** - Create: `Music/Streaming/StreamingClient.swift` Handles the client-side connection lifecycle: auth validation, DB download, WebSocket for events. - [ ] **Step 1: Create StreamingClient.swift** `Music/Streaming/StreamingClient.swift`: ```swift import Foundation import Observation import MusicShared import os @MainActor @Observable final class StreamingClient { enum State: Equatable { case disconnected case connecting case downloadingDB case connected(hostName: String) case error(message: String) var isConnected: Bool { if case .connected = self { return true } return false } } var state: State = .disconnected var onDBReady: (() -> Void)? private var hostURL: String = "" private var apiKey: String = "" private var webSocketTask: URLSessionWebSocketTask? private let logger = Logger(subsystem: "com.music.streaming", category: "client") static var streamingDBPath: String { let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first!.appendingPathComponent("Music", isDirectory: true) return appSupport.appendingPathComponent("streaming_db.sqlite").path } func connect(hostURL: String, apiKey: String) async { self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL self.apiKey = apiKey state = .connecting // Step 1: Validate auth do { let authResponse = try await authenticate() logger.info("Authenticated with host: \(authResponse.hostName)") // Step 2: Download DB state = .downloadingDB try await downloadDatabase() logger.info("Database downloaded") // Step 3: Connect WebSocket connectWebSocket() state = .connected(hostName: authResponse.hostName) } catch { logger.error("Connection failed: \(error.localizedDescription)") state = .error(message: error.localizedDescription) } } func disconnect() { webSocketTask?.cancel(with: .normalClosure, reason: nil) webSocketTask = nil deleteStreamingDB() state = .disconnected } func requestDBRefresh() { sendCommand(.refreshDB) } // MARK: - Auth private func authenticate() async throws -> AuthResponse { let url = URL(string: "\(hostURL)\(StreamingRoutes.auth)")! var request = URLRequest(url: url) request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw StreamingClientError.invalidResponse } if httpResponse.statusCode == 401 { throw StreamingClientError.unauthorized } guard httpResponse.statusCode == 200 else { throw StreamingClientError.serverError(httpResponse.statusCode) } return try JSONDecoder().decode(AuthResponse.self, from: data) } // MARK: - DB Download private func downloadDatabase() async throws { let url = URL(string: "\(hostURL)\(StreamingRoutes.db)")! var request = URLRequest(url: url) request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw StreamingClientError.dbDownloadFailed } let dirURL = URL(fileURLWithPath: Self.streamingDBPath).deletingLastPathComponent() try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) try data.write(to: URL(fileURLWithPath: Self.streamingDBPath)) logger.info("Database saved (\(data.count) bytes)") } // MARK: - WebSocket private func connectWebSocket() { let wsURLString = hostURL .replacingOccurrences(of: "https://", with: "wss://") .replacingOccurrences(of: "http://", with: "ws://") guard let url = URL(string: "\(wsURLString)\(StreamingRoutes.ws)") else { return } var request = URLRequest(url: url) request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let task = URLSession.shared.webSocketTask(with: request) task.resume() self.webSocketTask = task // Send handshake let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" let handshake = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: appVersion) if let data = try? JSONEncoder().encode(handshake), let string = String(data: data, encoding: .utf8) { task.send(.string(string)) { [weak self] error in if let error { self?.logger.error("Failed to send handshake: \(error.localizedDescription)") } } } receiveWebSocketMessages() } private func receiveWebSocketMessages() { webSocketTask?.receive { [weak self] result in Task { @MainActor [weak self] in guard let self else { return } switch result { case .success(let message): self.handleWebSocketMessage(message) self.receiveWebSocketMessages() case .failure(let error): self.logger.error("WebSocket error: \(error.localizedDescription)") if self.state.isConnected { self.state = .error(message: "Connection lost") } } } } } private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { let data: Data switch message { case .string(let text): guard let d = text.data(using: .utf8) else { return } data = d case .data(let d): data = d @unknown default: return } do { let event = try JSONDecoder().decode(HostEvent.self, from: data) switch event { case .playbackState: break // Not used in streaming mode (client drives its own playback) case .dbReady: onDBReady?() case .error(let message): logger.error("Host error: \(message)") } } catch { logger.error("Failed to decode event: \(error.localizedDescription)") } } private func sendCommand(_ command: RemoteCommand) { guard let data = try? JSONEncoder().encode(command), let string = String(data: data, encoding: .utf8) else { return } webSocketTask?.send(.string(string)) { [weak self] error in if let error { self?.logger.error("Failed to send command: \(error.localizedDescription)") } } } private func deleteStreamingDB() { let path = Self.streamingDBPath if FileManager.default.fileExists(atPath: path) { try? FileManager.default.removeItem(atPath: path) logger.info("Deleted streaming DB") } } } enum StreamingClientError: LocalizedError { case invalidResponse case unauthorized case serverError(Int) case dbDownloadFailed var errorDescription: String? { switch self { case .invalidResponse: return "Invalid server response" case .unauthorized: return "Invalid API key" case .serverError(let code): return "Server error (\(code))" case .dbDownloadFailed: return "Failed to download library" } } } ``` - [ ] **Step 2: Build to verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add Music/Streaming/StreamingClient.swift Music.xcodeproj git commit -m "feat: add StreamingClient with auth, DB download, and WebSocket" ``` --- ## Task 16: Wire Up MusicApp.swift with Streaming Host and Client **Files:** - Modify: `Music/MusicApp.swift` Add streaming server/client state, menu items for stream host and stream client, and mode switching logic. - [ ] **Step 1: Add streaming state properties** In `MusicApp.swift`, add these `@State` properties after the existing ones (around line 16): ```swift @State private var streamingServer: StreamingServer? @State private var tunnelManager = TunnelManager() @State private var streamingClient = StreamingClient() @State private var showStreamingSettings = false @State private var streamHostURL = UserDefaults.standard.string(forKey: "streamHostURL") ?? "" @State private var streamAPIKey = UserDefaults.standard.string(forKey: "streamAPIKey") ?? "" ``` - [ ] **Step 2: Add streaming menu commands** In the `.commands` block, after the existing "Connect to Remote..." button (around line 83), add: ```swift Divider() Button("Start Streaming Server...") { startStreamingServer() } .disabled(streamingServer?.isRunning ?? false || remoteClient.connectionState.isConnected) Button("Stop Streaming Server") { stopStreamingServer() } .disabled(!(streamingServer?.isRunning ?? false)) Divider() Button("Connect to Stream Host...") { showStreamingSettings = true } .disabled(streamingClient.state.isConnected || hostServer?.isHosting ?? false) if streamingClient.state.isConnected { Button("Disconnect from Stream") { exitStreamingClientMode() } } ``` - [ ] **Step 3: Add streaming server methods** Add these methods to `MusicApp`: ```swift // MARK: - Streaming Host private func startStreamingServer() { guard let db = dbService else { return } // Generate API key if first time var key = UserDefaults.standard.string(forKey: "streamServerAPIKey") ?? "" if key.isEmpty { key = UUID().uuidString UserDefaults.standard.set(key, forKey: "streamServerAPIKey") } let server = StreamingServer(db: db, apiKey: key) Task { do { try await server.start() self.streamingServer = server // Start tunnel if TunnelManager.isCloudflaredInstalled() { try tunnelManager.startQuickTunnel(localPort: server.actualPort ?? StreamingConstants.defaultPort) } } catch { print("Failed to start streaming server: \(error)") } } } private func stopStreamingServer() { streamingServer?.stop() streamingServer = nil tunnelManager.stop() } // MARK: - Streaming Client private func enterStreamingClientMode() { guard !streamHostURL.isEmpty, !streamAPIKey.isEmpty else { return } UserDefaults.standard.set(streamHostURL, forKey: "streamHostURL") UserDefaults.standard.set(streamAPIKey, forKey: "streamAPIKey") Task { await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) if streamingClient.state.isConnected { do { let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) self.libraryVM = LibraryViewModel(db: streamingDb) self.playlistVM = PlaylistViewModel(db: streamingDb) let streamProvider = StreamingPlaybackProvider( hostURL: streamHostURL, apiKey: streamAPIKey ) playerVM?.setProvider(streamProvider) playerVM?.trackResolver = { trackId in self.libraryVM?.tracks.first(where: { $0.id == trackId }) } streamingClient.onDBReady = { [weak self] in guard let self else { return } Task { try? await self.refreshStreamingDB() } } } catch { print("Failed to load streaming DB: \(error)") streamingClient.disconnect() } } } } private func exitStreamingClientMode() { streamingClient.disconnect() playerVM?.setProvider(audioService) playerVM?.trackResolver = nil guard let db = dbService else { return } self.libraryVM = LibraryViewModel(db: db) self.playlistVM = PlaylistViewModel(db: db) } private func refreshStreamingDB() async throws { await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) if streamingClient.state.isConnected { let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) self.libraryVM = LibraryViewModel(db: streamingDb) self.playlistVM = PlaylistViewModel(db: streamingDb) } } ``` - [ ] **Step 4: Update computeNetworkStatus() to include streaming modes** Add streaming cases to `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)) } if let server = streamingServer, server.isRunning { return NetworkStatus(mode: .streamHosting(tunnelURL: tunnelManager.tunnelURL)) } if case .connected(let hostName) = streamingClient.state { return NetworkStatus( mode: .streamClient(hostName: hostName), onDisconnect: { [weak self] in self?.exitStreamingClientMode() }, onRefreshLibrary: { [streamingClient] in streamingClient.requestDBRefresh() } ) } return nil } ``` - [ ] **Step 5: Add streaming settings sheet** Add a `.sheet` modifier for streaming connection settings. After the existing `.sheet(isPresented: $showConnectionSheet)` block: ```swift .sheet(isPresented: $showStreamingSettings) { VStack(spacing: 16) { Text("Connect to Streaming Host") .font(.headline) TextField("Host URL", text: $streamHostURL) .textFieldStyle(.roundedBorder) SecureField("API Key", text: $streamAPIKey) .textFieldStyle(.roundedBorder) HStack { Button("Cancel") { showStreamingSettings = false } Button("Connect") { showStreamingSettings = false enterStreamingClientMode() } .disabled(streamHostURL.isEmpty || streamAPIKey.isEmpty) .keyboardShortcut(.defaultAction) } } .padding(24) .frame(width: 400) } ``` - [ ] **Step 6: Build to verify everything compiles** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 7: Run all tests to verify nothing is broken** ```bash xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 8: Commit** ```bash git add Music/MusicApp.swift Music.xcodeproj git commit -m "feat: wire up streaming host and client in MusicApp" ``` --- ## Task 17: Update ContentView for Streaming Status Display **Files:** - Modify: `Music/ContentView.swift` The network status bar in ContentView already displays `NetworkStatus` — we just need to make sure the new streaming modes show properly and that editing is disabled in streaming client mode. - [ ] **Step 1: Update the network status bar display** In `Music/ContentView.swift`, find the network status bar section (around line 29-56). The bar likely reads `networkStatus?.mode`. Add handling for the new cases. If the existing code uses a `switch` on `networkStatus.mode`, add: ```swift case .streamHosting(let url): HStack { Image(systemName: "antenna.radiowaves.left.and.right") Text(url != nil ? "Streaming · \(url!)" : "Streaming server starting...") } case .streamClient(let host): HStack { Image(systemName: "music.note.tv") Text("Streaming from \(host)") Spacer() if let onRefresh = networkStatus?.onRefreshLibrary { Button("Refresh Library") { onRefresh() } .buttonStyle(.borderless) } if let onDisconnect = networkStatus?.onDisconnect { Button("Disconnect") { onDisconnect() } .buttonStyle(.borderless) } } ``` If the code uses `networkStatus.statusMessage`, the `statusMessage` property added in Task 9 already handles all modes. - [ ] **Step 2: Disable editing in streaming client mode** Anywhere the UI allows playlist creation, track deletion, or library modification, check `networkStatus?.isRemoteMode`. This property already returns `true` for `.streamClient` (set in Task 9). Existing guards for remote mode should cover streaming client mode automatically. - [ ] **Step 3: Build and verify** ```bash xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 ``` Expected: BUILD SUCCEEDED - [ ] **Step 4: Commit** ```bash git add Music/ContentView.swift git commit -m "feat: update ContentView for streaming status display" ``` --- ## Task 18: End-to-End Integration Test **Files:** - Create: `MusicTests/StreamingIntegrationTests.swift` Full round-trip test: start server, connect client, verify DB download, request HLS manifest. - [ ] **Step 1: Write integration tests** `MusicTests/StreamingIntegrationTests.swift`: ```swift import Testing import Foundation @testable import Music @testable import MusicShared @MainActor struct StreamingIntegrationTests { static let testAPIKey = "integration-test-key" // Full flow: start server, authenticate, download DB, request manifest. // Steps: // 1. Create an in-memory DB and insert a test track // 2. Start StreamingServer on a random port // 3. Authenticate via GET /auth // 4. Download DB via GET /db // 5. Open the downloaded DB and verify the track is present // 6. Request HLS manifest for the track (will fail since file doesn't exist, but verifies routing) @Test func fullConnectionFlow() async throws { // 1. Setup let db = try DatabaseService(inMemory: true) var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song") try db.insert(&track) let trackId = try #require(track.id) // 2. Start server let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) let baseURL = "http://localhost:\(port)" // 3. Authenticate var authReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.auth)")!) authReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") let (authData, authResp) = try await URLSession.shared.data(for: authReq) let authHTTP = try #require(authResp as? HTTPURLResponse) #expect(authHTTP.statusCode == 200) let authResponse = try JSONDecoder().decode(AuthResponse.self, from: authData) #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) // 4. Download DB var dbReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.db)")!) dbReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") let (dbData, dbResp) = try await URLSession.shared.data(for: dbReq) let dbHTTP = try #require(dbResp as? HTTPURLResponse) #expect(dbHTTP.statusCode == 200) #expect(dbData.count > 0) // 5. Verify downloaded DB contains the track let tempPath = FileManager.default.temporaryDirectory .appendingPathComponent("integration_test_\(UUID().uuidString).sqlite").path defer { try? FileManager.default.removeItem(atPath: tempPath) } try dbData.write(to: URL(fileURLWithPath: tempPath)) let downloadedDb = try DatabaseService(path: tempPath) let tracks = try downloadedDb.fetchTracks(search: "", sortColumn: "title", ascending: true) #expect(tracks.count == 1) #expect(tracks[0].title == "Test Song") // 6. Request manifest (routing works, but file won't exist so expect 500) var manifestReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.trackManifest(trackId: trackId))")!) manifestReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") let (_, manifestResp) = try await URLSession.shared.data(for: manifestReq) let manifestHTTP = try #require(manifestResp as? HTTPURLResponse) // File at /tmp/test.mp3 doesn't exist → server returns 500 // This is expected — we're testing routing, not actual file serving #expect(manifestHTTP.statusCode == 500 || manifestHTTP.statusCode == 200) } // Verifies that requests without auth get 401. @Test func unauthenticatedRequestsRejected() async throws { let db = try DatabaseService(inMemory: true) let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 401) } // Verifies that wrong API key gets 401. @Test func wrongApiKeyRejected() async throws { let db = try DatabaseService(inMemory: true) let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) try await server.start() defer { Task { await server.stop() } } let port = try #require(server.actualPort) var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) request.setValue("Bearer wrong-key", forHTTPHeaderField: "Authorization") let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 401) } } ``` - [ ] **Step 2: Run integration tests** ```bash xcodebuild -project Music.xcodeproj -scheme Music test \ -only-testing:MusicTests/StreamingIntegrationTests 2>&1 | tail -20 ``` Expected: all tests pass. - [ ] **Step 3: Run the full test suite** ```bash xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 ``` Expected: ALL tests pass — no regressions. - [ ] **Step 4: Commit** ```bash git add MusicTests/StreamingIntegrationTests.swift Music.xcodeproj git commit -m "test: add end-to-end streaming integration tests" ``` --- ## Summary | Task | What | Files | |------|------|-------| | 1 | Create MusicShared package, move RemoteProtocol | Package, protocol, tests | | 2 | Add AppRole enum | `AppRole.swift` | | 3 | Add constants, routes, API models | 3 files in MusicShared | | 4 | Add HLSManifestGenerator (TDD) | Generator + tests | | 5 | Define PlaybackProvider protocol | `PlaybackProvider.swift` | | 6 | Conform AudioService to PlaybackProvider | 1-line change | | 7 | Create RemotePlaybackProvider | New provider | | 8 | Refactor PlayerViewModel + update tests | ViewModel + tests | | 9 | Update NetworkStatus for streaming modes | `NetworkStatus.swift` | | 10 | Add Hummingbird dependency | `Package.swift` | | 11 | Create HLSSegmenter (TDD) | Segmenter + tests | | 12 | Create StreamingServer | Hummingbird routes + tests | | 13 | Create TunnelManager | cloudflared process | | 14 | Create StreamingPlaybackProvider | AVPlayer + HLS | | 15 | Create StreamingClient | HTTP + WebSocket client | | 16 | Wire up MusicApp.swift | Mode switching, menus, sheets | | 17 | Update ContentView | Status bar, edit guards | | 18 | Integration tests | End-to-end round-trip |