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