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.swift→MusicShared/Sources/MusicShared/RemoteProtocol.swift -
Move:
MusicTests/RemoteProtocolTests.swift→MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift -
Modify:
Music/Remote/HostServer.swift(addimport MusicShared) -
Modify:
Music/Remote/RemoteClient.swift(addimport MusicShared) -
Modify:
Music/ViewModels/PlayerViewModel.swift(addimport MusicShared) -
Modify:
Music/Remote/NDJSONTransport.swift(addimport MusicSharedif 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:
- Drag the
MusicShared/folder into the project navigator (at the root level). - Xcode detects it as a local Swift package automatically.
- Select the Music app target → General → "Frameworks, Libraries, and Embedded Content" → click
+→ selectMusicSharedlibrary. - Delete the original
Music/Remote/RemoteProtocol.swiftfrom the project (Move to Trash). - Delete
MusicTests/RemoteProtocolTests.swiftfrom the project (Move to Trash).
- Step 6: Add
import MusicSharedto 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 |