You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-27-music-streaming.md

94 KiB

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.swiftMusicShared/Sources/MusicShared/RemoteProtocol.swift

  • Move: MusicTests/RemoteProtocolTests.swiftMusicShared/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

mkdir -p MusicShared/Sources/MusicShared
mkdir -p MusicShared/Tests/MusicSharedTests

Create MusicShared/Package.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
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:

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

xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 8: Commit
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:

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
cd MusicShared && swift build

Expected: build succeeds.

  • Step 3: Commit
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

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
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
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
cd MusicShared && swift build

Expected: build succeeds.

  • Step 5: Commit
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:

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
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:

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..<count {
            let range = segmentTimeRange(index: i, trackDuration: duration, segmentDuration: segmentDuration)
            lines.append(String(format: "#EXTINF:%.3f,", range.duration))
            lines.append("segments/\(i).mp3")
        }

        lines.append("#EXT-X-ENDLIST")
        lines.append("")
        return lines.joined(separator: "\n")
    }

    public static func segmentCount(duration: Double, segmentDuration: Double) -> 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
cd MusicShared && swift test

Expected: all HLSManifestGenerator tests pass.

  • Step 5: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
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:

final class AudioService {

to:

final class AudioService: PlaybackProvider {

Add this method anywhere in the class body:

func urlForTrack(_ track: Track) -> URL? {
    URL(string: track.fileURL)
}
  • Step 2: Build to verify conformance
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED — AudioService now satisfies all PlaybackProvider requirements.

  • Step 3: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
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:

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:

let player = PlayerViewModel(audio: audioService, db: db)

to:

let player = PlayerViewModel(provider: audioService, db: db)

Update enterRemoteMode() (around line 197) — replace:

player.enterRemoteMode(client: remoteClient)

with:

let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient)
player.setProvider(remoteProvider)

Update exitRemoteMode() (around line 218) — replace:

playerVM?.exitRemoteMode()

with:

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:

let vm = PlayerViewModel(audio: AudioService(), db: nil)

becomes:

let vm = PlayerViewModel(provider: AudioService(), db: nil)
  • Step 4: Build and run tests
xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20

Expected: all PlayerViewModelTests pass, build succeeds.

  • Step 5: Commit
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:

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

xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 4: Commit
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:

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
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:

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
xcodebuild -project Music.xcodeproj -scheme Music test \
  -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -20

Expected: all HLSSegmenterTests pass.

  • Step 5: Commit
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:

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
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:

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<Void, Error>?
    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
xcodebuild -project Music.xcodeproj -scheme Music test \
  -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -20

Expected: all StreamingServerTests pass.

  • Step 5: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
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):

@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:

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:

// 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():

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:

.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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 7: Run all tests to verify nothing is broken
xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20

Expected: all tests pass.

  • Step 8: Commit
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:

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
xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 4: Commit
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:

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
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
xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20

Expected: ALL tests pass — no regressions.

  • Step 4: Commit
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