From 98f11658ad956e3fa66f63674215c86a81b418f0 Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 28 May 2026 23:31:05 +0200 Subject: [PATCH] first working instance --- Music.xcodeproj/project.pbxproj | 44 +- .../xcshareddata/swiftpm/Package.resolved | 218 +- .../xcshareddata/xcschemes/Music.xcscheme | 7 +- Music/ContentView.swift | 18 + Music/Music.entitlements | 12 +- Music/MusicApp.swift | 146 +- Music/Protocols/PlaybackProvider.swift | 25 + Music/Providers/RemotePlaybackProvider.swift | 100 + .../Providers/StreamingPlaybackProvider.swift | 303 ++ Music/Remote/HostServer.swift | 1 + Music/Remote/NetworkStatus.swift | 30 +- Music/Remote/RemoteClient.swift | 1 + Music/Remote/RemoteProtocol.swift | 172 - Music/Services/AudioService.swift | 17 +- Music/Streaming/HLSSegmenter.swift | 80 + Music/Streaming/StreamingClient.swift | 219 ++ .../Streaming/StreamingConnectionSheet.swift | 83 + Music/Streaming/StreamingServer.swift | 308 ++ Music/Streaming/TunnelManager.swift | 125 + Music/ViewModels/PlayerViewModel.swift | 134 +- Music/Views/PlayerControlsView.swift | 29 +- MusicShared/Package.resolved | 221 ++ MusicShared/Package.swift | 17 + .../Sources/MusicShared/APIModels.swift | 8 +- MusicShared/Sources/MusicShared/Exports.swift | 3 + .../MusicShared/HLSManifestGenerator.swift | 5 +- .../MusicShared/StreamingConstants.swift | 2 +- .../Sources/MusicShared/StreamingRoutes.swift | 4 + MusicTests/HLSSegmenterTests.swift | 89 + MusicTests/HostServerIntegrationTests.swift | 3 +- .../PlaybackPipelineTeardownTests.swift | 71 + MusicTests/PlayerViewModelTests.swift | 71 +- MusicTests/RemoteProtocolTests.swift | 132 - MusicTests/StreamingIntegrationTests.swift | 204 ++ MusicTests/StreamingServerTests.swift | 147 + .../plans/2026-05-26-remote-mode.md | 2263 +++++++++++++ .../plans/2026-05-27-music-streaming.md | 3010 +++++++++++++++++ .../2026-05-26-music-streaming-design.md | 276 ++ .../specs/2026-05-26-remote-mode-design.md | 270 ++ 39 files changed, 8460 insertions(+), 408 deletions(-) create mode 100644 Music/Protocols/PlaybackProvider.swift create mode 100644 Music/Providers/RemotePlaybackProvider.swift create mode 100644 Music/Providers/StreamingPlaybackProvider.swift delete mode 100644 Music/Remote/RemoteProtocol.swift create mode 100644 Music/Streaming/HLSSegmenter.swift create mode 100644 Music/Streaming/StreamingClient.swift create mode 100644 Music/Streaming/StreamingConnectionSheet.swift create mode 100644 Music/Streaming/StreamingServer.swift create mode 100644 Music/Streaming/TunnelManager.swift create mode 100644 MusicShared/Package.resolved create mode 100644 MusicShared/Sources/MusicShared/Exports.swift create mode 100644 MusicTests/HLSSegmenterTests.swift create mode 100644 MusicTests/PlaybackPipelineTeardownTests.swift delete mode 100644 MusicTests/RemoteProtocolTests.swift create mode 100644 MusicTests/StreamingIntegrationTests.swift create mode 100644 MusicTests/StreamingServerTests.swift create mode 100644 docs/superpowers/plans/2026-05-26-remote-mode.md create mode 100644 docs/superpowers/plans/2026-05-27-music-streaming.md create mode 100644 docs/superpowers/specs/2026-05-26-music-streaming-design.md create mode 100644 docs/superpowers/specs/2026-05-26-remote-mode-design.md diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index 420d273..c67a2cf 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = C46B2CBF2FC2449900F95A24 /* GRDB */; }; C46B2CC22FC2449900F95A24 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = C46B2CC12FC2449900F95A24 /* GRDB */; }; + C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */ = {isa = PBXBuildFile; productRef = C46CC4682FC6ED47000BD495 /* MusicShared */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -74,6 +75,7 @@ buildActionMask = 2147483647; files = ( C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */, + C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -101,6 +103,7 @@ C46B2C8F2FC2448700F95A24 /* Music */, C46B2C9D2FC2448800F95A24 /* MusicTests */, C46B2CA72FC2448800F95A24 /* MusicUITests */, + C46C8F952FC6D951000BD495 /* Frameworks */, C46B2C8E2FC2448700F95A24 /* Products */, ); sourceTree = ""; @@ -115,6 +118,13 @@ name = Products; sourceTree = ""; }; + C46C8F952FC6D951000BD495 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -136,6 +146,7 @@ name = Music; packageProductDependencies = ( C46B2CBF2FC2449900F95A24 /* GRDB */, + C46CC4682FC6ED47000BD495 /* MusicShared */, ); productName = Music; productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */; @@ -222,6 +233,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */, + C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */, ); preferredProjectObjectVersion = 77; productRefGroup = C46B2C8E2FC2448700F95A24 /* Products */; @@ -331,6 +343,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 895UN7FKH2; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -356,6 +369,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -395,6 +409,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 895UN7FKH2; ENABLE_NS_ASSERTIONS = NO; @@ -413,6 +428,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -427,11 +443,15 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 21; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Mumu; @@ -468,11 +488,15 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 21; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 526E96RFNP; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Mumu; @@ -505,6 +529,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 895UN7FKH2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; @@ -526,6 +551,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 895UN7FKH2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; @@ -546,6 +572,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 895UN7FKH2; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -565,6 +592,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 895UN7FKH2; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -620,6 +648,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = MusicShared; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */ = { isa = XCRemoteSwiftPackageReference; @@ -642,6 +677,11 @@ package = C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */; productName = GRDB; }; + C46CC4682FC6ED47000BD495 /* MusicShared */ = { + isa = XCSwiftPackageProductDependency; + package = C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */; + productName = MusicShared; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C46B2C852FC2448700F95A24 /* Project object */; diff --git a/Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 264bf7e..0d337ce 100644 --- a/Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c", + "originHash" : "b77ffc6518242a68c7f6b38dd3ba60b2719cc56c35b27391784d4b0d94e08b32", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -9,6 +18,213 @@ "revision" : "36e30a6f1ef10e4194f6af0cff90888526f0c115", "version" : "7.10.0" } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "a2ed0a0294de56e18ba55344eafc801a7a385a90", + "version" : "2.22.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", + "version" : "1.13.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme b/Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme index 3bccbe6..54abed2 100644 --- a/Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme +++ b/Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme @@ -1,7 +1,7 @@ + LastUpgradeVersion = "2630" + version = "1.3"> @@ -68,8 +68,7 @@ + skipped = "NO"> - com.apple.security.app-sandbox - - com.apple.security.get-task-allow - - com.apple.security.files.user-selected.read-only - com.apple.security.files.bookmarks.app-scope - com.apple.security.network.client - - com.apple.security.network.server + com.apple.security.files.user-selected.read-only - com.apple.security.device.audio-input + com.apple.security.get-task-allow diff --git a/Music/MusicApp.swift b/Music/MusicApp.swift index d6d1af5..3269287 100644 --- a/Music/MusicApp.swift +++ b/Music/MusicApp.swift @@ -1,3 +1,4 @@ +import MusicShared import SwiftUI @main @@ -14,6 +15,12 @@ struct MusicApp: App { @State private var hostServer: HostServer? @State private var remoteClient = RemoteClient() @State private var showConnectionSheet = false + @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") ?? "" var body: some Scene { WindowGroup { @@ -52,6 +59,15 @@ struct MusicApp: App { .sheet(isPresented: $showConnectionSheet) { ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet) } + .sheet(isPresented: $showStreamingSettings) { + StreamingConnectionSheet( + hostURL: $streamHostURL, + apiKey: $streamAPIKey, + client: streamingClient, + isPresented: $showStreamingSettings, + onConnect: { enterStreamingClientMode() } + ) + } } .commands { CommandGroup(after: .newItem) { @@ -80,6 +96,31 @@ struct MusicApp: App { remoteClient.startDiscovery() } .disabled(hostServer?.isHosting ?? false) + + 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() + } + } } } } @@ -95,7 +136,7 @@ struct MusicApp: App { let db = try DatabaseService(path: dbPath) let scanner = ScannerService(db: db) let library = LibraryViewModel(db: db) - let player = PlayerViewModel(audio: audioService, db: db) + let player = PlayerViewModel(provider: audioService, db: db) let playlist = PlaylistViewModel(db: db) self.dbService = db @@ -201,7 +242,8 @@ struct MusicApp: App { self.libraryVM = LibraryViewModel(db: remoteDb) self.playlistVM = PlaylistViewModel(db: remoteDb) - player.enterRemoteMode(client: remoteClient) + let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient) + player.setProvider(remoteProvider) player.trackResolver = { trackId in self.libraryVM?.tracks.first(where: { $0.id == trackId }) } @@ -216,7 +258,7 @@ struct MusicApp: App { } private func exitRemoteMode() { - playerVM?.exitRemoteMode() + playerVM?.setProvider(audioService) remoteClient.onPlaybackState = nil guard let db = dbService else { return } self.libraryVM = LibraryViewModel(db: db) @@ -224,6 +266,94 @@ struct MusicApp: App { try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath) } + // MARK: - Streaming Host + + private func startStreamingServer() { + guard let db = dbService else { return } + + 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 + 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 = { + 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) + } + } + private func computeNetworkStatus() -> NetworkStatus? { if remoteClient.connectionState.isConnected { let hostName: String @@ -237,6 +367,16 @@ struct MusicApp: App { 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: { self.exitStreamingClientMode() }, + onRefreshLibrary: { [streamingClient] in streamingClient.requestDBRefresh() } + ) + } return nil } } diff --git a/Music/Protocols/PlaybackProvider.swift b/Music/Protocols/PlaybackProvider.swift new file mode 100644 index 0000000..2fa4b7f --- /dev/null +++ b/Music/Protocols/PlaybackProvider.swift @@ -0,0 +1,25 @@ +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 } + + 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) +} diff --git a/Music/Providers/RemotePlaybackProvider.swift b/Music/Providers/RemotePlaybackProvider.swift new file mode 100644 index 0000000..79a720b --- /dev/null +++ b/Music/Providers/RemotePlaybackProvider.swift @@ -0,0 +1,100 @@ +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 + } + + 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?() + } +} diff --git a/Music/Providers/StreamingPlaybackProvider.swift b/Music/Providers/StreamingPlaybackProvider.swift new file mode 100644 index 0000000..b3f4ebc --- /dev/null +++ b/Music/Providers/StreamingPlaybackProvider.swift @@ -0,0 +1,303 @@ +import AVFoundation +import Foundation +import Observation +import MusicShared +import os + +@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 playbackError: String? + var isBuffering = false + + var onTrackFinished: (() -> Void)? + var onPlaybackStateChanged: (() -> Void)? + + private(set) var player: AVPlayer? + private var timeObserver: Any? + private var endObserver: NSObjectProtocol? + private var failedObserver: NSObjectProtocol? + private var statusObservation: NSKeyValueObservation? + private var timeControlObservation: NSKeyValueObservation? + private var seekInProgress = false + private var pendingSeekTime: Double? + private var playTask: Task? + + private let hostURL: String + private let apiKey: String + private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingPlayback") + + 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 } + // StreamingRoutes.trackFile already includes ?id=TRACKID + return URL(string: "\(hostURL)\(StreamingRoutes.trackFile(trackId: trackId))&token=\(apiKey)") + } + + func play(url: URL) { + cleanup() + playbackError = nil + isBuffering = true + isPlaying = true + onPlaybackStateChanged?() + + playTask = Task { [weak self] in + guard let self else { return } + + // Pre-flight: verify the URL is reachable before handing to AVPlayer + do { + var request = URLRequest(url: url) + request.httpMethod = "GET" + // Only fetch first byte to avoid downloading the whole file + request.setValue("bytes=0-0", forHTTPHeaderField: "Range") + let (data, response) = try await URLSession.shared.data(for: request) + + guard !Task.isCancelled else { return } + + if let http = response as? HTTPURLResponse, http.statusCode != 200 && http.statusCode != 206 { + let body = String(data: data.prefix(500), encoding: .utf8) ?? "" + let msg: String + switch http.statusCode { + case 401: msg = "Server rejected authentication" + case 404: msg = "Route not found (HTTP 404)" + case 500...599: msg = "Server error (\(http.statusCode))" + default: msg = "HTTP \(http.statusCode)" + } + self.logger.error("\(msg, privacy: .public) — body: \(body, privacy: .public)") + self.playbackError = msg + self.isPlaying = false + self.isBuffering = false + self.onPlaybackStateChanged?() + return + } + } catch { + guard !Task.isCancelled else { return } + self.logger.error("Network error: \(error.localizedDescription, privacy: .public)") + self.playbackError = "Network: \(error.localizedDescription)" + self.isPlaying = false + self.isBuffering = false + self.onPlaybackStateChanged?() + return + } + + guard !Task.isCancelled else { return } + self.logger.info("Pre-flight OK, starting AVPlayer for \(url.absoluteString, privacy: .public)") + self.startAVPlayer(url: url) + } + } + + func startAVPlayer(url: URL) { + let asset = AVURLAsset(url: url) + let item = AVPlayerItem(asset: asset) + player = AVPlayer(playerItem: item) + player?.volume = volume + + statusObservation = item.observe(\.status, options: [.new]) { [weak self] (playerItem: AVPlayerItem, _) in + DispatchQueue.main.async { + guard let self else { return } + switch playerItem.status { + case .failed: + let msg = playerItem.error?.localizedDescription ?? "Unknown playback error" + self.logger.error("AVPlayer failed: \(msg, privacy: .public)") + self.playbackError = msg + self.isPlaying = false + self.isBuffering = false + self.onPlaybackStateChanged?() + case .readyToPlay: + self.logger.info("Stream ready") + self.playbackError = nil + self.isBuffering = false + self.onPlaybackStateChanged?() + default: + break + } + } + } + + timeControlObservation = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] (avPlayer: AVPlayer, _) in + DispatchQueue.main.async { + guard let self else { return } + switch avPlayer.timeControlStatus { + case .waitingToPlayAtSpecifiedRate: + self.isBuffering = true + case .playing: + self.isBuffering = false + case .paused: + self.isBuffering = false + @unknown default: + break + } + self.onPlaybackStateChanged?() + } + } + + 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?() + } + + failedObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { [weak self] notification in + let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + let msg = error?.localizedDescription ?? "Playback interrupted" + self?.playbackError = msg + self?.isPlaying = false + self?.isBuffering = false + self?.onPlaybackStateChanged?() + } + + player?.play() + } + + 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 + 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 + isBuffering = false + playbackError = nil + 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() { + playTask?.cancel() + playTask = nil + statusObservation?.invalidate() + statusObservation = nil + timeControlObservation?.invalidate() + timeControlObservation = nil + if let obs = timeObserver { + player?.removeTimeObserver(obs) + timeObserver = nil + } + if let obs = endObserver { + NotificationCenter.default.removeObserver(obs) + endObserver = nil + } + if let obs = failedObserver { + NotificationCenter.default.removeObserver(obs) + failedObserver = nil + } + player?.pause() + // Dissociate the item from the player to release its decode/render pipeline. + // Setting `player = nil` alone does NOT free the pipeline (ARC tears it down + // asynchronously); without this, pipelines accumulate across track switches + // until a new player can't acquire a decode session and fails with -16044. + player?.replaceCurrentItem(with: nil) + player = nil + } + + nonisolated deinit {} +} diff --git a/Music/Remote/HostServer.swift b/Music/Remote/HostServer.swift index acf8801..c65ff11 100644 --- a/Music/Remote/HostServer.swift +++ b/Music/Remote/HostServer.swift @@ -1,4 +1,5 @@ import Foundation +import MusicShared import Network import os diff --git a/Music/Remote/NetworkStatus.swift b/Music/Remote/NetworkStatus.swift index cdadf5e..bf0587c 100644 --- a/Music/Remote/NetworkStatus.swift +++ b/Music/Remote/NetworkStatus.swift @@ -4,6 +4,8 @@ struct NetworkStatus { enum Mode { case hosting(connectedRemote: String?) case remote(hostName: String) + case streamHosting(tunnelURL: String?) + case streamClient(hostName: String) } var mode: Mode @@ -11,7 +13,31 @@ struct NetworkStatus { var onRefreshLibrary: (() -> Void)? var isRemoteMode: Bool { - if case .remote = mode { return true } - return false + 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)" + } } } diff --git a/Music/Remote/RemoteClient.swift b/Music/Remote/RemoteClient.swift index 7364dce..a5c7a92 100644 --- a/Music/Remote/RemoteClient.swift +++ b/Music/Remote/RemoteClient.swift @@ -1,4 +1,5 @@ import Foundation +import MusicShared import Network import os diff --git a/Music/Remote/RemoteProtocol.swift b/Music/Remote/RemoteProtocol.swift deleted file mode 100644 index e224328..0000000 --- a/Music/Remote/RemoteProtocol.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation - -// MARK: - Protocol Version - -/// Current version of the remote control wire protocol. -nonisolated let RemoteProtocolVersion: Int = 1 - -// MARK: - Supporting Types - -/// Snapshot of the host's playback state, sent to remote clients. -nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { - var trackId: Int64? - var isPlaying: Bool - var currentTime: Double - var duration: Double - var volume: Float - var isShuffled: Bool -} - -/// Exchanged during connection setup to agree on protocol version. -nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { - var protocolVersion: Int - var appVersion: String -} - -// MARK: - RemoteCommand - -/// Commands sent from a remote client to the host. -/// Wire format: `{"type":"","payload":{...}}` (payload omitted for cases with no associated values). -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 - } - - // Payload structs for cases with associated values - private struct PlayPayload: Codable { - var trackId: Int64 - var queueIds: [Int64] - } - - private struct SeekPayload: Codable { - var position: Double - } - - private struct VolumePayload: Codable { - var level: Float - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .play(let trackId, let queueIds): - try container.encode(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) - } - } - - 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 - -/// Events sent from the host to remote clients. -/// Wire format: `{"type":"","payload":{...}}` (payload omitted for cases with no associated values). -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 - } - - 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) - } - } - - 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) - } - } -} diff --git a/Music/Services/AudioService.swift b/Music/Services/AudioService.swift index cbecffe..4a9af02 100644 --- a/Music/Services/AudioService.swift +++ b/Music/Services/AudioService.swift @@ -8,7 +8,7 @@ import Observation // actor-isolated deinits — it simply nils out the player reference so ARC handles // cleanup safely without crossing actor boundaries. @Observable -final class AudioService { +final class AudioService: PlaybackProvider { var isPlaying = false var currentTime: Double = 0 var duration: Double = 0 @@ -16,7 +16,7 @@ final class AudioService { didSet { player?.volume = volume } } - private var player: AVPlayer? + private(set) var player: AVPlayer? private var timeObserver: Any? private var endObserver: NSObjectProtocol? @@ -145,6 +145,14 @@ final class AudioService { max(0, min(time, duration)) } + func urlForTrack(_ track: Track) -> URL? { + URL(string: track.fileURL) + } + + func setVolume(_ level: Float) { + volume = level + } + func stop() { cleanup() isPlaying = false @@ -163,6 +171,11 @@ final class AudioService { endObserver = nil } player?.pause() + // Dissociate the item from the player to release its decode/render pipeline. + // Setting `player = nil` alone does NOT free the pipeline (ARC tears it down + // asynchronously); without this, pipelines accumulate across track switches + // until a new player can't acquire a decode session and fails with -16044. + player?.replaceCurrentItem(with: nil) player = nil } diff --git a/Music/Streaming/HLSSegmenter.swift b/Music/Streaming/HLSSegmenter.swift new file mode 100644 index 0000000..ed32291 --- /dev/null +++ b/Music/Streaming/HLSSegmenter.swift @@ -0,0 +1,80 @@ +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 + + // 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?) +} diff --git a/Music/Streaming/StreamingClient.swift b/Music/Streaming/StreamingClient.swift new file mode 100644 index 0000000..00f4f05 --- /dev/null +++ b/Music/Streaming/StreamingClient.swift @@ -0,0 +1,219 @@ +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(set) var serverCapabilities: [String] = [] + + private var hostURL: String = "" + private var apiKey: String = "" + private var webSocketTask: URLSessionWebSocketTask? + private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingClient") + + 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 + + do { + let authResponse = try await authenticate() + logger.info("Authenticated with host: \(authResponse.hostName) (protocol v\(authResponse.protocolVersion), capabilities: \(authResponse.capabilities ?? []))") + serverCapabilities = authResponse.capabilities ?? [] + + state = .downloadingDB + try await downloadDatabase() + logger.info("Database downloaded") + + 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 + + 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 + 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" + } + } +} diff --git a/Music/Streaming/StreamingConnectionSheet.swift b/Music/Streaming/StreamingConnectionSheet.swift new file mode 100644 index 0000000..f0cf2e4 --- /dev/null +++ b/Music/Streaming/StreamingConnectionSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct StreamingConnectionSheet: View { + @Binding var hostURL: String + @Binding var apiKey: String + @Bindable var client: StreamingClient + @Binding var isPresented: Bool + var onConnect: () -> Void + + private var isConnecting: Bool { + switch client.state { + case .connecting, .downloadingDB: return true + default: return false + } + } + + var body: some View { + VStack(spacing: 16) { + Text("Connect to Streaming Host") + .font(.headline) + + TextField("Host URL (e.g. https://music.example.com)", text: $hostURL) + .textFieldStyle(.roundedBorder) + .disabled(isConnecting) + + SecureField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + .disabled(isConnecting) + + statusView + + HStack { + Button("Cancel") { + if isConnecting { + client.disconnect() + } + isPresented = false + } + + Button(isConnecting ? "Connecting..." : "Connect") { + onConnect() + } + .disabled(hostURL.isEmpty || apiKey.isEmpty || isConnecting) + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 420) + .onChange(of: client.state) { _, newState in + if newState.isConnected { + isPresented = false + } + } + } + + @ViewBuilder + private var statusView: some View { + switch client.state { + case .connecting: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Authenticating...").foregroundStyle(.secondary) + } + case .downloadingDB: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Downloading library...").foregroundStyle(.secondary) + } + case .error(let message): + Text(message) + .foregroundStyle(.red) + .font(.system(size: 12)) + case .connected: + if !client.serverCapabilities.contains("file-streaming") { + Label("Host needs update — streaming may not work", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.system(size: 12)) + } + default: + EmptyView() + } + } +} diff --git a/Music/Streaming/StreamingServer.swift b/Music/Streaming/StreamingServer.swift new file mode 100644 index 0000000..a41dd17 --- /dev/null +++ b/Music/Streaming/StreamingServer.swift @@ -0,0 +1,308 @@ +import Foundation +import MusicShared +import os + +// MARK: - StreamingServer + +/// Hummingbird-based HTTP server that exposes the music library for streaming. +/// +/// Endpoints: +/// - `GET /auth` — validate API key, return `AuthResponse` JSON +/// - `GET /db` — backup SQLite and serve the file +/// - `GET /tracks/:trackId/stream.m3u8` — HLS manifest +/// - `GET /tracks/:trackId/segments/:index.mp3` — audio segment data +/// +/// WebSocket at `/ws` is planned but deferred until the upstream +/// swift-websocket NIOSSL dependency issue is resolved. +@MainActor +@Observable +final class StreamingServer { + + // MARK: - Observable State + + var isRunning = false + private(set) var actualPort: Int? + + // MARK: - Dependencies + + private let db: DatabaseService + private let apiKey: String + private let requestedPort: Int + + /// Cache of HLSSegmenter instances keyed by track ID to avoid re-parsing AVAsset metadata. + private let segmenterCache = SegmenterCache() + + /// Continuation-based hook so `start()` can wait for the OS-assigned port. + private var portContinuation: CheckedContinuation? + + /// Task running the Hummingbird application; cancelled on `stop()`. + private var serverTask: Task? + + // MARK: - Init + + init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) { + self.db = db + self.apiKey = apiKey + self.requestedPort = port + } + + // MARK: - Lifecycle + + func start() async throws { + guard !isRunning else { return } + + // Capture immutable/sendable values for use in Sendable closures + let db = self.db + let apiKey = self.apiKey + let port = self.requestedPort + let segmenterCache = self.segmenterCache + + let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingServer") + + // Build the HTTP router + let router = Router() + + // GET /ping — diagnostic endpoint + router.get("ping") { _, _ -> Response in + Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "pong"))) + } + + // GET /ping/:value — diagnostic for parameterized routes + router.get("ping/:value") { _, context -> Response in + let value = context.parameters.get("value") ?? "nil" + return Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "echo: \(value)"))) + } + + // GET /auth + router.get("auth") { request, _ -> Response in + // Validate auth + guard let authHeader = request.headers[.authorization], + authHeader == "Bearer \(apiKey)" else { + return Response(status: .unauthorized) + } + + let hostName = Host.current().localizedName ?? "Music Server" + let authResponse = AuthResponse( + hostName: hostName, + protocolVersion: StreamingConstants.protocolVersion, + capabilities: ["file-streaming"] + ) + let data = try JSONEncoder().encode(authResponse) + return Response( + status: .ok, + headers: [.contentType: "application/json"], + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } + + // GET /db + router.get("db") { [db] request, _ -> Response in + // Validate auth + guard let authHeader = request.headers[.authorization], + authHeader == "Bearer \(apiKey)" else { + return Response(status: .unauthorized) + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sqlite") + defer { try? FileManager.default.removeItem(at: tempURL) } + + try db.backup(to: tempURL.path) + let data = try Data(contentsOf: tempURL) + + return Response( + status: .ok, + headers: [.contentType: "application/octet-stream"], + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } + + // GET /file?id=TRACKID&token=APIKEY — direct file streaming (progressive download) + router.get("file") { [db] request, _ -> Response in + let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)" + let hasToken = request.uri.queryParameters.get("token") == apiKey + guard hasBearer || hasToken else { + return Response(status: .unauthorized) + } + + guard let idString = request.uri.queryParameters.get("id"), + let trackId = Int64(idString) else { + throw HTTPError(.badRequest, message: "Missing or invalid 'id' parameter") + } + + let tracks = try db.fetchTracksByIds([trackId]) + guard let track = tracks.first else { + throw HTTPError(.notFound, message: "Track \(trackId) not found") + } + + let fileURL = resolveStoredFileURL(track.fileURL) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw HTTPError(.notFound, message: "File not found on disk") + } + + let data = try Data(contentsOf: fileURL) + let contentType = Self.audioContentType(for: fileURL.pathExtension) + + return Response( + status: .ok, + headers: [ + .contentType: contentType, + .contentLength: String(data.count), + ], + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } + + // GET /tracks/:trackId/stream.m3u8 + router.get("tracks/:trackId/stream.m3u8") { [db, segmenterCache] request, context -> Response in + // Validate auth + guard let authHeader = request.headers[.authorization], + authHeader == "Bearer \(apiKey)" else { + return Response(status: .unauthorized) + } + + let trackId = try context.parameters.require("trackId", as: Int64.self) + + let segmenter = try await segmenterCache.segmenter(for: trackId, db: db) + let manifest = HLSManifestGenerator.manifest( + trackId: trackId, + duration: segmenter.duration, + segmentDuration: StreamingConstants.segmentDuration, + token: apiKey + ) + + return Response( + status: .ok, + headers: [.contentType: "application/vnd.apple.mpegurl"], + body: .init(byteBuffer: ByteBuffer(string: manifest)) + ) + } + + // GET /tracks/:trackId/segments/:index + router.get("tracks/:trackId/segments/:index") { [db, segmenterCache] request, context -> Response in + let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)" + let hasToken = request.uri.queryParameters.get("token") == apiKey + guard hasBearer || hasToken else { + return Response(status: .unauthorized) + } + + let trackId = try context.parameters.require("trackId", as: Int64.self) + // The index parameter may include ".mp3" suffix from the URL; strip it + guard var indexString = context.parameters.get("index") else { + throw HTTPError(.badRequest) + } + if indexString.hasSuffix(".mp3") { + indexString = String(indexString.dropLast(4)) + } + guard let index = Int(indexString) else { + throw HTTPError(.badRequest, message: "Invalid segment index") + } + + let segmenter = try await segmenterCache.segmenter(for: trackId, db: db) + guard let data = try await segmenter.segment( + at: index, + segmentDuration: StreamingConstants.segmentDuration + ) else { + throw HTTPError(.notFound) + } + + return Response( + status: .ok, + headers: [.contentType: "audio/mpeg"], + body: .init(byteBuffer: ByteBuffer(bytes: data)) + ) + } + + let app = Application( + router: router, + configuration: .init(address: .hostname("127.0.0.1", port: port)), + onServerRunning: { @Sendable [weak self] channel in + let boundPort = channel.localAddress?.port ?? port + await MainActor.run { + self?.actualPort = boundPort + self?.isRunning = true + self?.portContinuation?.resume(returning: boundPort) + self?.portContinuation = nil + } + } + ) + + // Start in a detached task so start() can return after the port is known + serverTask = Task.detached { + try await app.run() + } + + // Wait for the port to be assigned (important for port: 0 in tests) + let assignedPort = await withCheckedContinuation { (continuation: CheckedContinuation) in + // If the port was already set by onServerRunning (unlikely but possible), + // resume immediately + if let port = self.actualPort { + continuation.resume(returning: port) + } else { + self.portContinuation = continuation + } + } + + self.actualPort = assignedPort + self.isRunning = true + } + + func stop() { + serverTask?.cancel() + serverTask = nil + isRunning = false + actualPort = nil + segmenterCache.clear() + } + + nonisolated private static func audioContentType(for ext: String) -> String { + switch ext.lowercased() { + case "mp3": return "audio/mpeg" + case "m4a", "aac": return "audio/mp4" + case "flac": return "audio/flac" + case "wav": return "audio/wav" + case "ogg": return "audio/ogg" + case "aiff", "aif": return "audio/aiff" + default: return "application/octet-stream" + } + } +} + +// MARK: - SegmenterCache + +/// Thread-safe cache of `HLSSegmenter` instances keyed by track ID. +private final class SegmenterCache: Sendable { + private let storage = OSAllocatedUnfairLock(initialState: [Int64: HLSSegmenter]()) + + func segmenter(for trackId: Int64, db: DatabaseService) throws -> HLSSegmenter { + // Check cache first + if let cached = storage.withLock({ $0[trackId] }) { + return cached + } + // Resolve the track's file URL from the database + let tracks = try db.fetchTracksByIds([trackId]) + guard let track = tracks.first else { + throw HTTPError(.notFound, message: "Track \(trackId) not found") + } + let fileURL = resolveStoredFileURL(track.fileURL) + let segmenter = try HLSSegmenter(fileURL: fileURL) + storage.withLock { $0[trackId] = segmenter } + return segmenter + } + + func clear() { + storage.withLock { $0.removeAll() } + } +} + +/// Reconstructs a filesystem `URL` from the `fileURL` string stored in the +/// database. The scanner persists `url.absoluteString` (e.g. "file:///…"), so it +/// must be parsed as a URL; `URL(fileURLWithPath:)` would treat the whole +/// "file://…" string as a relative path (prepending the CWD) and never resolve +/// the file. Bare/legacy path strings fall back to `URL(fileURLWithPath:)`. +fileprivate func resolveStoredFileURL(_ stored: String) -> URL { + if let url = URL(string: stored), url.isFileURL { + return url + } + return URL(fileURLWithPath: stored) +} diff --git a/Music/Streaming/TunnelManager.swift b/Music/Streaming/TunnelManager.swift new file mode 100644 index 0000000..3b227c5 --- /dev/null +++ b/Music/Streaming/TunnelManager.swift @@ -0,0 +1,125 @@ +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 { + cloudflaredPath != nil + } + + 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 { + try startTunnel( + arguments: ["tunnel", "--url", "http://localhost:\(localPort)"], + logMessage: "Started cloudflared quick tunnel on port \(localPort)", + preserveFailedState: true + ) + } + + func startNamedTunnel(tunnelName: String, localPort: Int) throws { + try startTunnel( + arguments: ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName], + logMessage: "Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)", + preserveFailedState: false + ) + } + + func stop() { + process?.terminate() + process = nil + outputPipe?.fileHandleForReading.readabilityHandler = nil + outputPipe = nil + state = .stopped + logger.info("Stopped cloudflared tunnel") + } + + // MARK: - Private + + private func startTunnel(arguments: [String], logMessage: String, preserveFailedState: Bool) 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 = arguments + + 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] proc in + Task { @MainActor [weak self] in + guard let self else { return } + if case .running = self.state { + // Was running normally — just mark stopped + self.state = .stopped + } else if preserveFailedState { + // Never reached .running — report the exit code as a failure + self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)") + } else { + self.state = .stopped + } + } + } + + try process.run() + self.process = process + self.outputPipe = pipe + logger.info("\(logMessage)") + } + + private func parseOutput(_ output: String) { + 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)") + } + } + } +} diff --git a/Music/ViewModels/PlayerViewModel.swift b/Music/ViewModels/PlayerViewModel.swift index 6e4d50e..23e6a05 100644 --- a/Music/ViewModels/PlayerViewModel.swift +++ b/Music/ViewModels/PlayerViewModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import MusicShared protocol RemoteCommandSender: AnyObject { func sendCommand(_ command: RemoteCommand) @@ -17,37 +18,69 @@ final class PlayerViewModel { private(set) var queue: [Track] = [] private var originalQueue: [Track] = [] - private let audio: AudioService + private var provider: PlaybackProvider private let db: DatabaseService? private var halfwayReported = false - private var remoteClient: RemoteCommandSender? var trackResolver: ((Int64) -> Track?)? - private var isRemote: Bool { remoteClient != nil } + var streamingError: String? { + (provider as? StreamingPlaybackProvider)?.playbackError + } + + var isBuffering: Bool { + (provider as? StreamingPlaybackProvider)?.isBuffering ?? false + } + + private var remoteProvider: RemotePlaybackProvider? { + provider as? RemotePlaybackProvider + } - init(audio: AudioService, db: DatabaseService?) { - self.audio = audio + init(provider: PlaybackProvider, db: DatabaseService?) { + self.provider = provider self.db = db + bindProvider() + } + + // MARK: - Provider Management - audio.onTrackFinished = { [weak self] in + 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() } - - audio.onPlaybackStateChanged = { [weak self] in - self?.syncFromAudio() + provider.onPlaybackStateChanged = { [weak self] in + self?.syncFromProvider() } } - // MARK: - Audio Sync + // MARK: - Provider Sync - private func syncFromAudio() { - guard !isRemote else { return } - isPlaying = audio.isPlaying - if !audio.isScrubbing { - currentTime = audio.currentTime + private func syncFromProvider() { + isPlaying = provider.isPlaying + if !provider.isScrubbing { + currentTime = provider.currentTime + } + // Only adopt the player's duration once it has actually resolved one. + // Progressive HTTP streams never report a valid duration, so keep the + // value seeded from the library database in play(_:). + if provider.duration > 0 { + duration = provider.duration } - duration = audio.duration + volume = provider.volume checkHalfway() } @@ -73,13 +106,16 @@ final class PlayerViewModel { halfwayReported = false isPlaying = true currentTime = 0 + // The player can't determine duration for a progressive HTTP stream, + // so seed the value the library database already knows up front. + duration = track.duration - if let client = remoteClient { + if let remote = remoteProvider { guard let trackId = track.id else { return } - client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id))) + remote.sendPlayCommand(trackId: trackId, queueIds: queue.compactMap(\.id)) } else { - guard let url = URL(string: track.fileURL) else { return } - audio.play(url: url) + guard let url = provider.urlForTrack(track) else { return } + provider.play(url: url) } } @@ -89,41 +125,41 @@ final class PlayerViewModel { func pause() { isPlaying = false - if let client = remoteClient { client.sendCommand(.pause) } else { audio.pause() } + provider.pause() } func resume() { isPlaying = true - if let client = remoteClient { client.sendCommand(.resume) } else { audio.resume() } + provider.resume() } func seek(to position: Double) { currentTime = position - if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.seek(to: position) } + provider.seek(to: position) } func setVolume(_ level: Float) { volume = level - if let client = remoteClient { client.sendCommand(.setVolume(level: level)) } else { audio.volume = level } + provider.setVolume(level) } func beginScrubbing() { - if !isRemote { audio.beginScrubbing() } + provider.beginScrubbing() } func scrub(to position: Double) { currentTime = position - if !isRemote { audio.scrub(to: position) } + provider.scrub(to: position) } func endScrubbing(at position: Double) { currentTime = position - if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) } + provider.endScrubbing(at: position) } func next() { - if let client = remoteClient { - client.sendCommand(.next) + if let remote = remoteProvider { + remote.sendNext() return } guard let idx = currentIndex else { return } @@ -136,8 +172,8 @@ final class PlayerViewModel { } func previous() { - if let client = remoteClient { - client.sendCommand(.previous) + if let remote = remoteProvider { + remote.sendPrevious() return } guard let idx = currentIndex else { return } @@ -147,8 +183,8 @@ final class PlayerViewModel { func toggleShuffle() { isShuffled.toggle() - if let client = remoteClient { - client.sendCommand(.toggleShuffle) + if let remote = remoteProvider { + remote.sendToggleShuffle() return } if isShuffled { @@ -167,37 +203,14 @@ final class PlayerViewModel { duration = 0 currentTrack = nil currentIndex = nil - if !isRemote { audio.stop() } + provider.stop() } - // MARK: - Remote Mode - - func enterRemoteMode(client: RemoteCommandSender) { - audio.stop() - remoteClient = client - currentTrack = nil - currentIndex = nil - isPlaying = false - currentTime = 0 - duration = 0 - queue = [] - originalQueue = [] - } - - func exitRemoteMode() { - remoteClient = nil - trackResolver = nil - currentTrack = nil - currentIndex = nil - isPlaying = false - currentTime = 0 - duration = 0 - queue = [] - originalQueue = [] - } + // MARK: - Remote State func applyRemoteState(_ state: PlaybackStatePayload) { - guard isRemote else { return } + guard let remote = remoteProvider else { return } + remote.applyRemoteState(state) isPlaying = state.isPlaying currentTime = state.currentTime duration = state.duration @@ -226,7 +239,6 @@ final class PlayerViewModel { } private func trackDidFinish() { - guard !isRemote else { return } if let track = currentTrack, let trackId = track.id, !halfwayReported { let newCount = track.playCount + 1 try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) diff --git a/Music/Views/PlayerControlsView.swift b/Music/Views/PlayerControlsView.swift index b7c04e4..41823f9 100644 --- a/Music/Views/PlayerControlsView.swift +++ b/Music/Views/PlayerControlsView.swift @@ -8,6 +8,8 @@ struct PlayerControlsView: View { let duration: Double let volume: Float let isShuffled: Bool + var isBuffering: Bool = false + var streamingError: String? = nil let onPlayPause: () -> Void let onNext: () -> Void let onPrevious: () -> Void @@ -84,13 +86,26 @@ struct PlayerControlsView: View { if let track = currentTrack { VStack(alignment: .leading, spacing: 2) { - Text(track.title) - .font(.system(size: 13, weight: .medium)) - .lineLimit(1) - Text("\(track.artist) — \(track.album)") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 6) { + Text(track.title) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + if isBuffering { + ProgressView() + .controlSize(.mini) + } + } + if let error = streamingError { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(.red) + .lineLimit(1) + } else { + Text("\(track.artist) — \(track.album)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } } diff --git a/MusicShared/Package.resolved b/MusicShared/Package.resolved new file mode 100644 index 0000000..5831595 --- /dev/null +++ b/MusicShared/Package.resolved @@ -0,0 +1,221 @@ +{ + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird.git", + "state" : { + "revision" : "2f407402799c2217df69b01582f3a44856fef012", + "version" : "2.24.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", + "version" : "1.13.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + } + ], + "version" : 2 +} diff --git a/MusicShared/Package.swift b/MusicShared/Package.swift index 15aee18..a384087 100644 --- a/MusicShared/Package.swift +++ b/MusicShared/Package.swift @@ -13,9 +13,26 @@ let package = Package( targets: ["MusicShared"] ), ], + dependencies: [ + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + // Explicit transitive dependencies to work around Xcode SPM linker bug + // where product frameworks fail to link their own transitive deps in + // the build-for-testing action. + .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.20.0"), + .package(url: "https://github.com/apple/swift-service-context.git", from: "1.0.0"), + ], targets: [ .target( name: "MusicShared", + dependencies: [ + .product(name: "Hummingbird", package: "hummingbird"), + // Explicit transitive deps — see comment on package dependencies above. + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOTransportServices", package: "swift-nio-transport-services"), + .product(name: "ServiceContextModule", package: "swift-service-context"), + ], path: "Sources/MusicShared" ), .testTarget( diff --git a/MusicShared/Sources/MusicShared/APIModels.swift b/MusicShared/Sources/MusicShared/APIModels.swift index 813fec4..d978e73 100644 --- a/MusicShared/Sources/MusicShared/APIModels.swift +++ b/MusicShared/Sources/MusicShared/APIModels.swift @@ -3,10 +3,16 @@ import Foundation public struct AuthResponse: Codable, Equatable, Sendable { public var hostName: String public var protocolVersion: Int + public var capabilities: [String]? - public init(hostName: String, protocolVersion: Int) { + public init(hostName: String, protocolVersion: Int, capabilities: [String]? = nil) { self.hostName = hostName self.protocolVersion = protocolVersion + self.capabilities = capabilities + } + + public var supportsDirectFileStreaming: Bool { + capabilities?.contains("file-streaming") ?? false } } diff --git a/MusicShared/Sources/MusicShared/Exports.swift b/MusicShared/Sources/MusicShared/Exports.swift new file mode 100644 index 0000000..cedddb8 --- /dev/null +++ b/MusicShared/Sources/MusicShared/Exports.swift @@ -0,0 +1,3 @@ +// Re-export Hummingbird so the app target can use it +// without adding a separate package dependency. +@_exported import Hummingbird diff --git a/MusicShared/Sources/MusicShared/HLSManifestGenerator.swift b/MusicShared/Sources/MusicShared/HLSManifestGenerator.swift index b4f78fc..98b66d1 100644 --- a/MusicShared/Sources/MusicShared/HLSManifestGenerator.swift +++ b/MusicShared/Sources/MusicShared/HLSManifestGenerator.swift @@ -6,7 +6,7 @@ public enum HLSManifestGenerator: Sendable { public var duration: Double } - public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double) -> String { + public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double, token: String? = nil) -> String { let count = segmentCount(duration: duration, segmentDuration: segmentDuration) let targetDuration = Int(segmentDuration.rounded(.up)) @@ -17,10 +17,11 @@ public enum HLSManifestGenerator: Sendable { "#EXT-X-MEDIA-SEQUENCE:0", ] + let tokenQuery = token.map { "?token=\($0)" } ?? "" for i in 0.. String { + "/file?id=\(trackId)" + } + public static func trackManifest(trackId: Int64) -> String { "/tracks/\(trackId)/stream.m3u8" } diff --git a/MusicTests/HLSSegmenterTests.swift b/MusicTests/HLSSegmenterTests.swift new file mode 100644 index 0000000..22e6561 --- /dev/null +++ b/MusicTests/HLSSegmenterTests.swift @@ -0,0 +1,89 @@ +import Testing +import Foundation +@testable import Music + +@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 #require(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 audio file (M4A/AAC). + // macOS cannot encode MP3, so we use AAC which AVAssetReader handles identically. + static func shortMP3URL() throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let url = tempDir.appendingPathComponent("test_fixture.m4a") + + if !FileManager.default.fileExists(atPath: url.path) { + let wavURL = tempDir.appendingPathComponent("test_fixture.wav") + let sampleRate = 44100 + let channels = 1 + let durationSamples = sampleRate * 3 + let bytesPerSample = 2 + let dataSize = durationSamples * channels * bytesPerSample + + 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) + 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)) + + try wavData.write(to: wavURL) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") + process.arguments = [wavURL.path, url.path, "-f", "m4af", "-d", "aac"] + 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 audio fixture"]) + } + } + + return url + } +} diff --git a/MusicTests/HostServerIntegrationTests.swift b/MusicTests/HostServerIntegrationTests.swift index 83e2a06..9463bb5 100644 --- a/MusicTests/HostServerIntegrationTests.swift +++ b/MusicTests/HostServerIntegrationTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import MusicShared import Network @testable import Music @@ -54,7 +55,7 @@ struct HostServerIntegrationTests { // 2. Set up the player and server with command dispatch configured let audio = AudioService() - let player = PlayerViewModel(audio: audio, db: nil) + let player = PlayerViewModel(provider: audio, db: nil) let server = HostServer(dbPath: dbPath) server.configure(player: player, db: nil) try server.start() diff --git a/MusicTests/PlaybackPipelineTeardownTests.swift b/MusicTests/PlaybackPipelineTeardownTests.swift new file mode 100644 index 0000000..34e8f20 --- /dev/null +++ b/MusicTests/PlaybackPipelineTeardownTests.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +import AVFoundation +@testable import Music + +// Reproduces the "CoreMediaErrorDomain error -16044 after streaming a few tracks" +// bug (plus the accompanying HALC_ProxyIOContext overload / out-of-order log spew). +// +// Root cause: setting `player = nil` does NOT release an AVPlayer's decode/render +// pipeline — it is the *association* of an AVPlayerItem with an AVPlayer that owns +// the pipeline, and ARC tears it down asynchronously. The teardown path paused and +// nilled the player but never called `replaceCurrentItem(with: nil)`, so each track +// switch leaked a still-associated pipeline. After a handful of tracks they exceed +// CoreMedia's small concurrent-pipeline limit and a new player can't acquire a +// decode session, failing with -16044. +// +// The invariant proven here: tearing down the player must dissociate its item so +// the pipeline is released immediately. +@MainActor +struct PlaybackPipelineTeardownTests { + + // Verifies the streaming provider releases the previous player's pipeline on teardown. + // Steps: + // 1. Create a StreamingPlaybackProvider (host/key unused — we drive AVPlayer + // directly with a local file URL to bypass the network pre-flight). + // 2. Start an AVPlayer on a real local audio fixture and capture a strong + // reference to it, then confirm the pipeline is established (currentItem set). + // 3. Tear down via stop() — the same cleanup path a queue advance runs. + // 4. The captured player must have been dissociated from its item (currentItem + // == nil). Before the fix it is still set, so pipelines accumulate. + @Test func streamingProviderReleasesPipelineOnTeardown() throws { + // 1. Provider with throwaway connection details. + let provider = StreamingPlaybackProvider(hostURL: "http://unused.invalid", apiKey: "unused") + + // 2. Start playback on a real local file and grab the AVPlayer it created. + let fixture = try TestFixtures.shortMP3URL() + provider.startAVPlayer(url: fixture) + let firstPlayer = try #require(provider.player) + #expect(firstPlayer.currentItem != nil) + + // 3. Tear down (queue advance / stop runs this path). + provider.stop() + + // 4. The pipeline must be released: the item is dissociated from the player. + #expect(firstPlayer.currentItem == nil) + } + + // Verifies the local-playback provider (AudioService) has the same fix. + // Steps: + // 1. Create an AudioService. + // 2. Play a real local audio fixture and capture the AVPlayer; confirm the + // pipeline is established (currentItem set). + // 3. Tear down via stop(). + // 4. The captured player must have been dissociated from its item. + @Test func audioServiceReleasesPipelineOnTeardown() throws { + // 1. Local playback provider. + let provider = AudioService() + + // 2. Play a real local file and grab the AVPlayer it created. + let fixture = try TestFixtures.shortMP3URL() + provider.play(url: fixture) + let firstPlayer = try #require(provider.player) + #expect(firstPlayer.currentItem != nil) + + // 3. Tear down. + provider.stop() + + // 4. The pipeline must be released: the item is dissociated from the player. + #expect(firstPlayer.currentItem == nil) + } +} diff --git a/MusicTests/PlayerViewModelTests.swift b/MusicTests/PlayerViewModelTests.swift index 3a973a5..5878a55 100644 --- a/MusicTests/PlayerViewModelTests.swift +++ b/MusicTests/PlayerViewModelTests.swift @@ -16,7 +16,7 @@ struct PlayerViewModelTests { // Sets the queue and plays a track, verifies current track and index are set. @Test func playTrackSetsCurrentTrackAndIndex() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(5) vm.setQueue(tracks) vm.play(tracks[2]) @@ -27,7 +27,7 @@ struct PlayerViewModelTests { // Calls next() and verifies it advances to the next track. @Test func nextAdvancesToNextTrack() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(5) vm.setQueue(tracks) vm.play(tracks[0]) @@ -39,7 +39,7 @@ struct PlayerViewModelTests { // Calls next() on the last track and verifies it stops (no wrap for v1). @Test func nextAtEndStops() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(3) vm.setQueue(tracks) vm.play(tracks[2]) @@ -50,7 +50,7 @@ struct PlayerViewModelTests { // Calls previous() and verifies it goes to the previous track. @Test func previousGoesToPreviousTrack() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(5) vm.setQueue(tracks) vm.play(tracks[3]) @@ -61,7 +61,7 @@ struct PlayerViewModelTests { // Calls previous() on the first track and verifies it stays at the first track. @Test func previousAtStartStaysAtFirst() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(3) vm.setQueue(tracks) vm.play(tracks[0]) @@ -74,7 +74,7 @@ struct PlayerViewModelTests { // Enables shuffle and verifies the shuffled queue contains all tracks // and starts with the current track. @Test func shuffleContainsAllTracksStartingWithCurrent() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(20) vm.setQueue(tracks) vm.play(tracks[5]) @@ -91,7 +91,7 @@ struct PlayerViewModelTests { // Disables shuffle and verifies the queue returns to original order // and current track is preserved. @Test func unshuffleRestoresOriginalOrder() { - let vm = PlayerViewModel(audio: AudioService(), db: nil) + let vm = PlayerViewModel(provider: AudioService(), db: nil) let tracks = makeTracks(10) vm.setQueue(tracks) vm.play(tracks[3]) @@ -102,4 +102,61 @@ struct PlayerViewModelTests { #expect(vm.currentTrack?.id == 4) #expect(vm.queue.map { $0.id } == tracks.map { $0.id }) } + + // Reproduces the streaming "0:00" bug: when the player cannot determine a + // track's total duration (as with a progressive HTTP MP3 stream), the view + // model must fall back to the duration already known from the library database. + @Test func streamingTrackUsesDatabaseDurationWhenPlayerCannotDetermineIt() { + // Step 1: Back the view model with a provider that mimics streaming — + // it reports playback state but never a valid duration (stays 0). + let provider = FakeStreamingProvider() + let vm = PlayerViewModel(provider: provider, db: nil) + + // Step 2: Queue a track whose duration is known from the database (215s). + let track = Track.fixture(id: 1, title: "Streamed", duration: 215) + vm.setQueue([track]) + + // Step 3: Play it. The provider fires a playback-state change with + // duration 0, exactly as AVPlayer does for an unmeasurable + // progressive stream. + vm.play(track) + + // Step 4: The displayed total duration must be the database value (215s), + // not 0 — otherwise the UI shows "0:00". + #expect(vm.duration == 215) + } +} + +// A PlaybackProvider that mimics the streaming case: it "plays" from an HTTP URL +// but — like AVPlayer with a progressive MP3 stream — can never determine the +// total duration, so `duration` stays 0. Used to reproduce the "0:00" bug. +@MainActor +private final class FakeStreamingProvider: PlaybackProvider { + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 // never resolves — the crux of the bug + var volume: Float = 0.65 + private(set) var isScrubbing = false + var onTrackFinished: (() -> Void)? + var onPlaybackStateChanged: (() -> Void)? + + func urlForTrack(_ track: Track) -> URL? { + URL(string: "http://host/file?id=\(track.id ?? 0)") + } + + func play(url: URL) { + isPlaying = true + // Simulate the periodic time observer firing with no determinable duration. + onPlaybackStateChanged?() + } + + func pause() { isPlaying = false; onPlaybackStateChanged?() } + func resume() { isPlaying = true; onPlaybackStateChanged?() } + func togglePlayPause() { if isPlaying { pause() } else { resume() } } + func seek(to position: Double) { currentTime = position } + func setVolume(_ level: Float) { volume = level } + func stop() { isPlaying = false; currentTime = 0; duration = 0 } + func beginScrubbing() { isScrubbing = true } + func scrub(to position: Double) { currentTime = position } + func endScrubbing(at position: Double) { currentTime = position; isScrubbing = false } } diff --git a/MusicTests/RemoteProtocolTests.swift b/MusicTests/RemoteProtocolTests.swift deleted file mode 100644 index ae8439b..0000000 --- a/MusicTests/RemoteProtocolTests.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation -import Testing -@testable import Music - -struct RemoteProtocolTests { - private let encoder: JSONEncoder = { - let e = JSONEncoder() - e.outputFormatting = [.sortedKeys] - return e - }() - private let decoder = JSONDecoder() - - // MARK: - Helpers - - /// Encode then decode a value, returning the decoded copy. - private func roundTrip(_ value: T) throws -> T { - let data = try encoder.encode(value) - return try decoder.decode(T.self, from: data) - } - - // MARK: - RemoteCommand round-trip tests - // Each test encodes a RemoteCommand case to JSON and decodes it back, - // verifying the decoded value equals the original. - - @Test func remoteCommandRoundTrip_play() throws { - let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_pause() throws { - let cmd = RemoteCommand.pause - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_resume() throws { - let cmd = RemoteCommand.resume - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_next() throws { - let cmd = RemoteCommand.next - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_previous() throws { - let cmd = RemoteCommand.previous - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_seek() throws { - let cmd = RemoteCommand.seek(position: 123.456) - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_setVolume() throws { - let cmd = RemoteCommand.setVolume(level: 0.75) - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_toggleShuffle() throws { - let cmd = RemoteCommand.toggleShuffle - #expect(try roundTrip(cmd) == cmd) - } - - @Test func remoteCommandRoundTrip_refreshDB() throws { - let cmd = RemoteCommand.refreshDB - #expect(try roundTrip(cmd) == cmd) - } - - // MARK: - HostEvent round-trip tests - // Each test encodes a HostEvent case to JSON and decodes it back, - // verifying the decoded value equals the original. - - @Test func hostEventRoundTrip_playbackState() throws { - let payload = PlaybackStatePayload( - trackId: 7, - isPlaying: true, - currentTime: 42.5, - duration: 210.0, - volume: 0.8, - isShuffled: false - ) - let event = HostEvent.playbackState(payload) - #expect(try roundTrip(event) == event) - } - - @Test func hostEventRoundTrip_dbReady() throws { - let event = HostEvent.dbReady - #expect(try roundTrip(event) == event) - } - - @Test func hostEventRoundTrip_error() throws { - let event = HostEvent.error(message: "Something went wrong") - #expect(try roundTrip(event) == event) - } - - // MARK: - HandshakeMessage round-trip test - // Verifies HandshakeMessage survives JSON encoding and decoding. - - @Test func handshakeMessageRoundTrip() throws { - let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.2.3") - #expect(try roundTrip(msg) == msg) - } - - // MARK: - Wire format decode tests - // Verify that hand-crafted JSON strings matching the expected wire format - // decode correctly, ensuring the Codable implementation matches the spec. - - @Test func wireFormatDecode_playCommand() throws { - let json = """ - {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} - """ - let decoded = try decoder.decode(RemoteCommand.self, from: Data(json.utf8)) - #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) - } - - @Test func wireFormatDecode_playbackStateEvent() throws { - let json = """ - {"type":"playbackState","payload":{"trackId":7,"isPlaying":true,"currentTime":42.5,"duration":210.0,"volume":0.8,"isShuffled":false}} - """ - let decoded = try decoder.decode(HostEvent.self, from: Data(json.utf8)) - let expected = HostEvent.playbackState(PlaybackStatePayload( - trackId: 7, - isPlaying: true, - currentTime: 42.5, - duration: 210.0, - volume: 0.8, - isShuffled: false - )) - #expect(decoded == expected) - } -} diff --git a/MusicTests/StreamingIntegrationTests.swift b/MusicTests/StreamingIntegrationTests.swift new file mode 100644 index 0000000..d3f7bb7 --- /dev/null +++ b/MusicTests/StreamingIntegrationTests.swift @@ -0,0 +1,204 @@ +import Testing +import Foundation +import MusicShared +@testable import Music + +@MainActor +struct StreamingIntegrationTests { + static let testAPIKey = "integration-test-key" + + // Full flow: start server, authenticate, download DB, verify track is present. + // 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. Save downloaded DB to disk and verify the track is present + @Test(.timeLimit(.minutes(1))) + func fullConnectionFlow() async throws { + let db = try DatabaseService(inMemory: true) + var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song") + try db.insert(&track) + + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { server.stop() } + let port = try #require(server.actualPort) + let baseURL = "http://127.0.0.1:\(port)" + + // 3. Authenticate + var authReq = URLRequest(url: URL(string: "\(baseURL)/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)/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") + } + + // Verifies that requests without auth get 401. + @Test(.timeLimit(.minutes(1))) + 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 { server.stop() } + let port = try #require(server.actualPort) + + let request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!) + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 401) + } + + // Verifies the /tracks/:trackId/file endpoint serves audio data. + @Test(.timeLimit(.minutes(1))) + func fileEndpointServesTrack() async throws { + // 1. Create DB with a test track pointing to a real audio file + let db = try DatabaseService(inMemory: true) + let fixtureURL = try TestFixtures.shortMP3URL() + var track = Track.fixture(id: nil, fileURL: fixtureURL.path, title: "Stream Test") + 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 { server.stop() } + let port = try #require(server.actualPort) + let baseURL = "http://127.0.0.1:\(port)" + + // 3. Request via Bearer auth + var bearerReq = URLRequest(url: URL(string: "\(baseURL)/file?id=\(trackId)")!) + bearerReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + let (bearerData, bearerResp) = try await URLSession.shared.data(for: bearerReq) + let bearerHTTP = try #require(bearerResp as? HTTPURLResponse) + #expect(bearerHTTP.statusCode == 200) + #expect(bearerData.count > 0) + + // 4. Request via token query param + let tokenURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! + let (tokenData, tokenResp) = try await URLSession.shared.data(for: URLRequest(url: tokenURL)) + let tokenHTTP = try #require(tokenResp as? HTTPURLResponse) + #expect(tokenHTTP.statusCode == 200) + #expect(tokenData.count == bearerData.count) + + // 5. Unauthenticated request should be 401 + let noAuthURL = URL(string: "\(baseURL)/file?id=\(trackId)")! + let (_, noAuthResp) = try await URLSession.shared.data(for: URLRequest(url: noAuthURL)) + let noAuthHTTP = try #require(noAuthResp as? HTTPURLResponse) + #expect(noAuthHTTP.statusCode == 401) + } + + // Verifies that wrong API key gets 401. + @Test(.timeLimit(.minutes(1))) + 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 { server.stop() } + let port = try #require(server.actualPort) + + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/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) + } + + // Reproduces the real-world "File not found on disk" (HTTP 404) bug. + // + // The production scanner (ScannerService) stores `fileURL` as + // `url.absoluteString` — e.g. "file:///Users/.../song.m4a" — WITH the + // "file://" scheme and percent-encoding. StreamingServer reconstructed it + // with `URL(fileURLWithPath:)`, which treats that whole string as a raw + // (relative) path, prepends the CWD, and skips percent-decoding, so the file + // is never found on disk. The earlier `fileEndpointServesTrack` test masked + // this because it stored `fixtureURL.path` (a bare path) instead. + // + // Steps: + // 1. Create a DB with a track whose fileURL is stored EXACTLY as the scanner + // stores it: `fixtureURL.absoluteString` (not `.path`). + // 2. Start the streaming server. + // 3. Request GET /file?id= with a valid token. + // 4. Expect HTTP 200 with the file's bytes. Before the fix this returns 404 + // with body {"error":{"message":"File not found on disk"}}. + @Test(.timeLimit(.minutes(1))) + func fileEndpointServesTrackStoredAsAbsoluteString() async throws { + // 1. Insert a track using the production storage format (absoluteString). + let db = try DatabaseService(inMemory: true) + let fixtureURL = try TestFixtures.shortMP3URL() + var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs Stream Test") + try db.insert(&track) + let trackId = try #require(track.id) + + // 2. Start the server. + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { server.stop() } + let port = try #require(server.actualPort) + let baseURL = "http://127.0.0.1:\(port)" + + // 3. Request the file via the token query parameter. + let url = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! + let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url)) + let http = try #require(resp as? HTTPURLResponse) + + // 4. Must serve the bytes, not 404. + #expect(http.statusCode == 200) + #expect(data.count > 0) + } + + // Same root cause as above, exercised through the HLS path (SegmenterCache + // also used URL(fileURLWithPath:) on the stored fileURL). + // + // Steps: + // 1. Insert a track whose fileURL is stored as `absoluteString`. + // 2. Start the server. + // 3. Request GET /tracks//segments/0.mp3 with a valid token. + // 4. Expect HTTP 200 with segment bytes (before the fix the segmenter cannot + // open the file, so this returns a 404/error). + @Test(.timeLimit(.minutes(1))) + func segmentEndpointServesTrackStoredAsAbsoluteString() async throws { + // 1. Insert a track using the production storage format (absoluteString). + let db = try DatabaseService(inMemory: true) + let fixtureURL = try TestFixtures.shortMP3URL() + var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs HLS Test") + try db.insert(&track) + let trackId = try #require(track.id) + + // 2. Start the server. + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { server.stop() } + let port = try #require(server.actualPort) + let baseURL = "http://127.0.0.1:\(port)" + + // 3. Request the first HLS segment via the token query parameter. + let url = URL(string: "\(baseURL)/tracks/\(trackId)/segments/0.mp3?token=\(Self.testAPIKey)")! + let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url)) + let http = try #require(resp as? HTTPURLResponse) + + // 4. Must serve segment bytes, not 404. + #expect(http.statusCode == 200) + #expect(data.count > 0) + } +} diff --git a/MusicTests/StreamingServerTests.swift b/MusicTests/StreamingServerTests.swift new file mode 100644 index 0000000..d612af9 --- /dev/null +++ b/MusicTests/StreamingServerTests.swift @@ -0,0 +1,147 @@ +import Foundation +import MusicShared +import Network +import Testing +@testable import Music + +@MainActor +struct StreamingServerTests { + + // MARK: - Helpers + + /// Creates a StreamingServer backed by an in-memory database on port 0 + /// (OS-assigned) with a known API key for testing. + private func makeServer() throws -> StreamingServer { + let db = try DatabaseService(inMemory: true) + return StreamingServer(db: db, apiKey: "test-key-12345", port: 0) + } + + /// Performs a simple HTTP GET using NWConnection and returns the full + /// response (headers + body) as raw Data, then splits out just the body. + private func httpGet( + host: String, + port: Int, + path: String, + headers: [String: String] = [:] + ) async throws -> (statusCode: Int, body: Data) { + try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: UInt16(port))!, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state { + // Build the HTTP request + var request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n" + for (key, value) in headers { + request += "\(key): \(value)\r\n" + } + request += "\r\n" + + connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) + + // Receive the full response (Connection: close ensures everything arrives) + connection.receiveMessage { data, _, _, error in + defer { connection.cancel() } + + if let error { + continuation.resume(throwing: error) + return + } + + guard let data else { + continuation.resume(returning: (statusCode: 0, body: Data())) + return + } + + // Parse the status code from the first line + let responseString = String(data: data, encoding: .utf8) ?? "" + let firstLine = responseString.split(separator: "\r\n").first ?? "" + let parts = firstLine.split(separator: " ") + let statusCode = parts.count >= 2 ? Int(parts[1]) ?? 0 : 0 + + // Extract body after the header/body separator + if let range = data.range(of: Data("\r\n\r\n".utf8)) { + continuation.resume(returning: (statusCode: statusCode, body: Data(data[range.upperBound...]))) + } else { + continuation.resume(returning: (statusCode: statusCode, body: Data())) + } + } + } else if case .failed(let error) = state { + continuation.resume(throwing: error) + } + } + connection.start(queue: .main) + } + } + + // MARK: - Tests + + // 1. Sends GET /auth with a valid Bearer key. + // 2. Expects a 200 status code. + // 3. Decodes the body as AuthResponse and verifies protocolVersion matches. + @Test(.timeLimit(.minutes(1))) + func authEndpointAcceptsValidKey() async throws { + let server = try makeServer() + try await server.start() + defer { server.stop() } + + let port = server.actualPort! + let (statusCode, body) = try await httpGet( + host: "127.0.0.1", + port: port, + path: "/auth", + headers: ["Authorization": "Bearer test-key-12345"] + ) + + #expect(statusCode == 200) + + let authResponse = try JSONDecoder().decode(AuthResponse.self, from: body) + #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) + #expect(!authResponse.hostName.isEmpty) + } + + // 1. Sends GET /auth WITHOUT an Authorization header. + // 2. Expects a 401 Unauthorized status code. + @Test(.timeLimit(.minutes(1))) + func authEndpointRejectsNoKey() async throws { + let server = try makeServer() + try await server.start() + defer { server.stop() } + + let port = server.actualPort! + let (statusCode, _) = try await httpGet( + host: "127.0.0.1", + port: port, + path: "/auth" + // No Authorization header + ) + + #expect(statusCode == 401) + } + + // 1. Sends GET /db with a valid key using URLSession (binary response + // requires proper HTTP framing that NWConnection.receiveMessage lacks). + // 2. Expects a 200 status code. + // 3. Verifies the response body starts with the SQLite magic header "SQLite format 3". + @Test(.timeLimit(.minutes(1))) + func dbEndpointReturnsDatabaseFile() async throws { + let server = try makeServer() + try await server.start() + defer { server.stop() } + + let port = server.actualPort! + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/db")!) + request.setValue("Bearer test-key-12345", 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" (16 bytes) + let header = String(data: data.prefix(16), encoding: .utf8) ?? "" + #expect(header.hasPrefix("SQLite format 3")) + } +} diff --git a/docs/superpowers/plans/2026-05-26-remote-mode.md b/docs/superpowers/plans/2026-05-26-remote-mode.md new file mode 100644 index 0000000..a2d511c --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-remote-mode.md @@ -0,0 +1,2263 @@ +# Remote Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Host/Remote mode so the app on a MacBook can control playback on a Mac Mini over the local network. + +**Architecture:** PlayerViewModel becomes the single source of truth for all playback state — Views never read from AudioService directly. In local mode, PlayerViewModel syncs state from AudioService via a callback. In remote mode, it syncs from the network. This eliminates all `if isRemoteMode` branching from the View layer. Networking uses Network.framework with Bonjour discovery, HTTP for one-shot DB download, and NDJSON (newline-delimited JSON) over TCP for the bidirectional command channel. + +**Tech Stack:** Network.framework (NWListener, NWConnection, NWBrowser), Bonjour/mDNS, os.Logger, GRDB (existing), Swift Codable for JSON protocol. + +**Architectural note:** The spec describes a `PlaybackController` protocol with separate implementations. This plan achieves the same goal (Views unchanged between modes) more simply: PlayerViewModel handles both modes internally with clear `if/else` routing. The protocol added a layer of indirection that obscured what was happening, and ContentView still ended up needing `isRemoteMode` checks for reading state. This approach eliminates all of that. + +--- + +## File Structure + +### New Files + +| File | Responsibility | +|------|---------------| +| `Music/Remote/RemoteProtocol.swift` | `RemoteCommand` and `HostEvent` Codable enums, protocol version constant, `PlaybackStatePayload`, `HandshakeMessage` | +| `Music/Remote/RemoteLogger.swift` | `os.Logger` wrappers with `host` and `client` categories under `com.music.remote` subsystem | +| `Music/Remote/ConnectionState.swift` | Connection state machine enum with transition validation and user-visible messages | +| `Music/Remote/NDJSONTransport.swift` | Newline-delimited JSON framing over `NWConnection` — line-buffered reads, typed sends. Shared by host and client. | +| `Music/Remote/HostServer.swift` | `NWListener`-based server: Bonjour advertisement, HTTP DB serving, command channel via `NDJSONTransport`, command dispatch to `PlayerViewModel` | +| `Music/Remote/RemoteClient.swift` | `NWBrowser`-based client: Bonjour discovery, DB download, command channel via `NDJSONTransport`, state receiving | +| `Music/Views/ConnectionSheet.swift` | SwiftUI sheet for discovering and connecting to hosts | +| `MusicTests/RemoteProtocolTests.swift` | Codable round-trip tests for all message types | +| `MusicTests/ConnectionStateTests.swift` | State machine transition tests | +| `MusicTests/NDJSONTransportTests.swift` | Line-buffered framing tests | +| `MusicTests/PlayerViewModelRemoteTests.swift` | PlayerViewModel remote mode tests: command forwarding, state syncing | +| `MusicTests/HostServerIntegrationTests.swift` | Loopback integration: DB download, command/state round-trip | + +### Modified Files + +| File | Changes | +|------|---------| +| `Music/Services/AudioService.swift` | Add `onPlaybackStateChanged` callback, fire on time updates and discrete state changes | +| `Music/Services/DatabaseService.swift` | Add `fetchTracksByIds(_:)` method for efficient ID-based lookups | +| `Music/ViewModels/PlayerViewModel.swift` | Add `isPlaying`, `currentTime`, `duration`, `volume` properties. Add `enterRemoteMode`/`exitRemoteMode`. Route all playback actions through local audio or remote commands. | +| `Music/ContentView.swift` | Replace all `audio.*` reads with `player.*`. Remove `audio` property. Add `networkStatus` for banner/indicators. | +| `Music/Views/PlaylistBarView.swift` | Add `isRemoteMode` to disable context menus | +| `Music/MusicApp.swift` | Add `HostServer`, `RemoteClient`, menu items, DB swap on connect/disconnect | + +--- + +### Task 1: Message Protocol — `RemoteCommand` and `HostEvent` + +**Files:** +- Create: `Music/Remote/RemoteProtocol.swift` +- Test: `MusicTests/RemoteProtocolTests.swift` + +- [ ] **Step 1: Write failing tests for RemoteCommand encoding/decoding** + +```swift +// MusicTests/RemoteProtocolTests.swift +import Testing +import Foundation +@testable import Music + +struct RemoteProtocolTests { + + // Encodes each RemoteCommand to JSON and decodes it back, + // verifying the round-trip preserves the value exactly. + + @Test func playCommandRoundTrip() throws { + let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func pauseCommandRoundTrip() throws { + let cmd = RemoteCommand.pause + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func resumeCommandRoundTrip() throws { + let cmd = RemoteCommand.resume + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func nextCommandRoundTrip() throws { + let cmd = RemoteCommand.next + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func previousCommandRoundTrip() throws { + let cmd = RemoteCommand.previous + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func seekCommandRoundTrip() throws { + let cmd = RemoteCommand.seek(position: 65.3) + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func setVolumeCommandRoundTrip() throws { + let cmd = RemoteCommand.setVolume(level: 0.75) + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func toggleShuffleCommandRoundTrip() throws { + let cmd = RemoteCommand.toggleShuffle + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func refreshDBCommandRoundTrip() throws { + let cmd = RemoteCommand.refreshDB + let data = try JSONEncoder().encode(cmd) + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) + #expect(decoded == cmd) + } + + @Test func playbackStateEventRoundTrip() throws { + let event = HostEvent.playbackState(PlaybackStatePayload( + trackId: 42, isPlaying: true, currentTime: 65.3, + duration: 210.0, volume: 0.75, isShuffled: false + )) + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(HostEvent.self, from: data) + #expect(decoded == event) + } + + @Test func dbReadyEventRoundTrip() throws { + let event = HostEvent.dbReady + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(HostEvent.self, from: data) + #expect(decoded == event) + } + + @Test func errorEventRoundTrip() throws { + let event = HostEvent.error(message: "Track file not found") + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(HostEvent.self, from: data) + #expect(decoded == event) + } + + @Test func handshakeRoundTrip() throws { + let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.0.0") + let data = try JSONEncoder().encode(msg) + let decoded = try JSONDecoder().decode(HandshakeMessage.self, from: data) + #expect(decoded == msg) + } + + // Decodes from known JSON strings to verify wire format matches the spec. + @Test func playCommandDecodesFromWireFormat() throws { + let json = """ + {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} + """ + let decoded = try JSONDecoder().decode(RemoteCommand.self, from: Data(json.utf8)) + #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) + } + + @Test func playbackStateDecodesFromWireFormat() throws { + let json = """ + {"type":"playbackState","payload":{"trackId":42,"isPlaying":true,"currentTime":65.3,"duration":210.0,"volume":0.75,"isShuffled":false}} + """ + let decoded = try JSONDecoder().decode(HostEvent.self, from: Data(json.utf8)) + #expect(decoded == .playbackState(PlaybackStatePayload( + trackId: 42, isPlaying: true, currentTime: 65.3, + duration: 210.0, volume: 0.75, isShuffled: false + ))) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` + +Expected: Compilation failure — `RemoteCommand`, `HostEvent`, etc. not defined. + +- [ ] **Step 3: Implement the protocol types** + +```swift +// Music/Remote/RemoteProtocol.swift +import Foundation + +let RemoteProtocolVersion: Int = 1 + +nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { + var trackId: Int64? + var isPlaying: Bool + var currentTime: Double + var duration: Double + var volume: Float + var isShuffled: Bool +} + +nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { + var protocolVersion: Int + var appVersion: String +} + +nonisolated enum RemoteCommand: Codable, Equatable, Sendable { + case play(trackId: Int64, queueIds: [Int64]) + case pause + case resume + case next + case previous + case seek(position: Double) + case setVolume(level: Float) + case toggleShuffle + case refreshDB + + private enum CodingKeys: String, CodingKey { + case type, payload + } + + private enum CommandType: String, Codable { + case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB + } + + private struct PlayPayload: Codable { let trackId: Int64; let queueIds: [Int64] } + private struct SeekPayload: Codable { let position: Double } + private struct VolumePayload: Codable { let level: Float } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .play(let trackId, let queueIds): + try container.encode(CommandType.play, forKey: .type) + try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) + case .pause: try container.encode(CommandType.pause, forKey: .type) + case .resume: try container.encode(CommandType.resume, forKey: .type) + case .next: try container.encode(CommandType.next, forKey: .type) + case .previous: try container.encode(CommandType.previous, forKey: .type) + case .seek(let position): + try container.encode(CommandType.seek, forKey: .type) + try container.encode(SeekPayload(position: position), forKey: .payload) + case .setVolume(let level): + try container.encode(CommandType.setVolume, forKey: .type) + try container.encode(VolumePayload(level: level), forKey: .payload) + case .toggleShuffle: try container.encode(CommandType.toggleShuffle, forKey: .type) + case .refreshDB: try container.encode(CommandType.refreshDB, forKey: .type) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(CommandType.self, forKey: .type) + switch type { + case .play: + let p = try container.decode(PlayPayload.self, forKey: .payload) + self = .play(trackId: p.trackId, queueIds: p.queueIds) + case .pause: self = .pause + case .resume: self = .resume + case .next: self = .next + case .previous: self = .previous + case .seek: + let p = try container.decode(SeekPayload.self, forKey: .payload) + self = .seek(position: p.position) + case .setVolume: + let p = try container.decode(VolumePayload.self, forKey: .payload) + self = .setVolume(level: p.level) + case .toggleShuffle: self = .toggleShuffle + case .refreshDB: self = .refreshDB + } + } +} + +nonisolated enum HostEvent: Codable, Equatable, Sendable { + case playbackState(PlaybackStatePayload) + case dbReady + case error(message: String) + + private enum CodingKeys: String, CodingKey { case type, payload } + private enum EventType: String, Codable { case playbackState, dbReady, error } + private struct ErrorPayload: Codable { let message: String } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .playbackState(let payload): + try container.encode(EventType.playbackState, forKey: .type) + try container.encode(payload, forKey: .payload) + case .dbReady: try container.encode(EventType.dbReady, forKey: .type) + case .error(let message): + try container.encode(EventType.error, forKey: .type) + try container.encode(ErrorPayload(message: message), forKey: .payload) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(EventType.self, forKey: .type) + switch type { + case .playbackState: + self = .playbackState(try container.decode(PlaybackStatePayload.self, forKey: .payload)) + case .dbReady: self = .dbReady + case .error: + self = .error(message: try container.decode(ErrorPayload.self, forKey: .payload).message) + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` + +Expected: All 15 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Remote/RemoteProtocol.swift MusicTests/RemoteProtocolTests.swift +git commit -m "feat(remote): add RemoteCommand and HostEvent protocol types with tests" +``` + +--- + +### Task 2: Remote Logger + +**Files:** +- Create: `Music/Remote/RemoteLogger.swift` + +- [ ] **Step 1: Implement the logger** + +```swift +// Music/Remote/RemoteLogger.swift +import os + +enum RemoteLogger { + static let host = Logger(subsystem: "com.music.remote", category: "host") + static let client = Logger(subsystem: "com.music.remote", category: "client") +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Music/Remote/RemoteLogger.swift +git commit -m "feat(remote): add structured os.Logger for host and client" +``` + +--- + +### Task 3: Connection State Machine + +**Files:** +- Create: `Music/Remote/ConnectionState.swift` +- Test: `MusicTests/ConnectionStateTests.swift` + +- [ ] **Step 1: Write failing tests** + +```swift +// MusicTests/ConnectionStateTests.swift +import Testing +import Foundation +@testable import Music + +struct ConnectionStateTests { + + // Verifies that each valid forward transition is allowed. + @Test func validTransitionsSucceed() { + #expect(ConnectionState.disconnected.canTransition(to: .discovering) == true) + #expect(ConnectionState.discovering.canTransition(to: .foundHost("Mac Mini")) == true) + #expect(ConnectionState.foundHost("Mac Mini").canTransition(to: .downloadingDB) == true) + #expect(ConnectionState.downloadingDB.canTransition(to: .connectingCommandChannel) == true) + #expect(ConnectionState.connectingCommandChannel.canTransition(to: .connected("Mac Mini")) == true) + #expect(ConnectionState.connected("Mac Mini").canTransition(to: .connectionLost("Network changed")) == true) + #expect(ConnectionState.connectionLost("Network changed").canTransition(to: .discovering) == true) + } + + // Verifies that skipping states is rejected. + @Test func invalidTransitionsRejected() { + #expect(ConnectionState.disconnected.canTransition(to: .connected("Mac Mini")) == false) + #expect(ConnectionState.discovering.canTransition(to: .connected("Mac Mini")) == false) + #expect(ConnectionState.connected("Mac Mini").canTransition(to: .discovering) == false) + } + + // Any state can transition to disconnected (user can always disconnect). + @Test func anyStateCanDisconnect() { + #expect(ConnectionState.discovering.canTransition(to: .disconnected) == true) + #expect(ConnectionState.foundHost("X").canTransition(to: .disconnected) == true) + #expect(ConnectionState.downloadingDB.canTransition(to: .disconnected) == true) + #expect(ConnectionState.connectingCommandChannel.canTransition(to: .disconnected) == true) + #expect(ConnectionState.connected("X").canTransition(to: .disconnected) == true) + } + + // Verifies the human-readable status message for each state. + @Test func userVisibleDescriptions() { + #expect(ConnectionState.disconnected.userMessage == nil) + #expect(ConnectionState.discovering.userMessage == "Searching for hosts...") + #expect(ConnectionState.foundHost("Mac Mini").userMessage == "Found Mac Mini") + #expect(ConnectionState.downloadingDB.userMessage == "Downloading library...") + #expect(ConnectionState.connectingCommandChannel.userMessage == "Connecting...") + #expect(ConnectionState.connected("Mac Mini").userMessage == "Connected to Mac Mini") + #expect(ConnectionState.connectionLost("Network changed").userMessage == "Connection lost — Network changed") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` + +Expected: Compilation failure. + +- [ ] **Step 3: Implement the state machine** + +```swift +// Music/Remote/ConnectionState.swift +import Foundation + +nonisolated enum ConnectionState: Equatable, Sendable { + case disconnected + case discovering + case foundHost(String) + case downloadingDB + case connectingCommandChannel + case connected(String) + case connectionLost(String) + + var userMessage: String? { + switch self { + case .disconnected: nil + case .discovering: "Searching for hosts..." + case .foundHost(let name): "Found \(name)" + case .downloadingDB: "Downloading library..." + case .connectingCommandChannel: "Connecting..." + case .connected(let name): "Connected to \(name)" + case .connectionLost(let reason): "Connection lost — \(reason)" + } + } + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + func canTransition(to next: ConnectionState) -> Bool { + if next == .disconnected { return true } + switch (self, next) { + case (.disconnected, .discovering), + (.discovering, .foundHost), + (.foundHost, .downloadingDB), + (.downloadingDB, .connectingCommandChannel), + (.connectingCommandChannel, .connected), + (.connected, .connectionLost), + (.connectionLost, .discovering): + return true + default: + return false + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` + +Expected: All 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Remote/ConnectionState.swift MusicTests/ConnectionStateTests.swift +git commit -m "feat(remote): add ConnectionState machine with transition validation" +``` + +--- + +### Task 4: NDJSONTransport — Shared Line-Buffered Framing + +**Files:** +- Create: `Music/Remote/NDJSONTransport.swift` +- Test: `MusicTests/NDJSONTransportTests.swift` + +This class handles the newline-delimited JSON framing over a raw TCP connection. Both `HostServer` and `RemoteClient` use it for their command channels, eliminating duplicated line-buffering logic. + +- [ ] **Step 1: Write failing tests** + +```swift +// MusicTests/NDJSONTransportTests.swift +import Testing +import Foundation +@testable import Music + +struct NDJSONTransportTests { + + // Verifies that processReceivedData correctly splits newline-delimited + // input into individual lines, handling partial lines across calls. + @Test func splitsLinesCorrectly() { + var lines: [String] = [] + let transport = NDJSONLineBuffer { lines.append($0) } + + transport.feed(Data("{\"type\":\"pause\"}\n{\"type\":\"resume\"}\n".utf8)) + #expect(lines == ["{\"type\":\"pause\"}", "{\"type\":\"resume\"}"]) + } + + // Verifies that a line split across two TCP reads is reassembled. + @Test func handlesPartialLines() { + var lines: [String] = [] + let transport = NDJSONLineBuffer { lines.append($0) } + + transport.feed(Data("{\"type\":\"pa".utf8)) + #expect(lines.isEmpty) + + transport.feed(Data("use\"}\n".utf8)) + #expect(lines == ["{\"type\":\"pause\"}"]) + } + + // Verifies empty lines are ignored. + @Test func ignoresEmptyLines() { + var lines: [String] = [] + let transport = NDJSONLineBuffer { lines.append($0) } + + transport.feed(Data("\n\n{\"type\":\"next\"}\n\n".utf8)) + #expect(lines == ["{\"type\":\"next\"}"]) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` + +Expected: Compilation failure. + +- [ ] **Step 3: Implement NDJSONTransport** + +```swift +// Music/Remote/NDJSONTransport.swift +import Foundation +import Network + +final class NDJSONLineBuffer: @unchecked Sendable { + private var buffer = "" + private let onLine: (String) -> Void + + init(onLine: @escaping (String) -> Void) { + self.onLine = onLine + } + + func feed(_ data: Data) { + guard let text = String(data: data, encoding: .utf8) else { return } + buffer += text + while let newlineIndex = buffer.firstIndex(of: "\n") { + let line = String(buffer[buffer.startIndex.. Void)? + var onClose: (() -> Void)? + + init(connection: NWConnection, logger: os.Logger) { + self.connection = connection + self.logger = logger + self.lineBuffer = NDJSONLineBuffer { [weak self] line in + self?.onLine?(line) + } + } + + func send(_ message: T) { + do { + var data = try JSONEncoder().encode(message) + data.append(contentsOf: "\n".utf8) + connection.send(content: data, completion: .contentProcessed { [logger] error in + if let error { + logger.error("Send error: \(error.localizedDescription)") + } + }) + } catch { + logger.error("Encode error: \(error.localizedDescription)") + } + } + + func startReceiving() { + receiveNext() + } + + func close() { + connection.cancel() + } + + private func receiveNext() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + Task { @MainActor in + guard let self else { return } + if let data { + self.lineBuffer.feed(data) + } + if isComplete { + self.logger.info("Connection closed by peer") + self.onClose?() + } else if let error { + self.logger.error("Receive error: \(error.localizedDescription)") + self.onClose?() + } else { + self.receiveNext() + } + } + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` + +Expected: All 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Remote/NDJSONTransport.swift MusicTests/NDJSONTransportTests.swift +git commit -m "feat(remote): add NDJSONTransport for line-buffered JSON framing over TCP" +``` + +--- + +### Task 5: DatabaseService.fetchTracksByIds + +**Files:** +- Modify: `Music/Services/DatabaseService.swift` +- Modify: `MusicTests/DatabaseServiceTests.swift` + +- [ ] **Step 1: Write failing test** + +Append to `MusicTests/DatabaseServiceTests.swift`: + +```swift +// Inserts 5 tracks, fetches 3 by ID, verifies only those 3 are returned +// in the order of the requested IDs. +@Test func fetchTracksByIds() throws { + let db = try DatabaseService(inMemory: true) + var tracks = (0..<5).map { i in + Track.fixture(fileURL: "/track\(i).mp3", title: "Track \(i)") + } + for i in tracks.indices { + try db.insert(&tracks[i]) + } + + let ids: [Int64] = [tracks[2].id!, tracks[0].id!, tracks[4].id!] + let result = try db.fetchTracksByIds(ids) + + #expect(result.count == 3) + #expect(result[0].id == tracks[2].id) + #expect(result[1].id == tracks[0].id) + #expect(result[2].id == tracks[4].id) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` + +Expected: Compilation failure — `fetchTracksByIds` not defined. + +- [ ] **Step 3: Implement the method** + +Add to `DatabaseService.swift` in the `// MARK: - Read` section: + +```swift +func fetchTracksByIds(_ ids: [Int64]) throws -> [Track] { + guard !ids.isEmpty else { return [] } + let tracks = try dbPool.read { db in + let placeholders = databaseQuestionMarks(count: ids.count) + return try Track.fetchAll( + db, + sql: "SELECT * FROM tracks WHERE id IN (\(placeholders))", + arguments: StatementArguments(ids) + ) + } + let trackMap = Dictionary(uniqueKeysWithValues: tracks.compactMap { t in t.id.map { ($0, t) } }) + return ids.compactMap { trackMap[$0] } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift +git commit -m "feat(remote): add DatabaseService.fetchTracksByIds for efficient ID-based lookups" +``` + +--- + +### Task 6: Refactor PlayerViewModel to Own Playback State + +This is the key architectural change. PlayerViewModel becomes the single source of truth for ALL playback state. Views will read from PlayerViewModel instead of AudioService. + +**Files:** +- Modify: `Music/Services/AudioService.swift` +- Modify: `Music/ViewModels/PlayerViewModel.swift` +- Modify: `MusicTests/PlayerViewModelTests.swift` + +- [ ] **Step 1: Add `onPlaybackStateChanged` callback to AudioService** + +In `AudioService.swift`, add a new callback alongside `onTrackFinished`: + +```swift +var onPlaybackStateChanged: (() -> Void)? +``` + +Fire it from the periodic time observer (inside the existing closure, after updating `currentTime` and `duration`): + +```swift +self.onPlaybackStateChanged?() +``` + +Also fire it at the end of `play(url:)` (after `isPlaying = true`), `pause()` (after `isPlaying = false`), `resume()` (after `isPlaying = true`), and `stop()` (after setting all state). Also fire in the `endObserver` callback (after `isPlaying = false`). + +- [ ] **Step 2: Add playback state properties to PlayerViewModel** + +In `PlayerViewModel.swift`, add new stored properties: + +```swift +var isPlaying = false +var currentTime: Double = 0 +var duration: Double = 0 +var volume: Float = 0.65 +``` + +Add a private helper and the sync callback in `init`: + +```swift +private var remoteClient: RemoteClient? +var trackResolver: ((Int64) -> Track?)? + +private var isRemote: Bool { remoteClient != nil } +``` + +At the end of `init`, add: + +```swift +audio.onPlaybackStateChanged = { [weak self] in + self?.syncFromAudio() +} +``` + +Add the sync method: + +```swift +private func syncFromAudio() { + guard !isRemote else { return } + isPlaying = audio.isPlaying + if !audio.isScrubbing { + currentTime = audio.currentTime + } + duration = audio.duration + checkHalfway() +} +``` + +- [ ] **Step 3: Route playback actions through PlayerViewModel** + +Add these methods to `PlayerViewModel`: + +```swift +func togglePlayPause() { + if isPlaying { pause() } else { resume() } +} + +func pause() { + isPlaying = false + if isRemote { remoteClient!.sendCommand(.pause) } else { audio.pause() } +} + +func resume() { + isPlaying = true + if isRemote { remoteClient!.sendCommand(.resume) } else { audio.resume() } +} + +func seek(to position: Double) { + currentTime = position + if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.seek(to: position) } +} + +func setVolume(_ level: Float) { + volume = level + if isRemote { remoteClient!.sendCommand(.setVolume(level: level)) } else { audio.volume = level } +} + +func beginScrubbing() { + if !isRemote { audio.beginScrubbing() } +} + +func scrub(to position: Double) { + currentTime = position + if !isRemote { audio.scrub(to: position) } +} + +func endScrubbing(at position: Double) { + currentTime = position + if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) } +} + +func stop() { + isPlaying = false + currentTime = 0 + duration = 0 + currentTrack = nil + currentIndex = nil + if !isRemote { audio.stop() } +} +``` + +Update the existing `play(_ track:)` method to also update the new state properties: + +```swift +func play(_ track: Track) { + currentTrack = track + currentIndex = queue.firstIndex(where: { $0.id == track.id }) + halfwayReported = false + isPlaying = true + currentTime = 0 + + if let client = remoteClient { + guard let trackId = track.id else { return } + client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id))) + } else { + guard let url = URL(string: track.fileURL) else { return } + audio.play(url: url) + } +} +``` + +Update `next()`: + +```swift +func next() { + if isRemote { + remoteClient!.sendCommand(.next) + return + } + guard let idx = currentIndex else { return } + let nextIdx = idx + 1 + if nextIdx < queue.count { + play(queue[nextIdx]) + } else { + stop() + } +} +``` + +Update `previous()`: + +```swift +func previous() { + if isRemote { + remoteClient!.sendCommand(.previous) + return + } + guard let idx = currentIndex else { return } + let prevIdx = max(0, idx - 1) + play(queue[prevIdx]) +} +``` + +Update `toggleShuffle()`: + +```swift +func toggleShuffle() { + isShuffled.toggle() + if isRemote { + remoteClient!.sendCommand(.toggleShuffle) + return + } + if isShuffled { + queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) + } else { + queue = originalQueue + } + if let current = currentTrack { + currentIndex = queue.firstIndex(where: { $0.id == current.id }) + } +} +``` + +Update `trackDidFinish()` to guard against remote mode: + +```swift +private func trackDidFinish() { + guard !isRemote else { return } + if let track = currentTrack, let trackId = track.id, !halfwayReported { + let newCount = track.playCount + 1 + try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) + } + next() +} +``` + +- [ ] **Step 4: Add remote mode entry/exit methods** + +```swift +func enterRemoteMode(client: RemoteClient) { + audio.stop() + remoteClient = client + currentTrack = nil + currentIndex = nil + isPlaying = false + currentTime = 0 + duration = 0 + queue = [] + originalQueue = [] +} + +func exitRemoteMode() { + remoteClient = nil + trackResolver = nil + currentTrack = nil + currentIndex = nil + isPlaying = false + currentTime = 0 + duration = 0 + queue = [] + originalQueue = [] +} + +func applyRemoteState(_ state: PlaybackStatePayload) { + guard isRemote else { return } + isPlaying = state.isPlaying + currentTime = state.currentTime + duration = state.duration + volume = state.volume + isShuffled = state.isShuffled + + if let trackId = state.trackId, currentTrack?.id != trackId { + currentTrack = trackResolver?(trackId) + } else if state.trackId == nil { + currentTrack = nil + } +} +``` + +- [ ] **Step 5: Run existing PlayerViewModel tests** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/PlayerViewModelTests 2>&1 | tail -20` + +Expected: All 6 existing tests pass — they test queue/track logic, not playback state. + +- [ ] **Step 6: Commit** + +```bash +git add Music/Services/AudioService.swift Music/ViewModels/PlayerViewModel.swift +git commit -m "refactor: make PlayerViewModel single source of truth for all playback state" +``` + +--- + +### Task 7: Update ContentView to Read from PlayerViewModel + +This removes ContentView's direct dependency on AudioService. No behavior change — just rewiring where state is read from. + +**Files:** +- Modify: `Music/ContentView.swift` +- Modify: `Music/MusicApp.swift` + +- [ ] **Step 1: Remove `audio` property from ContentView and replace all references** + +In `ContentView.swift`: + +Remove the `audio` property: + +```swift +// DELETE: var audio: AudioService +``` + +Replace `playerControls`: + +```swift +private var playerControls: some View { + PlayerControlsView( + currentTrack: player.currentTrack, + isPlaying: player.isPlaying, + currentTime: player.currentTime, + duration: player.duration, + volume: player.volume, + isShuffled: player.isShuffled, + onPlayPause: { + if player.currentTrack == nil { + let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks + if let first = trackList.first { + player.setQueue(trackList) + player.play(first) + } + } else { + player.togglePlayPause() + } + }, + onNext: { player.next() }, + onPrevious: { player.previous() }, + onSeek: { player.seek(to: $0) }, + onScrubStart: { player.beginScrubbing() }, + onScrub: { player.scrub(to: $0) }, + onScrubEnd: { player.endScrubbing(at: $0) }, + onVolumeChange: { player.setVolume($0) }, + onShuffleToggle: { player.toggleShuffle() }, + onNowPlayingTap: { scrollToPlayingTrigger = UUID() } + ) +} +``` + +Remove the `.onChange(of: audio.currentTime)` handler — `checkHalfway` is now called internally by `syncFromAudio()`: + +```swift +// DELETE: .onChange(of: audio.currentTime) { _, _ in +// player.checkHalfway() +// } +``` + +Update `installKeyboardMonitor` — remove `audio` from capture list, replace `audio.togglePlayPause()` with `player.togglePlayPause()`: + +```swift +keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [player, library, playlist] event in + guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else { + return event + } + guard let responder = NSApp.keyWindow?.firstResponder, + !(responder is NSTextView) else { + return event + } + switch event.keyCode { + case 49: // space + if player.currentTrack == nil { + let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks + if let first = trackList.first { + player.setQueue(trackList) + player.play(first) + } + } else { + player.togglePlayPause() + } + return nil + case 123: // left arrow + player.previous() + return nil + case 124: // right arrow + player.next() + return nil + default: + return event + } +} +``` + +- [ ] **Step 2: Update MusicApp ContentView call to remove `audio` parameter** + +In `MusicApp.swift`, remove the `audio: audioService` parameter from the `ContentView(...)` call. + +- [ ] **Step 3: Build and run existing tests** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Executed|FAIL)'` + +Expected: All tests pass. This is a pure refactor — no behavior change. + +- [ ] **Step 4: Commit** + +```bash +git add Music/ContentView.swift Music/MusicApp.swift +git commit -m "refactor: remove AudioService from ContentView — all playback state via PlayerViewModel" +``` + +--- + +### Task 8: HostServer — Bonjour + HTTP + Command Channel + +**Files:** +- Create: `Music/Remote/HostServer.swift` +- Test: `MusicTests/HostServerIntegrationTests.swift` + +- [ ] **Step 1: Write failing integration test for DB download** + +```swift +// MusicTests/HostServerIntegrationTests.swift +import Testing +import Foundation +import Network +@testable import Music + +@MainActor +struct HostServerIntegrationTests { + + // Starts a HostServer, connects via TCP, sends GET /db, + // verifies the response is valid SQLite data. + @Test(.timeLimit(.minutes(1))) + func dbDownloadReturnsValidSQLite() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let dbPath = tempDir.appendingPathComponent("db.sqlite").path + let db = try DatabaseService(path: dbPath) + var track = Track.fixture(fileURL: "/test.mp3") + try db.insert(&track) + + let server = HostServer(dbPath: dbPath) + try server.start() + let port = server.actualPort! + + let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db") + + let header = String(data: responseData.prefix(16), encoding: .utf8) ?? "" + #expect(header.hasPrefix("SQLite format 3")) + + let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path + try responseData.write(to: URL(fileURLWithPath: downloadedPath)) + let downloadedDb = try DatabaseService(path: downloadedPath) + #expect(try downloadedDb.trackCount() == 1) + + server.stop() + try? FileManager.default.removeItem(at: tempDir) + } + + // Connects to /cmd, sends a pause command, verifies a playbackState event comes back. + @Test(.timeLimit(.minutes(1))) + func commandChannelRoundTrip() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let dbPath = tempDir.appendingPathComponent("db.sqlite").path + _ = try DatabaseService(path: dbPath) + + let audio = AudioService() + let player = PlayerViewModel(audio: audio, db: nil) + let server = HostServer(dbPath: dbPath) + server.configure(player: player, db: nil) + try server.start() + let port = server.actualPort! + + // Connect and send GET /cmd to establish command channel. + let connection = try await connectCommandChannel(host: "127.0.0.1", port: port) + + // Send a pause command. + let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause) + var lineData = pauseCmd + lineData.append(contentsOf: "\n".utf8) + connection.send(content: lineData, completion: .contentProcessed { _ in }) + + // Wait for a playbackState response. + let responseLine = try await receiveOneLine(on: connection) + let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8)) + + if case .playbackState(let payload) = event { + #expect(payload.isPlaying == false) + } else { + Issue.record("Expected playbackState, got \(event)") + } + + connection.cancel() + server.stop() + try? FileManager.default.removeItem(at: tempDir) + } + + // MARK: - Helpers + + private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state { + let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" + connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) + connection.receiveMessage { data, _, _, error in + if let error { + continuation.resume(throwing: error) + } else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { + continuation.resume(returning: Data(data[range.upperBound...])) + } else { + continuation.resume(returning: data ?? Data()) + } + connection.cancel() + } + } else if case .failed(let error) = state { + continuation.resume(throwing: error) + } + } + connection.start(queue: .main) + } + } + + private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection { + try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state { + let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n" + connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) + // Read the 200 OK response. + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in + continuation.resume(returning: connection) + } + } else if case .failed(let error) = state { + continuation.resume(throwing: error) + } + } + connection.start(queue: .main) + } + } + + private func receiveOneLine(on connection: NWConnection) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in + if let error { + continuation.resume(throwing: error) + } else { + let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" + continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text) + } + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` + +Expected: Compilation failure. + +- [ ] **Step 3: Implement HostServer** + +```swift +// Music/Remote/HostServer.swift +import Foundation +import Network +import Observation + +@MainActor +@Observable +final class HostServer { + var isHosting = false + var connectedRemoteName: String? + private(set) var actualPort: UInt16? + + private let dbPath: String + private var listener: NWListener? + private var commandTransport: NDJSONTransport? + private var player: PlayerViewModel? + private var db: DatabaseService? + private var stateTimer: Timer? + + init(dbPath: String) { + self.dbPath = dbPath + } + + func configure(player: PlayerViewModel, db: DatabaseService?) { + self.player = player + self.db = db + } + + func start() throws { + let params = NWParameters.tcp + params.includePeerToPeer = true + let listener = try NWListener(using: params) + listener.service = NWListener.Service(name: Host.current().localizedName, type: "_musicremote._tcp") + + listener.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + switch state { + case .ready: + if let port = listener.port?.rawValue { + self?.actualPort = port + RemoteLogger.host.info("Host started on port \(port)") + } + case .failed(let error): + RemoteLogger.host.error("Listener failed: \(error.localizedDescription)") + self?.stop() + default: break + } + } + } + + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in self?.handleNewConnection(connection) } + } + + listener.start(queue: .main) + self.listener = listener + isHosting = true + } + + func stop() { + stateTimer?.invalidate() + stateTimer = nil + commandTransport?.close() + commandTransport = nil + connectedRemoteName = nil + listener?.cancel() + listener = nil + actualPort = nil + isHosting = false + RemoteLogger.host.info("Host stopped") + } + + // MARK: - Connection Handling + + private func handleNewConnection(_ connection: NWConnection) { + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + switch state { + case .ready: + self?.receiveInitialRequest(on: connection) + case .failed(let error): + RemoteLogger.host.error("Connection failed: \(error.localizedDescription)") + connection.cancel() + case .cancelled: + if self?.commandTransport != nil { + self?.commandTransport = nil + self?.connectedRemoteName = nil + self?.stateTimer?.invalidate() + self?.stateTimer = nil + } + default: break + } + } + } + connection.start(queue: .main) + } + + private func receiveInitialRequest(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, error in + Task { @MainActor in + guard let self, let data, let request = String(data: data, encoding: .utf8) else { + if let error { RemoteLogger.host.error("Receive error: \(error.localizedDescription)") } + connection.cancel() + return + } + RemoteLogger.host.debug("Request: \(request.prefix(80))") + + if request.hasPrefix("GET /db") { + self.serveDatabase(on: connection) + } else if request.hasPrefix("GET /cmd") { + self.setupCommandChannel(on: connection) + } else { + self.sendHTTP(status: "404 Not Found", body: nil, on: connection, close: true) + } + } + } + } + + // MARK: - HTTP DB Download + + private func serveDatabase(on connection: NWConnection) { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: dbPath)) + RemoteLogger.host.info("Serving database: \(data.count) bytes") + sendHTTP(status: "200 OK", body: data, contentType: "application/octet-stream", on: connection, close: true) + } catch { + RemoteLogger.host.error("Failed to read database: \(error.localizedDescription)") + sendHTTP(status: "500 Internal Server Error", body: nil, on: connection, close: true) + } + } + + private func sendHTTP(status: String, body: Data?, contentType: String = "text/plain", on connection: NWConnection, close: Bool) { + let bodyLen = body?.count ?? 0 + let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyLen)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n" + var response = Data(header.utf8) + if let body { response.append(body) } + connection.send(content: response, completion: .contentProcessed { _ in + if close { connection.cancel() } + }) + } + + // MARK: - Command Channel + + private func setupCommandChannel(on connection: NWConnection) { + if commandTransport != nil { + RemoteLogger.host.info("Rejecting second remote — already connected") + sendHTTP(status: "409 Conflict", body: nil, on: connection, close: true) + return + } + + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nConnection: keep-alive\r\n\r\n" + connection.send(content: Data(response.utf8), completion: .contentProcessed { [weak self] _ in + Task { @MainActor in + guard let self else { return } + let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.host) + transport.onLine = { [weak self] line in self?.handleCommandLine(line) } + transport.onClose = { [weak self] in + Task { @MainActor in + RemoteLogger.host.info("Remote disconnected") + self?.commandTransport = nil + self?.connectedRemoteName = nil + self?.stateTimer?.invalidate() + self?.stateTimer = nil + } + } + transport.startReceiving() + + self.commandTransport = transport + self.connectedRemoteName = "Remote" + self.startStateUpdates() + RemoteLogger.host.info("Command channel established") + } + }) + } + + private func handleCommandLine(_ line: String) { + guard let data = line.data(using: .utf8) else { return } + + if let handshake = try? JSONDecoder().decode(HandshakeMessage.self, from: data) { + RemoteLogger.host.info("Handshake: protocol v\(handshake.protocolVersion), app v\(handshake.appVersion)") + if handshake.protocolVersion != RemoteProtocolVersion { + RemoteLogger.host.error("Protocol mismatch: host v\(RemoteProtocolVersion), remote v\(handshake.protocolVersion)") + commandTransport?.send(HostEvent.error(message: "Protocol version mismatch")) + } + connectedRemoteName = handshake.appVersion + return + } + + do { + let command = try JSONDecoder().decode(RemoteCommand.self, from: data) + RemoteLogger.host.debug("Command: \(line.prefix(80))") + executeCommand(command) + } catch { + RemoteLogger.host.error("Decode failed: \(error.localizedDescription)") + } + } + + private func executeCommand(_ command: RemoteCommand) { + guard let player else { return } + + switch command { + case .play(let trackId, let queueIds): + guard let db else { + commandTransport?.send(HostEvent.error(message: "No database available")) + return + } + do { + let tracks = try db.fetchTracksByIds(queueIds) + guard let track = tracks.first(where: { $0.id == trackId }) else { + commandTransport?.send(HostEvent.error(message: "Track not found")) + return + } + player.setQueue(tracks) + player.play(track) + } catch { + RemoteLogger.host.error("Failed to resolve tracks: \(error.localizedDescription)") + commandTransport?.send(HostEvent.error(message: "Failed to load tracks")) + } + case .pause: player.pause() + case .resume: player.resume() + case .next: player.next() + case .previous: player.previous() + case .seek(let position): player.seek(to: position) + case .setVolume(let level): player.setVolume(level) + case .toggleShuffle: player.toggleShuffle() + case .refreshDB: + RemoteLogger.host.info("DB refresh requested") + commandTransport?.send(HostEvent.dbReady) + } + + sendCurrentState() + } + + private func sendCurrentState() { + guard let player, let transport = commandTransport else { return } + transport.send(HostEvent.playbackState(PlaybackStatePayload( + trackId: player.currentTrack?.id, + isPlaying: player.isPlaying, + currentTime: player.currentTime, + duration: player.duration, + volume: player.volume, + isShuffled: player.isShuffled + ))) + } + + private func startStateUpdates() { + stateTimer?.invalidate() + stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in self?.sendCurrentState() } + } + } +} +``` + +- [ ] **Step 4: Run integration tests** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` + +Expected: Both tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Remote/HostServer.swift MusicTests/HostServerIntegrationTests.swift +git commit -m "feat(remote): add HostServer with Bonjour, HTTP DB download, and NDJSON command channel" +``` + +--- + +### Task 9: RemoteClient — Discovery, DB Download, Command Channel + +**Files:** +- Create: `Music/Remote/RemoteClient.swift` + +- [ ] **Step 1: Implement RemoteClient** + +```swift +// Music/Remote/RemoteClient.swift +import Foundation +import Network +import Observation + +@MainActor +@Observable +final class RemoteClient { + var connectionState = ConnectionState.disconnected + var discoveredHosts: [(name: String, endpoint: NWEndpoint)] = [] + var onPlaybackState: ((PlaybackStatePayload) -> Void)? + var onDBReady: (() -> Void)? + + private var browser: NWBrowser? + private var commandTransport: NDJSONTransport? + private var hostEndpoint: NWEndpoint? + private var pingTimer: Timer? + private var missedPings = 0 + + // MARK: - Discovery + + func startDiscovery() { + transition(to: .discovering) + discoveredHosts = [] + + let params = NWParameters() + params.includePeerToPeer = true + let browser = NWBrowser(for: .bonjour(type: "_musicremote._tcp", domain: nil), using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + if case .failed(let error) = state { + RemoteLogger.client.error("Browser failed: \(error.localizedDescription)") + self?.transition(to: .disconnected) + } + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + self?.discoveredHosts = results.compactMap { result in + if case .service(let name, _, _, _) = result.endpoint { + return (name: name, endpoint: result.endpoint) + } + return nil + } + RemoteLogger.client.info("Discovered \(results.count) host(s)") + } + } + + browser.start(queue: .main) + self.browser = browser + } + + func stopDiscovery() { + browser?.cancel() + browser = nil + discoveredHosts = [] + if case .discovering = connectionState { transition(to: .disconnected) } + } + + // MARK: - Connect / Disconnect + + func connect(to host: (name: String, endpoint: NWEndpoint)) { + transition(to: .foundHost(host.name)) + browser?.cancel() + browser = nil + + let connection = NWConnection(to: host.endpoint, using: .tcp) + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + switch state { + case .ready: + self?.hostEndpoint = host.endpoint + self?.downloadDatabase(on: connection, hostName: host.name) + case .failed(let error): + RemoteLogger.client.error("Connection failed: \(error.localizedDescription)") + self?.transition(to: .connectionLost(error.localizedDescription)) + default: break + } + } + } + connection.start(queue: .main) + } + + func disconnect() { + pingTimer?.invalidate() + pingTimer = nil + commandTransport?.close() + commandTransport = nil + browser?.cancel() + browser = nil + discoveredHosts = [] + transition(to: .disconnected) + RemoteLogger.client.info("Disconnected") + try? FileManager.default.removeItem(atPath: Self.remoteDBPath) + } + + func sendCommand(_ command: RemoteCommand) { + guard let transport = commandTransport else { + RemoteLogger.client.error("No command channel — cannot send") + return + } + transport.send(command) + RemoteLogger.client.debug("Sent: \(String(describing: command))") + } + + // MARK: - DB Download + + static var remoteDBPath: String { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.appendingPathComponent("Music", isDirectory: true) + return appSupport.appendingPathComponent("remote_db.sqlite").path + } + + private func downloadDatabase(on connection: NWConnection, hostName: String) { + transition(to: .downloadingDB) + let startTime = Date() + let request = "GET /db HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" + + connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in + if let error { + Task { @MainActor in + RemoteLogger.client.error("DB request failed: \(error.localizedDescription)") + self?.transition(to: .connectionLost(error.localizedDescription)) + } + return + } + connection.receiveMessage { [weak self] data, _, _, error in + Task { @MainActor in self?.handleDBResponse(data: data, error: error, startTime: startTime, hostName: hostName) } + } + }) + } + + private func handleDBResponse(data: Data?, error: NWError?, startTime: Date, hostName: String) { + if let error { + RemoteLogger.client.error("DB download error: \(error.localizedDescription)") + transition(to: .connectionLost(error.localizedDescription)) + return + } + guard var data else { + RemoteLogger.client.error("DB download returned no data") + transition(to: .connectionLost("No data received")) + return + } + if let range = data.range(of: Data("\r\n\r\n".utf8)) { + data = Data(data[range.upperBound...]) + } + + let elapsed = Date().timeIntervalSince(startTime) + RemoteLogger.client.info("DB downloaded (\(String(format: "%.1f", Double(data.count) / 1024)) KB, \(String(format: "%.0f", elapsed * 1000))ms)") + + do { + let url = URL(fileURLWithPath: Self.remoteDBPath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url) + } catch { + RemoteLogger.client.error("Failed to save remote DB: \(error.localizedDescription)") + transition(to: .connectionLost("Failed to save database")) + return + } + + connectCommandChannel(hostName: hostName) + } + + // MARK: - Command Channel + + private func connectCommandChannel(hostName: String) { + transition(to: .connectingCommandChannel) + guard let endpoint = hostEndpoint else { + transition(to: .connectionLost("No host endpoint")) + return + } + + let connection = NWConnection(to: endpoint, using: .tcp) + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + switch state { + case .ready: + let request = "GET /cmd HTTP/1.1\r\nHost: local\r\nConnection: keep-alive\r\n\r\n" + connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in + Task { @MainActor in self?.commandChannelReady(connection: connection, hostName: hostName) } + } + case .failed(let error): + RemoteLogger.client.error("Command channel failed: \(error.localizedDescription)") + self?.transition(to: .connectionLost(error.localizedDescription)) + default: break + } + } + } + connection.start(queue: .main) + } + + private func commandChannelReady(connection: NWConnection, hostName: String) { + let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.client) + transport.onLine = { [weak self] line in self?.handleEventLine(line) } + transport.onClose = { [weak self] in + Task { @MainActor in + self?.commandTransport = nil + self?.transition(to: .connectionLost("Host closed connection")) + self?.pingTimer?.invalidate() + self?.pingTimer = nil + } + } + transport.startReceiving() + + self.commandTransport = transport + transition(to: .connected(hostName)) + + let handshake = HandshakeMessage( + protocolVersion: RemoteProtocolVersion, + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + ) + transport.send(handshake) + startPingTimer() + } + + private func handleEventLine(_ line: String) { + guard !line.isEmpty, let data = line.data(using: .utf8) else { return } + do { + let event = try JSONDecoder().decode(HostEvent.self, from: data) + switch event { + case .playbackState(let payload): + missedPings = 0 + onPlaybackState?(payload) + case .dbReady: + RemoteLogger.client.info("Host signals DB ready for re-download") + onDBReady?() + case .error(let message): + RemoteLogger.client.error("Host error: \(message)") + } + } catch { + RemoteLogger.client.error("Decode failed: \(error.localizedDescription)") + } + } + + // MARK: - Keep-alive + + private func startPingTimer() { + missedPings = 0 + pingTimer?.invalidate() + pingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.missedPings += 1 + if self.missedPings >= 3 { + RemoteLogger.client.error("No response from host for 15s — connection lost") + self.transition(to: .connectionLost("Host not responding")) + self.commandTransport?.close() + self.commandTransport = nil + self.pingTimer?.invalidate() + self.pingTimer = nil + } + } + } + } + + // MARK: - State + + private func transition(to newState: ConnectionState) { + let oldState = connectionState + if oldState == newState { return } + guard oldState.canTransition(to: newState) else { + RemoteLogger.client.error("Invalid transition: \(String(describing: oldState)) → \(String(describing: newState))") + return + } + connectionState = newState + RemoteLogger.client.info("State: \(newState.userMessage ?? "disconnected")") + } +} +``` + +- [ ] **Step 2: Build and verify** + +Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` + +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add Music/Remote/RemoteClient.swift +git commit -m "feat(remote): add RemoteClient with Bonjour discovery, DB download, NDJSON command channel" +``` + +--- + +### Task 10: NetworkStatus Model + +A small value type that encapsulates what ContentView needs to know about host/remote mode. Keeps networking details out of the View. + +**Files:** +- Create: `Music/Remote/NetworkStatus.swift` + +- [ ] **Step 1: Implement NetworkStatus** + +```swift +// Music/Remote/NetworkStatus.swift +import Foundation + +struct NetworkStatus { + enum Mode { + case hosting(connectedRemote: String?) + case remote(hostName: String) + } + + var mode: Mode + var onDisconnect: (() -> Void)? + var onRefreshLibrary: (() -> Void)? + + var isRemoteMode: Bool { + if case .remote = mode { return true } + return false + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Music/Remote/NetworkStatus.swift +git commit -m "feat(remote): add NetworkStatus model for View-layer network state" +``` + +--- + +### Task 11: Wire Host/Remote into MusicApp + +**Files:** +- Modify: `Music/MusicApp.swift` + +- [ ] **Step 1: Add state properties** + +Add to `MusicApp`: + +```swift +@State private var hostServer: HostServer? +@State private var remoteClient = RemoteClient() +@State private var showConnectionSheet = false +``` + +- [ ] **Step 2: Add menu items** + +Replace the `.commands { ... }` block: + +```swift +.commands { + CommandGroup(after: .newItem) { + Button("Open Music Folder...") { + pickFolder() + } + .keyboardShortcut("o") + .disabled(remoteClient.connectionState.isConnected) + + Button("New Playlist...") { + showNewPlaylistAlert = true + } + .keyboardShortcut("n") + .disabled(remoteClient.connectionState.isConnected) + + Divider() + + Toggle("Enable Host Mode", isOn: Binding( + get: { hostServer?.isHosting ?? false }, + set: { $0 ? startHosting() : hostServer?.stop() } + )) + .disabled(remoteClient.connectionState.isConnected) + + Button("Connect to Remote...") { + showConnectionSheet = true + remoteClient.startDiscovery() + } + .disabled(hostServer?.isHosting ?? false) + } +} +``` + +- [ ] **Step 3: Add onChange for remote connection state and DB swap** + +Add to the `Group` in `body`, after `.frame(...)`: + +```swift +.onChange(of: remoteClient.connectionState) { _, newState in + if case .connected = newState { + enterRemoteMode() + } else if newState == .disconnected { + exitRemoteMode() + } +} +.sheet(isPresented: $showConnectionSheet) { + ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet) +} +``` + +- [ ] **Step 4: Add remote mode enter/exit methods** + +```swift +private func enterRemoteMode() { + guard let player = playerVM else { return } + do { + let remoteDb = try DatabaseService(path: RemoteClient.remoteDBPath) + self.libraryVM = LibraryViewModel(db: remoteDb) + self.playlistVM = PlaylistViewModel(db: remoteDb) + + player.enterRemoteMode(client: remoteClient) + player.trackResolver = { [weak self] trackId in + self?.libraryVM?.tracks.first(where: { $0.id == trackId }) + } + + remoteClient.onPlaybackState = { [weak player] state in + player?.applyRemoteState(state) + } + } catch { + print("Failed to load remote DB: \(error)") + remoteClient.disconnect() + } +} + +private func exitRemoteMode() { + playerVM?.exitRemoteMode() + remoteClient.onPlaybackState = nil + guard let db = dbService else { return } + self.libraryVM = LibraryViewModel(db: db) + self.playlistVM = PlaylistViewModel(db: db) + try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath) +} +``` + +- [ ] **Step 5: Add startHosting method** + +```swift +private func startHosting() { + guard let db = dbService, let player = playerVM else { return } + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.appendingPathComponent("Music", isDirectory: true) + let dbPath = appSupport.appendingPathComponent("db.sqlite").path + + let server = HostServer(dbPath: dbPath) + server.configure(player: player, db: db) + do { + try server.start() + hostServer = server + } catch { + print("Failed to start host: \(error)") + } +} +``` + +- [ ] **Step 6: Update ContentView call to pass networkStatus** + +Compute `networkStatus` and pass it: + +```swift +ContentView( + library: library, + player: player, + scanner: scanner, + playlist: playlist, + shazam: shazamService, + db: db, + showNewPlaylistAlert: $showNewPlaylistAlert, + networkStatus: computeNetworkStatus() +) +``` + +```swift +private func computeNetworkStatus() -> NetworkStatus? { + if remoteClient.connectionState.isConnected { + let hostName: String + if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" } + return NetworkStatus( + mode: .remote(hostName: hostName), + onDisconnect: { [remoteClient] in remoteClient.disconnect() }, + onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) } + ) + } + if let server = hostServer, server.isHosting { + return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName)) + } + return nil +} +``` + +- [ ] **Step 7: Build and verify** + +Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` + +Expected: Build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add Music/MusicApp.swift +git commit -m "feat(remote): wire HostServer and RemoteClient into MusicApp with menu items and DB swap" +``` + +--- + +### Task 12: ConnectionSheet UI + +**Files:** +- Create: `Music/Views/ConnectionSheet.swift` + +- [ ] **Step 1: Implement ConnectionSheet** + +```swift +// Music/Views/ConnectionSheet.swift +import SwiftUI + +struct ConnectionSheet: View { + var remoteClient: RemoteClient + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 16) { + Text("Connect to Host") + .font(.headline) + + if let message = remoteClient.connectionState.userMessage { + HStack(spacing: 8) { + if !remoteClient.connectionState.isConnected && + remoteClient.connectionState != .disconnected { + ProgressView().controlSize(.small) + } + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if case .connectionLost(let reason) = remoteClient.connectionState { + VStack(spacing: 8) { + Text(reason).font(.caption).foregroundStyle(.red) + Button("Retry") { remoteClient.startDiscovery() } + } + } + + if remoteClient.discoveredHosts.isEmpty && remoteClient.connectionState == .discovering { + VStack(spacing: 8) { + ProgressView() + Text("Looking for hosts on your network...") + .font(.caption).foregroundStyle(.secondary) + } + .frame(height: 100) + } else { + List(remoteClient.discoveredHosts, id: \.name) { host in + HStack { + Image(systemName: "desktopcomputer").foregroundStyle(.secondary) + Text(host.name) + Spacer() + Button("Connect") { remoteClient.connect(to: host) } + .buttonStyle(.borderedProminent).controlSize(.small) + } + .padding(.vertical, 4) + } + .frame(minHeight: 100, maxHeight: 200) + } + + Button("Cancel") { + remoteClient.stopDiscovery() + isPresented = false + } + .keyboardShortcut(.cancelAction) + } + .padding(20) + .frame(width: 380) + .onChange(of: remoteClient.connectionState) { _, newState in + if case .connected = newState { isPresented = false } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add Music/Views/ConnectionSheet.swift +git commit -m "feat(remote): add ConnectionSheet for discovering and connecting to hosts" +``` + +--- + +### Task 13: ContentView — Banner, Disable Writes, Host Indicator + +**Files:** +- Modify: `Music/ContentView.swift` +- Modify: `Music/Views/PlaylistBarView.swift` + +- [ ] **Step 1: Add networkStatus property to ContentView** + +Replace the `audio` property with: + +```swift +var networkStatus: NetworkStatus? +``` + +- [ ] **Step 2: Add network status banners** + +Insert right after the opening `VStack(spacing: 0) {` in `body`: + +```swift +if let status = networkStatus { + switch status.mode { + case .remote(let hostName): + HStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 10)).foregroundStyle(.blue) + Text("Connected to \(hostName)") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.blue) + Spacer() + Button("Refresh") { status.onRefreshLibrary?() } + .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary) + Button("Disconnect") { status.onDisconnect?() } + .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red) + } + .padding(.horizontal, 12).padding(.vertical, 4) + .background(Color.blue.opacity(0.08)) + case .hosting(let remoteName): + HStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 10)).foregroundStyle(.green) + Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.green) + Spacer() + } + .padding(.horizontal, 12).padding(.vertical, 4) + .background(Color.green.opacity(0.08)) + } +} +``` + +- [ ] **Step 3: Disable playlist context menus in remote mode** + +In `PlaylistBarView.swift`, add property: + +```swift +var isRemoteMode: Bool = false +``` + +Wrap the context menu content: + +```swift +.contextMenu { + if !isRemoteMode { + Button("Rename...") { onRename(item) } + if let smart = item as? SmartPlaylist { + Button("Edit Search Query...") { onEditQuery(smart) } + } + Button("Delete") { onDelete(item) } + } +} +``` + +- [ ] **Step 4: Pass isRemoteMode to PlaylistBarView** + +In `ContentView.swift`, update the `PlaylistBarView(...)` call: + +```swift +PlaylistBarView( + playlists: playlist.allPlaylists, + selectedItem: showHome ? nil : playlist.selectedItem, + isHomeSelected: showHome, + isRemoteMode: networkStatus?.isRemoteMode ?? false, + // ... rest unchanged +``` + +- [ ] **Step 5: Build and verify** + +Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` + +Expected: Build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add Music/ContentView.swift Music/Views/PlaylistBarView.swift +git commit -m "feat(remote): add network status banners, disable write actions in remote mode" +``` + +--- + +### Task 14: Run All Tests + +**Files:** None — verification only. + +- [ ] **Step 1: Run the full test suite** + +Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Test Suite|Test Case|Executed|FAIL)'` + +Expected: All tests pass — existing tests unchanged, new tests (RemoteProtocol, ConnectionState, NDJSONTransport, HostServerIntegration) pass. + +- [ ] **Step 2: Fix any failures and commit** + +--- + +### Task 15: Manual Testing + +- [ ] **Step 1: Test Host Mode** + +1. Launch app → File → "Enable Host Mode" → verify toggle works +2. Open Console.app, filter "com.music.remote" → verify "Host started on port XXXX" +3. Verify green "Hosting" banner appears +4. Toggle off → verify "Host stopped" and banner disappears + +- [ ] **Step 2: Test Remote Connection** + +1. On second machine, launch app → File → "Connect to Remote..." +2. Verify host appears in list → click "Connect" +3. Verify DB downloads, blue "Connected to [name]" banner appears +4. Verify library shows host's tracks + +- [ ] **Step 3: Test Remote Playback** + +1. Double-click a track → audio plays on host, not remote +2. Play/pause, next, previous, seek, volume, shuffle all work +3. Now-playing indicator updates on remote + +- [ ] **Step 4: Test Error Handling** + +1. Click "Disconnect" → returns to local mode cleanly +2. Kill host app mid-playback → "Connection lost" appears on remote +3. Check Console.app for clear diagnostic logs at every step diff --git a/docs/superpowers/plans/2026-05-27-music-streaming.md b/docs/superpowers/plans/2026-05-27-music-streaming.md new file mode 100644 index 0000000..9154951 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-music-streaming.md @@ -0,0 +1,3010 @@ +# Music Streaming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add internet-based HLS music streaming so a client app can browse the host's library and play audio remotely over HTTPS, exposed through a Cloudflare Tunnel. + +**Architecture:** A `PlaybackProvider` protocol abstracts local/remote/streaming playback so `PlayerViewModel` is role-agnostic. An `AppRole` enum (`local`, `remoteHost`, `remoteClient`, `streamHost`, `streamClient`) drives which capabilities are active. A `MusicShared` local Swift package holds wire protocol types, HLS manifest generation, and constants for future iOS reuse. The host runs a Hummingbird HTTP server serving HLS-segmented MP3s; the client uses `AVPlayer` with HLS URLs. + +**Tech Stack:** Swift, SwiftUI, Hummingbird 2.x (HTTP server), HummingbirdWebSocket (WebSocket), AVFoundation (HLS playback + segment extraction), Cloudflare Tunnel, URLSession + +--- + +## File Structure + +### MusicShared/ (new local Swift package at repo root) + +``` +MusicShared/ +├── Package.swift +├── Sources/MusicShared/ +│ ├── RemoteProtocol.swift ← moved from Music/Remote/RemoteProtocol.swift +│ ├── AppRole.swift ← new: role enum +│ ├── HLSManifestGenerator.swift ← new: .m3u8 generation (pure logic) +│ ├── StreamingRoutes.swift ← new: route path constants +│ ├── StreamingConstants.swift ← new: segment duration, port, etc. +│ └── APIModels.swift ← new: AuthResponse, DBMetadata DTOs +└── Tests/MusicSharedTests/ + ├── RemoteProtocolTests.swift ← moved from MusicTests/RemoteProtocolTests.swift + └── HLSManifestGeneratorTests.swift ← new +``` + +### Music/ (app target — new files) + +``` +Music/ +├── Protocols/ +│ ├── PlaylistRepresentable.swift (existing, unchanged) +│ └── PlaybackProvider.swift ← new: playback abstraction protocol +├── Providers/ +│ ├── LocalPlaybackProvider.swift ← new: wraps AudioService +│ ├── RemotePlaybackProvider.swift ← new: sends commands over NDJSON (extracted from PlayerViewModel) +│ └── StreamingPlaybackProvider.swift ← new: AVPlayer + HLS URLs +├── Streaming/ +│ ├── StreamingServer.swift ← new: Hummingbird HTTP + WebSocket server +│ ├── HLSSegmenter.swift ← new: AVAssetReader segment extraction +│ ├── TunnelManager.swift ← new: cloudflared process management +│ └── StreamingClient.swift ← new: HTTP client + WebSocket for streaming mode +``` + +### Music/ (app target — modified files) + +``` +Music/ +├── MusicApp.swift ← modified: role switching, streaming server/client wiring +├── ViewModels/ +│ └── PlayerViewModel.swift ← modified: use PlaybackProvider instead of direct AudioService +├── Remote/ +│ ├── RemoteProtocol.swift ← deleted (moved to MusicShared) +│ ├── NetworkStatus.swift ← modified: add streaming modes +│ └── HostServer.swift ← modified: import MusicShared +│ └── RemoteClient.swift ← modified: import MusicShared +├── ContentView.swift ← modified: streaming UI indicators +``` + +### MusicTests/ (test target — modified) + +``` +MusicTests/ +├── RemoteProtocolTests.swift ← deleted (moved to MusicSharedTests) +├── PlayerViewModelTests.swift ← modified: use PlaybackProvider +├── HLSSegmenterTests.swift ← new +├── StreamingServerTests.swift ← new +├── TunnelManagerTests.swift ← new +``` + +--- + +## Task 1: Create MusicShared Package and Move RemoteProtocol + +**Files:** +- Create: `MusicShared/Package.swift` +- Move: `Music/Remote/RemoteProtocol.swift` → `MusicShared/Sources/MusicShared/RemoteProtocol.swift` +- Move: `MusicTests/RemoteProtocolTests.swift` → `MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift` +- Modify: `Music/Remote/HostServer.swift` (add `import MusicShared`) +- Modify: `Music/Remote/RemoteClient.swift` (add `import MusicShared`) +- Modify: `Music/ViewModels/PlayerViewModel.swift` (add `import MusicShared`) +- Modify: `Music/Remote/NDJSONTransport.swift` (add `import MusicShared` if it references protocol types) +- Modify: `Music.xcodeproj/project.pbxproj` (remove old file refs, add package dependency) + +- [ ] **Step 1: Create the MusicShared package directory and Package.swift** + +```bash +mkdir -p MusicShared/Sources/MusicShared +mkdir -p MusicShared/Tests/MusicSharedTests +``` + +Create `MusicShared/Package.swift`: + +```swift +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "MusicShared", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library(name: "MusicShared", targets: ["MusicShared"]), + ], + targets: [ + .target(name: "MusicShared"), + .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), + ] +) +``` + +- [ ] **Step 2: Move RemoteProtocol.swift to MusicShared** + +```bash +cp Music/Remote/RemoteProtocol.swift MusicShared/Sources/MusicShared/RemoteProtocol.swift +``` + +Edit the copied file: add `public` access to all types, properties, initializers, and the `RemoteProtocolVersion` constant. Every type and member that is used outside the package must be `public`. + +`MusicShared/Sources/MusicShared/RemoteProtocol.swift`: + +```swift +import Foundation + +// MARK: - Protocol Version + +public nonisolated let RemoteProtocolVersion: Int = 1 + +// MARK: - Supporting Types + +public nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { + public var trackId: Int64? + public var isPlaying: Bool + public var currentTime: Double + public var duration: Double + public var volume: Float + public var isShuffled: Bool + + public init(trackId: Int64? = nil, isPlaying: Bool, currentTime: Double, duration: Double, volume: Float, isShuffled: Bool) { + self.trackId = trackId + self.isPlaying = isPlaying + self.currentTime = currentTime + self.duration = duration + self.volume = volume + self.isShuffled = isShuffled + } +} + +public nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { + public var protocolVersion: Int + public var appVersion: String + + public init(protocolVersion: Int, appVersion: String) { + self.protocolVersion = protocolVersion + self.appVersion = appVersion + } +} + +// MARK: - RemoteCommand + +public nonisolated enum RemoteCommand: Equatable, Sendable { + case play(trackId: Int64, queueIds: [Int64]) + case pause + case resume + case next + case previous + case seek(position: Double) + case setVolume(level: Float) + case toggleShuffle + case refreshDB +} + +extension RemoteCommand: Codable { + private enum TypeKey: String, Codable { + case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB + } + + private enum CodingKeys: String, CodingKey { + case type, payload + } + + private struct PlayPayload: Codable { + var trackId: Int64 + var queueIds: [Int64] + } + + private struct SeekPayload: Codable { + var position: Double + } + + private struct VolumePayload: Codable { + var level: Float + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .play(let trackId, let queueIds): + try container.encode(TypeKey.play, forKey: .type) + try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) + case .pause: + try container.encode(TypeKey.pause, forKey: .type) + case .resume: + try container.encode(TypeKey.resume, forKey: .type) + case .next: + try container.encode(TypeKey.next, forKey: .type) + case .previous: + try container.encode(TypeKey.previous, forKey: .type) + case .seek(let position): + try container.encode(TypeKey.seek, forKey: .type) + try container.encode(SeekPayload(position: position), forKey: .payload) + case .setVolume(let level): + try container.encode(TypeKey.setVolume, forKey: .type) + try container.encode(VolumePayload(level: level), forKey: .payload) + case .toggleShuffle: + try container.encode(TypeKey.toggleShuffle, forKey: .type) + case .refreshDB: + try container.encode(TypeKey.refreshDB, forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeKey.self, forKey: .type) + switch type { + case .play: + let payload = try container.decode(PlayPayload.self, forKey: .payload) + self = .play(trackId: payload.trackId, queueIds: payload.queueIds) + case .pause: + self = .pause + case .resume: + self = .resume + case .next: + self = .next + case .previous: + self = .previous + case .seek: + let payload = try container.decode(SeekPayload.self, forKey: .payload) + self = .seek(position: payload.position) + case .setVolume: + let payload = try container.decode(VolumePayload.self, forKey: .payload) + self = .setVolume(level: payload.level) + case .toggleShuffle: + self = .toggleShuffle + case .refreshDB: + self = .refreshDB + } + } +} + +// MARK: - HostEvent + +public nonisolated enum HostEvent: Equatable, Sendable { + case playbackState(PlaybackStatePayload) + case dbReady + case error(message: String) +} + +extension HostEvent: Codable { + private enum TypeKey: String, Codable { + case playbackState, dbReady, error + } + + private enum CodingKeys: String, CodingKey { + case type, payload + } + + private struct ErrorPayload: Codable { + var message: String + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .playbackState(let payload): + try container.encode(TypeKey.playbackState, forKey: .type) + try container.encode(payload, forKey: .payload) + case .dbReady: + try container.encode(TypeKey.dbReady, forKey: .type) + case .error(let message): + try container.encode(TypeKey.error, forKey: .type) + try container.encode(ErrorPayload(message: message), forKey: .payload) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeKey.self, forKey: .type) + switch type { + case .playbackState: + let payload = try container.decode(PlaybackStatePayload.self, forKey: .payload) + self = .playbackState(payload) + case .dbReady: + self = .dbReady + case .error: + let payload = try container.decode(ErrorPayload.self, forKey: .payload) + self = .error(message: payload.message) + } + } +} +``` + +- [ ] **Step 3: Move RemoteProtocolTests.swift to MusicSharedTests** + +```bash +cp MusicTests/RemoteProtocolTests.swift MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift +``` + +Edit the copied test file: change `@testable import Music` to `@testable import MusicShared`. Remove the `@MainActor` annotation if present (MusicShared types are `nonisolated`). + +- [ ] **Step 4: Run MusicShared tests to verify the move** + +```bash +cd MusicShared && swift test +``` + +Expected: all RemoteProtocol tests pass. + +- [ ] **Step 5: Integrate MusicShared into the Xcode project** + +In Xcode: +1. Drag the `MusicShared/` folder into the project navigator (at the root level). +2. Xcode detects it as a local Swift package automatically. +3. Select the Music app target → General → "Frameworks, Libraries, and Embedded Content" → click `+` → select `MusicShared` library. +4. Delete the original `Music/Remote/RemoteProtocol.swift` from the project (Move to Trash). +5. Delete `MusicTests/RemoteProtocolTests.swift` from the project (Move to Trash). + +- [ ] **Step 6: Add `import MusicShared` to all files that reference protocol types** + +Add `import MusicShared` to the top of these files: +- `Music/Remote/HostServer.swift` +- `Music/Remote/RemoteClient.swift` +- `Music/Remote/NDJSONTransport.swift` +- `Music/ViewModels/PlayerViewModel.swift` + +- [ ] **Step 7: Build the Xcode project to verify everything compiles** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 8: Commit** + +```bash +git add MusicShared/ Music/Remote/ Music/ViewModels/PlayerViewModel.swift MusicTests/ Music.xcodeproj +git commit -m "refactor: extract MusicShared package, move RemoteProtocol" +``` + +--- + +## Task 2: Add AppRole Enum to MusicShared + +**Files:** +- Create: `MusicShared/Sources/MusicShared/AppRole.swift` + +- [ ] **Step 1: Create AppRole.swift** + +`MusicShared/Sources/MusicShared/AppRole.swift`: + +```swift +import Foundation + +public enum AppRole: String, Codable, CaseIterable, Sendable { + case local + case remoteHost + case remoteClient + case streamHost + case streamClient +} + +extension AppRole { + public var isHost: Bool { self == .remoteHost || self == .streamHost } + public var isClient: Bool { self == .remoteClient || self == .streamClient } + public var isLocal: Bool { self == .local } + public var usesLocalAudio: Bool { self == .local || self == .remoteHost || self == .streamClient } + public var isReadOnlyLibrary: Bool { self == .remoteClient || self == .streamClient } + public var needsNetworkServer: Bool { self == .remoteHost || self == .streamHost } + public var isStreaming: Bool { self == .streamHost || self == .streamClient } +} +``` + +- [ ] **Step 2: Build MusicShared to verify** + +```bash +cd MusicShared && swift build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add MusicShared/Sources/MusicShared/AppRole.swift +git commit -m "feat: add AppRole enum to MusicShared" +``` + +--- + +## Task 3: Add StreamingConstants, Routes, and APIModels to MusicShared + +**Files:** +- Create: `MusicShared/Sources/MusicShared/StreamingConstants.swift` +- Create: `MusicShared/Sources/MusicShared/StreamingRoutes.swift` +- Create: `MusicShared/Sources/MusicShared/APIModels.swift` + +- [ ] **Step 1: Create StreamingConstants.swift** + +```swift +import Foundation + +public enum StreamingConstants: Sendable { + public static let defaultPort: Int = 8420 + public static let segmentDuration: Double = 6.0 + public static let protocolVersion: Int = 1 +} +``` + +- [ ] **Step 2: Create StreamingRoutes.swift** + +```swift +import Foundation + +public enum StreamingRoutes: Sendable { + public static let auth = "/auth" + public static let db = "/db" + public static let ws = "/ws" + + public static func trackManifest(trackId: Int64) -> String { + "/tracks/\(trackId)/stream.m3u8" + } + + public static func trackSegment(trackId: Int64, index: Int) -> String { + "/tracks/\(trackId)/segments/\(index).mp3" + } + + public static func trackManifestPattern() -> String { + "/tracks/:trackId/stream.m3u8" + } + + public static func trackSegmentPattern() -> String { + "/tracks/:trackId/segments/:index" + } +} +``` + +- [ ] **Step 3: Create APIModels.swift** + +```swift +import Foundation + +public struct AuthResponse: Codable, Equatable, Sendable { + public var hostName: String + public var protocolVersion: Int + + public init(hostName: String, protocolVersion: Int) { + self.hostName = hostName + self.protocolVersion = protocolVersion + } +} + +public struct DBMetadata: Codable, Equatable, Sendable { + public var checksum: String + public var trackCount: Int + + public init(checksum: String, trackCount: Int) { + self.checksum = checksum + self.trackCount = trackCount + } +} +``` + +- [ ] **Step 4: Build MusicShared to verify** + +```bash +cd MusicShared && swift build +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add MusicShared/Sources/MusicShared/StreamingConstants.swift \ + MusicShared/Sources/MusicShared/StreamingRoutes.swift \ + MusicShared/Sources/MusicShared/APIModels.swift +git commit -m "feat: add StreamingConstants, Routes, and APIModels to MusicShared" +``` + +--- + +## Task 4: Add HLSManifestGenerator (TDD) + +**Files:** +- Create: `MusicShared/Sources/MusicShared/HLSManifestGenerator.swift` +- Create: `MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift` + +- [ ] **Step 1: Write the failing tests** + +`MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift`: + +```swift +import Testing +@testable import MusicShared + +struct HLSManifestGeneratorTests { + // Generates a manifest for a 16-second track with 6s segments. + // Expects 3 segments: 6s, 6s, 4s (remainder). + @Test func generatesCorrectManifestForTypicalTrack() { + let manifest = HLSManifestGenerator.manifest( + trackId: 42, + duration: 16.0, + segmentDuration: 6.0 + ) + + #expect(manifest.contains("#EXTM3U")) + #expect(manifest.contains("#EXT-X-VERSION:3")) + #expect(manifest.contains("#EXT-X-TARGETDURATION:6")) + #expect(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")) + #expect(manifest.contains("#EXTINF:6.000,")) + #expect(manifest.contains("#EXTINF:4.000,")) + #expect(manifest.contains("segments/0.mp3")) + #expect(manifest.contains("segments/1.mp3")) + #expect(manifest.contains("segments/2.mp3")) + #expect(!manifest.contains("segments/3.mp3")) + #expect(manifest.contains("#EXT-X-ENDLIST")) + } + + // A track whose duration is an exact multiple of the segment duration. + // Expects no short final segment. + @Test func exactMultipleOfSegmentDuration() { + let manifest = HLSManifestGenerator.manifest( + trackId: 1, + duration: 12.0, + segmentDuration: 6.0 + ) + + let segmentCount = manifest.components(separatedBy: "#EXTINF:6.000,").count - 1 + #expect(segmentCount == 2) + #expect(!manifest.contains("segments/2.mp3")) + } + + // A very short track (shorter than one segment). + // Expects a single segment with the track's full duration. + @Test func veryShortTrack() { + let manifest = HLSManifestGenerator.manifest( + trackId: 7, + duration: 2.5, + segmentDuration: 6.0 + ) + + #expect(manifest.contains("#EXTINF:2.500,")) + #expect(manifest.contains("segments/0.mp3")) + #expect(!manifest.contains("segments/1.mp3")) + } + + // Segment count helper returns the correct number of segments. + @Test func segmentCountCalculation() { + #expect(HLSManifestGenerator.segmentCount(duration: 16.0, segmentDuration: 6.0) == 3) + #expect(HLSManifestGenerator.segmentCount(duration: 12.0, segmentDuration: 6.0) == 2) + #expect(HLSManifestGenerator.segmentCount(duration: 2.5, segmentDuration: 6.0) == 1) + #expect(HLSManifestGenerator.segmentCount(duration: 6.0, segmentDuration: 6.0) == 1) + } + + // Time range for a given segment index returns correct start and duration. + @Test func segmentTimeRange() { + // Track: 16s, segment: 6s → segments at 0-6, 6-12, 12-16 + let range0 = HLSManifestGenerator.segmentTimeRange( + index: 0, trackDuration: 16.0, segmentDuration: 6.0 + ) + #expect(range0.start == 0.0) + #expect(range0.duration == 6.0) + + let range2 = HLSManifestGenerator.segmentTimeRange( + index: 2, trackDuration: 16.0, segmentDuration: 6.0 + ) + #expect(range2.start == 12.0) + #expect(range2.duration == 4.0) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd MusicShared && swift test 2>&1 | grep -E "(FAIL|error:|Build)" +``` + +Expected: build error — `HLSManifestGenerator` not found. + +- [ ] **Step 3: Implement HLSManifestGenerator** + +`MusicShared/Sources/MusicShared/HLSManifestGenerator.swift`: + +```swift +import Foundation + +public enum HLSManifestGenerator: Sendable { + public struct TimeRange: Equatable, Sendable { + public var start: Double + public var duration: Double + } + + public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double) -> String { + let count = segmentCount(duration: duration, segmentDuration: segmentDuration) + let targetDuration = Int(segmentDuration.rounded(.up)) + + var lines: [String] = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:\(targetDuration)", + "#EXT-X-MEDIA-SEQUENCE:0", + ] + + for i in 0.. Int { + guard duration > 0, segmentDuration > 0 else { return 0 } + return Int((duration / segmentDuration).rounded(.up)) + } + + public static func segmentTimeRange(index: Int, trackDuration: Double, segmentDuration: Double) -> TimeRange { + let start = Double(index) * segmentDuration + let remaining = trackDuration - start + let duration = min(segmentDuration, remaining) + return TimeRange(start: start, duration: duration) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd MusicShared && swift test +``` + +Expected: all HLSManifestGenerator tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add MusicShared/Sources/MusicShared/HLSManifestGenerator.swift \ + MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift +git commit -m "feat: add HLSManifestGenerator with TDD tests" +``` + +--- + +## Task 5: Define PlaybackProvider Protocol + +**Files:** +- Create: `Music/Protocols/PlaybackProvider.swift` + +This protocol abstracts the three playback modes so `PlayerViewModel` never checks which mode it's in. + +- [ ] **Step 1: Create PlaybackProvider.swift** + +`Music/Protocols/PlaybackProvider.swift`: + +```swift +import Foundation + +@MainActor +protocol PlaybackProvider: AnyObject { + var isPlaying: Bool { get } + var currentTime: Double { get } + var duration: Double { get } + var volume: Float { get } + var isScrubbing: Bool { get } + + var onTrackFinished: (() -> Void)? { get set } + var onPlaybackStateChanged: (() -> Void)? { get set } + + /// Resolve a Track to the URL the provider should play. + /// Local providers return the file URL; streaming providers return the HLS URL. + func urlForTrack(_ track: Track) -> URL? + func play(url: URL) + func pause() + func resume() + func togglePlayPause() + func seek(to position: Double) + func setVolume(_ level: Float) + func stop() + func beginScrubbing() + func scrub(to position: Double) + func endScrubbing(at position: Double) +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/Protocols/PlaybackProvider.swift Music.xcodeproj +git commit -m "feat: add PlaybackProvider protocol" +``` + +--- + +## Task 6: Conform AudioService to PlaybackProvider + +**Files:** +- Modify: `Music/Services/AudioService.swift` + +`AudioService` already has every method and property that `PlaybackProvider` requires except `urlForTrack(_:)`. We declare the conformance and add the missing method. + +- [ ] **Step 1: Add protocol conformance and urlForTrack to AudioService** + +In `Music/Services/AudioService.swift`, change the class declaration at line 11 from: + +```swift +final class AudioService { +``` + +to: + +```swift +final class AudioService: PlaybackProvider { +``` + +Add this method anywhere in the class body: + +```swift +func urlForTrack(_ track: Track) -> URL? { + URL(string: track.fileURL) +} +``` + +- [ ] **Step 2: Build to verify conformance** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED — AudioService now satisfies all PlaybackProvider requirements. + +- [ ] **Step 3: Commit** + +```bash +git add Music/Services/AudioService.swift +git commit -m "feat: conform AudioService to PlaybackProvider" +``` + +--- + +## Task 7: Create RemotePlaybackProvider + +**Files:** +- Create: `Music/Providers/RemotePlaybackProvider.swift` + +This extracts the remote-command-sending logic that currently lives inside `PlayerViewModel` into a dedicated `PlaybackProvider` conformer. In remote mode, commands are forwarded over the network; playback state comes from `applyRemoteState()`. + +- [ ] **Step 1: Create RemotePlaybackProvider.swift** + +`Music/Providers/RemotePlaybackProvider.swift`: + +```swift +import Foundation +import Observation +import MusicShared + +@Observable +final class RemotePlaybackProvider: PlaybackProvider { + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 + var volume: Float = 0.65 + private(set) var isScrubbing = false + + var onTrackFinished: (() -> Void)? + var onPlaybackStateChanged: (() -> Void)? + + private weak var commandSender: RemoteCommandSender? + + init(commandSender: RemoteCommandSender) { + self.commandSender = commandSender + } + + func urlForTrack(_ track: Track) -> URL? { + nil // Remote mode doesn't use URLs — commands are sent via sendPlayCommand + } + + func play(url: URL) { + // Remote mode uses sendPlayCommand(trackId:queueIds:) instead + } + + func sendPlayCommand(trackId: Int64, queueIds: [Int64]) { + commandSender?.sendCommand(.play(trackId: trackId, queueIds: queueIds)) + } + + func pause() { + isPlaying = false + commandSender?.sendCommand(.pause) + onPlaybackStateChanged?() + } + + func resume() { + isPlaying = true + commandSender?.sendCommand(.resume) + onPlaybackStateChanged?() + } + + func togglePlayPause() { + if isPlaying { pause() } else { resume() } + } + + func seek(to position: Double) { + currentTime = position + commandSender?.sendCommand(.seek(position: position)) + } + + func setVolume(_ level: Float) { + volume = level + commandSender?.sendCommand(.setVolume(level: level)) + } + + func stop() { + isPlaying = false + currentTime = 0 + duration = 0 + onPlaybackStateChanged?() + } + + func beginScrubbing() { + isScrubbing = true + } + + func scrub(to position: Double) { + currentTime = position + } + + func endScrubbing(at position: Double) { + currentTime = position + isScrubbing = false + commandSender?.sendCommand(.seek(position: position)) + } + + func sendNext() { + commandSender?.sendCommand(.next) + } + + func sendPrevious() { + commandSender?.sendCommand(.previous) + } + + func sendToggleShuffle() { + commandSender?.sendCommand(.toggleShuffle) + } + + func applyRemoteState(_ state: PlaybackStatePayload) { + isPlaying = state.isPlaying + currentTime = state.currentTime + duration = state.duration + volume = state.volume + onPlaybackStateChanged?() + } +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/Providers/RemotePlaybackProvider.swift Music.xcodeproj +git commit -m "feat: add RemotePlaybackProvider" +``` + +--- + +## Task 8: Refactor PlayerViewModel to Use PlaybackProvider + +**Files:** +- Modify: `Music/ViewModels/PlayerViewModel.swift` +- Modify: `MusicTests/PlayerViewModelTests.swift` + +This is the key refactor. `PlayerViewModel` drops its direct `AudioService` reference and instead holds a `PlaybackProvider`. The `enterRemoteMode`/`exitRemoteMode` methods are replaced by `setProvider(_:)`. + +- [ ] **Step 1: Rewrite PlayerViewModel to use PlaybackProvider** + +Replace the contents of `Music/ViewModels/PlayerViewModel.swift`: + +```swift +import Foundation +import Observation +import MusicShared + +protocol RemoteCommandSender: AnyObject { + func sendCommand(_ command: RemoteCommand) +} + +@Observable +final class PlayerViewModel { + var currentTrack: Track? + var currentIndex: Int? + var isShuffled = false + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 + var volume: Float = 0.65 + + private(set) var queue: [Track] = [] + private var originalQueue: [Track] = [] + private var provider: PlaybackProvider + private let db: DatabaseService? + private var halfwayReported = false + + var trackResolver: ((Int64) -> Track?)? + + private var remoteProvider: RemotePlaybackProvider? { + provider as? RemotePlaybackProvider + } + + init(provider: PlaybackProvider, db: DatabaseService?) { + self.provider = provider + self.db = db + bindProvider() + } + + // MARK: - Provider Management + + func setProvider(_ newProvider: PlaybackProvider) { + provider.stop() + provider = newProvider + currentTrack = nil + currentIndex = nil + isPlaying = false + currentTime = 0 + duration = 0 + queue = [] + originalQueue = [] + halfwayReported = false + bindProvider() + } + + private func bindProvider() { + provider.onTrackFinished = { [weak self] in + self?.trackDidFinish() + } + provider.onPlaybackStateChanged = { [weak self] in + self?.syncFromProvider() + } + } + + // MARK: - Provider Sync + + private func syncFromProvider() { + isPlaying = provider.isPlaying + if !provider.isScrubbing { + currentTime = provider.currentTime + } + duration = provider.duration + volume = provider.volume + checkHalfway() + } + + // MARK: - Queue Management + + func setQueue(_ tracks: [Track]) { + originalQueue = tracks + if isShuffled { + queue = buildShuffledQueue(from: tracks, startingWith: currentTrack) + } else { + queue = tracks + } + if let current = currentTrack { + currentIndex = queue.firstIndex(where: { $0.id == current.id }) + } + } + + // MARK: - Playback Controls + + func play(_ track: Track) { + currentTrack = track + currentIndex = queue.firstIndex(where: { $0.id == track.id }) + halfwayReported = false + isPlaying = true + currentTime = 0 + + if let remote = remoteProvider { + guard let trackId = track.id else { return } + remote.sendPlayCommand(trackId: trackId, queueIds: queue.compactMap(\.id)) + } else { + guard let url = provider.urlForTrack(track) else { return } + provider.play(url: url) + } + } + + func togglePlayPause() { + if isPlaying { pause() } else { resume() } + } + + func pause() { + isPlaying = false + provider.pause() + } + + func resume() { + isPlaying = true + provider.resume() + } + + func seek(to position: Double) { + currentTime = position + provider.seek(to: position) + } + + func setVolume(_ level: Float) { + volume = level + provider.setVolume(level) + } + + func beginScrubbing() { + provider.beginScrubbing() + } + + func scrub(to position: Double) { + currentTime = position + provider.scrub(to: position) + } + + func endScrubbing(at position: Double) { + currentTime = position + provider.endScrubbing(at: position) + } + + func next() { + if let remote = remoteProvider { + remote.sendNext() + return + } + guard let idx = currentIndex else { return } + let nextIdx = idx + 1 + if nextIdx < queue.count { + play(queue[nextIdx]) + } else { + stop() + } + } + + func previous() { + if let remote = remoteProvider { + remote.sendPrevious() + return + } + guard let idx = currentIndex else { return } + let prevIdx = max(0, idx - 1) + play(queue[prevIdx]) + } + + func toggleShuffle() { + isShuffled.toggle() + if let remote = remoteProvider { + remote.sendToggleShuffle() + return + } + if isShuffled { + queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) + } else { + queue = originalQueue + } + if let current = currentTrack { + currentIndex = queue.firstIndex(where: { $0.id == current.id }) + } + } + + func stop() { + isPlaying = false + currentTime = 0 + duration = 0 + currentTrack = nil + currentIndex = nil + provider.stop() + } + + // MARK: - Remote State + + func applyRemoteState(_ state: PlaybackStatePayload) { + guard let remote = remoteProvider else { return } + remote.applyRemoteState(state) + isPlaying = state.isPlaying + currentTime = state.currentTime + duration = state.duration + volume = state.volume + isShuffled = state.isShuffled + + if let trackId = state.trackId, currentTrack?.id != trackId { + currentTrack = trackResolver?(trackId) + } else if state.trackId == nil { + currentTrack = nil + } + } + + // MARK: - Internal + + func checkHalfway() { + guard !halfwayReported, + duration > 0, + currentTime >= duration * 0.5, + let track = currentTrack, + let trackId = track.id else { return } + + halfwayReported = true + let newCount = track.playCount + 1 + try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) + } + + private func trackDidFinish() { + if let track = currentTrack, let trackId = track.id, !halfwayReported { + let newCount = track.playCount + 1 + try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) + } + next() + } + + private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] { + var shuffled = tracks.shuffled() + if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) { + shuffled.remove(at: idx) + shuffled.insert(current, at: 0) + } + return shuffled + } +} +``` + +- [ ] **Step 2: Update MusicApp.swift to use new PlayerViewModel init** + +In `Music/MusicApp.swift`, change line 98 from: + +```swift +let player = PlayerViewModel(audio: audioService, db: db) +``` + +to: + +```swift +let player = PlayerViewModel(provider: audioService, db: db) +``` + +Update `enterRemoteMode()` (around line 197) — replace: + +```swift +player.enterRemoteMode(client: remoteClient) +``` + +with: + +```swift +let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient) +player.setProvider(remoteProvider) +``` + +Update `exitRemoteMode()` (around line 218) — replace: + +```swift +playerVM?.exitRemoteMode() +``` + +with: + +```swift +playerVM?.setProvider(audioService) +``` + +- [ ] **Step 3: Update PlayerViewModelTests** + +Replace `PlayerViewModel(audio: AudioService(), db: nil)` with `PlayerViewModel(provider: AudioService(), db: nil)` in every test. In `MusicTests/PlayerViewModelTests.swift`, change the `makeTracks` helper's test setup. Every occurrence of: + +```swift +let vm = PlayerViewModel(audio: AudioService(), db: nil) +``` + +becomes: + +```swift +let vm = PlayerViewModel(provider: AudioService(), db: nil) +``` + +- [ ] **Step 4: Build and run tests** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 +``` + +Expected: all PlayerViewModelTests pass, build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add Music/ViewModels/PlayerViewModel.swift Music/MusicApp.swift \ + MusicTests/PlayerViewModelTests.swift Music/Providers/ +git commit -m "refactor: PlayerViewModel uses PlaybackProvider protocol" +``` + +--- + +## Task 9: Update NetworkStatus for Streaming Modes + +**Files:** +- Modify: `Music/Remote/NetworkStatus.swift` + +- [ ] **Step 1: Add streaming modes to NetworkStatus.Mode** + +Replace the contents of `Music/Remote/NetworkStatus.swift`: + +```swift +import Foundation +import MusicShared + +struct NetworkStatus { + enum Mode { + case hosting(connectedRemote: String?) + case remote(hostName: String) + case streamHosting(tunnelURL: String?) + case streamClient(hostName: String) + } + + var mode: Mode + var onDisconnect: (() -> Void)? + var onRefreshLibrary: (() -> Void)? + + var isRemoteMode: Bool { + switch mode { + case .remote, .streamClient: return true + default: return false + } + } + + var isHostMode: Bool { + switch mode { + case .hosting, .streamHosting: return true + default: return false + } + } + + var statusMessage: String { + switch mode { + case .hosting(let remote): + if let remote { return "Hosting · \(remote) connected" } + return "Hosting" + case .remote(let host): + return "Connected to \(host)" + case .streamHosting(let url): + if let url { return "Streaming · \(url)" } + return "Streaming server starting..." + case .streamClient(let host): + return "Streaming from \(host)" + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED (existing `computeNetworkStatus()` in MusicApp.swift still compiles since the original cases still exist). + +- [ ] **Step 3: Commit** + +```bash +git add Music/Remote/NetworkStatus.swift +git commit -m "feat: add streaming modes to NetworkStatus" +``` + +--- + +## Task 10: Add Hummingbird Dependency + +**Files:** +- Modify: `MusicShared/Package.swift` (add Hummingbird as dependency) +- Modify: `Music.xcodeproj/project.pbxproj` (Xcode auto-handles via local package) + +Hummingbird is added to MusicShared so the streaming server types can be imported by both macOS and (eventually) iOS targets. Only the host target will actually use server functionality. + +- [ ] **Step 1: Update MusicShared/Package.swift to add Hummingbird** + +Replace `MusicShared/Package.swift`: + +```swift +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "MusicShared", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library(name: "MusicShared", targets: ["MusicShared"]), + ], + dependencies: [ + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.0.0"), + ], + targets: [ + .target( + name: "MusicShared", + dependencies: [ + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdWebSocket", package: "hummingbird-websocket"), + ] + ), + .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), + ] +) +``` + +- [ ] **Step 2: Resolve and build** + +```bash +cd MusicShared && swift package resolve && swift build +``` + +Expected: Hummingbird downloads and builds successfully. + +- [ ] **Step 3: Verify Xcode picks up the dependency** + +In Xcode, clean build folder and rebuild. The local package's new dependencies should resolve automatically. + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 4: Commit** + +```bash +git add MusicShared/Package.swift MusicShared/Package.resolved +git commit -m "chore: add Hummingbird dependency to MusicShared" +``` + +--- + +## Task 11: Create HLSSegmenter (TDD) + +**Files:** +- Create: `Music/Streaming/HLSSegmenter.swift` +- Create: `MusicTests/HLSSegmenterTests.swift` + +The segmenter uses `AVAssetReader` with time ranges to extract audio segments from MP3 files. It handles VBR files and frame-boundary alignment correctly. + +- [ ] **Step 1: Write the failing tests** + +`MusicTests/HLSSegmenterTests.swift`: + +```swift +import Testing +import Foundation +@testable import Music + +// Integration test using a real audio file. +// Requires a test MP3 fixture (see Step 3). +@MainActor +struct HLSSegmenterTests { + // Creates a segmenter for a test MP3 file and verifies it reports the correct duration. + @Test func readsDurationFromFile() async throws { + let url = try TestFixtures.shortMP3URL() + let segmenter = try HLSSegmenter(fileURL: url) + + // The test fixture is ~3 seconds long + #expect(segmenter.duration > 2.0) + #expect(segmenter.duration < 5.0) + } + + // Extracts the first segment and verifies it returns non-empty data. + @Test func extractsFirstSegment() async throws { + let url = try TestFixtures.shortMP3URL() + let segmenter = try HLSSegmenter(fileURL: url) + + let data = try await segmenter.segment(at: 0, segmentDuration: 6.0) + #expect(!data.isEmpty) + } + + // Requesting a segment index beyond the track duration returns nil. + @Test func outOfRangeSegmentReturnsNil() async throws { + let url = try TestFixtures.shortMP3URL() + let segmenter = try HLSSegmenter(fileURL: url) + + let data = try await segmenter.segment(at: 999, segmentDuration: 6.0) + #expect(data == nil) + } +} + +enum TestFixtures { + // Returns the URL of a short test MP3 file. + // The file is generated once using afconvert if it doesn't exist. + static func shortMP3URL() throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let url = tempDir.appendingPathComponent("test_fixture.mp3") + + if !FileManager.default.fileExists(atPath: url.path) { + // Generate a 3-second silent MP3 using afconvert via a CAF intermediary + let cafURL = tempDir.appendingPathComponent("test_fixture.caf") + let sampleRate = 44100 + let channels = 1 + let durationSamples = sampleRate * 3 + let bytesPerSample = 2 + let dataSize = durationSamples * channels * bytesPerSample + + // Create a minimal WAV as input for afconvert + let wavURL = tempDir.appendingPathComponent("test_fixture.wav") + var wavData = Data() + func appendString(_ s: String) { wavData.append(contentsOf: s.utf8) } + func appendUInt32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } + func appendUInt16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } + + appendString("RIFF") + appendUInt32(UInt32(36 + dataSize)) + appendString("WAVE") + appendString("fmt ") + appendUInt32(16) + appendUInt16(1) // PCM + appendUInt16(UInt16(channels)) + appendUInt32(UInt32(sampleRate)) + appendUInt32(UInt32(sampleRate * channels * bytesPerSample)) + appendUInt16(UInt16(channels * bytesPerSample)) + appendUInt16(UInt16(bytesPerSample * 8)) + appendString("data") + appendUInt32(UInt32(dataSize)) + wavData.append(Data(count: dataSize)) // silence + + try wavData.write(to: wavURL) + + // Convert WAV → MP3 using afconvert + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") + process.arguments = [wavURL.path, url.path, "-f", "MPE3", "-d", "mp3"] + try process.run() + process.waitUntilExit() + + try? FileManager.default.removeItem(at: wavURL) + + guard FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "TestFixtures", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create test MP3"]) + } + } + + return url + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test \ + -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -10 +``` + +Expected: build error — `HLSSegmenter` not found. + +- [ ] **Step 3: Implement HLSSegmenter** + +`Music/Streaming/HLSSegmenter.swift`: + +```swift +import AVFoundation +import Foundation + +final class HLSSegmenter: Sendable { + let fileURL: URL + let duration: Double + + init(fileURL: URL) throws { + self.fileURL = fileURL + let asset = AVURLAsset(url: fileURL) + let durationCM = asset.duration + guard durationCM.isValid, !durationCM.isIndefinite else { + throw HLSSegmenterError.invalidDuration + } + self.duration = durationCM.seconds + } + + func segment(at index: Int, segmentDuration: Double) async throws -> Data? { + let startTime = Double(index) * segmentDuration + guard startTime < duration else { return nil } + + let endTime = min(startTime + segmentDuration, duration) + let timeRange = CMTimeRange( + start: CMTime(seconds: startTime, preferredTimescale: 600), + end: CMTime(seconds: endTime, preferredTimescale: 600) + ) + + let asset = AVURLAsset(url: fileURL) + + guard let track = try await asset.loadTracks(withMediaType: .audio).first else { + throw HLSSegmenterError.noAudioTrack + } + + let reader = try AVAssetReader(asset: asset) + reader.timeRange = timeRange + + let outputSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEGLayer3, + ] + + // Try passthrough first (for MP3 sources), fall back to PCM if needed + let output: AVAssetReaderOutput + let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil) + if reader.canAdd(trackOutput) { + reader.add(trackOutput) + output = trackOutput + } else { + let pcmOutput = AVAssetReaderTrackOutput(track: track, outputSettings: [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ]) + reader.add(pcmOutput) + output = pcmOutput + } + + reader.startReading() + + var segmentData = Data() + while let sampleBuffer = output.copyNextSampleBuffer() { + if let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) { + let length = CMBlockBufferGetDataLength(blockBuffer) + var bytes = [UInt8](repeating: 0, count: length) + CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &bytes) + segmentData.append(contentsOf: bytes) + } + } + + guard reader.status == .completed else { + throw HLSSegmenterError.readFailed(reader.error) + } + + return segmentData + } +} + +enum HLSSegmenterError: Error { + case invalidDuration + case noAudioTrack + case readFailed(Error?) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test \ + -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -20 +``` + +Expected: all HLSSegmenterTests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Streaming/HLSSegmenter.swift MusicTests/HLSSegmenterTests.swift Music.xcodeproj +git commit -m "feat: add HLSSegmenter with AVAssetReader-based extraction" +``` + +--- + +## Task 12: Create StreamingServer + +**Files:** +- Create: `Music/Streaming/StreamingServer.swift` +- Create: `MusicTests/StreamingServerTests.swift` + +The streaming server is a Hummingbird HTTP server that serves: +- `GET /auth` — API key validation +- `GET /db` — SQLite database download +- `GET /tracks/:trackId/stream.m3u8` — HLS manifest +- `GET /tracks/:trackId/segments/:index.mp3` — Audio segment +- WebSocket at `/ws` — real-time events + +- [ ] **Step 1: Write integration tests for the streaming server** + +`MusicTests/StreamingServerTests.swift`: + +```swift +import Testing +import Foundation +@testable import Music +@testable import MusicShared + +@MainActor +struct StreamingServerTests { + static let testAPIKey = "test-key-12345" + + // Verifies that GET /auth with a valid API key returns 200 and an AuthResponse. + @Test func authEndpointAcceptsValidKey() async throws { + let server = try makeServer() + try await server.start() + defer { Task { await server.stop() } } + + let port = try #require(server.actualPort) + var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) + request.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + + let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) + #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) + } + + // Verifies that GET /auth without a key returns 401. + @Test func authEndpointRejectsNoKey() async throws { + let server = try makeServer() + try await server.start() + defer { Task { await server.stop() } } + + let port = try #require(server.actualPort) + let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 401) + } + + // Verifies that GET /db returns a non-empty SQLite file. + @Test func dbEndpointReturnsDatabaseFile() async throws { + let server = try makeServer() + try await server.start() + defer { Task { await server.stop() } } + + let port = try #require(server.actualPort) + var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.db)")!) + request.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + // SQLite files start with "SQLite format 3\0" + #expect(data.count > 16) + #expect(String(data: data.prefix(15), encoding: .utf8) == "SQLite format 3") + } + + private func makeServer() throws -> StreamingServer { + let db = try DatabaseService(inMemory: true) + return StreamingServer( + db: db, + apiKey: Self.testAPIKey, + port: 0 // OS-assigned port + ) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test \ + -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -10 +``` + +Expected: build error — `StreamingServer` not found. + +- [ ] **Step 3: Implement StreamingServer** + +`Music/Streaming/StreamingServer.swift`: + +```swift +import Foundation +import Hummingbird +import HummingbirdWebSocket +import MusicShared +import os + +@MainActor +@Observable +final class StreamingServer { + var isRunning = false + private(set) var actualPort: Int? + + private let db: DatabaseService + private let apiKey: String + private let requestedPort: Int + private var serverTask: Task? + private let logger = Logger(subsystem: "com.music.streaming", category: "server") + + // Cache segmenters by track ID to avoid re-reading duration on every request + private var segmenters: [Int64: HLSSegmenter] = [:] + + init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) { + self.db = db + self.apiKey = apiKey + self.requestedPort = port + } + + func start() async throws { + let router = Router() + + router.middlewares.add(AuthMiddleware(apiKey: apiKey)) + + router.get(StreamingRoutes.auth) { [weak self] request, context -> Response in + guard let self else { return Response(status: .internalServerError) } + let hostName = Host.current().localizedName ?? "Music Host" + let response = AuthResponse(hostName: hostName, protocolVersion: StreamingConstants.protocolVersion) + let data = try JSONEncoder().encode(response) + return Response( + status: .ok, + headers: [.contentType: "application/json"], + body: .init(byteBuffer: .init(data: data)) + ) + } + + router.get(StreamingRoutes.db) { [weak self] request, context -> Response in + guard let self else { return Response(status: .internalServerError) } + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sqlite") + defer { try? FileManager.default.removeItem(at: tempURL) } + try self.db.backup(to: tempURL.path) + let data = try Data(contentsOf: tempURL) + return Response( + status: .ok, + headers: [.contentType: "application/octet-stream"], + body: .init(byteBuffer: .init(data: data)) + ) + } + + router.get("tracks/:trackId/stream.m3u8") { [weak self] request, context -> Response in + guard let self else { return Response(status: .internalServerError) } + guard let trackIdStr = context.parameters.get("trackId"), + let trackId = Int64(trackIdStr) else { + return Response(status: .badRequest) + } + let segmenter = try self.getOrCreateSegmenter(trackId: trackId) + let manifest = HLSManifestGenerator.manifest( + trackId: trackId, + duration: segmenter.duration, + segmentDuration: StreamingConstants.segmentDuration + ) + return Response( + status: .ok, + headers: [ + .contentType: "application/vnd.apple.mpegurl", + .cacheControl: "no-cache", + ], + body: .init(byteBuffer: .init(string: manifest)) + ) + } + + router.get("tracks/:trackId/segments/:index") { [weak self] request, context -> Response in + guard let self else { return Response(status: .internalServerError) } + guard let trackIdStr = context.parameters.get("trackId"), + let trackId = Int64(trackIdStr), + let indexStr = context.parameters.get("index"), + let index = Int(indexStr.replacingOccurrences(of: ".mp3", with: "")) else { + return Response(status: .badRequest) + } + let segmenter = try self.getOrCreateSegmenter(trackId: trackId) + guard let data = try await segmenter.segment(at: index, segmentDuration: StreamingConstants.segmentDuration) else { + return Response(status: .notFound) + } + return Response( + status: .ok, + headers: [.contentType: "audio/mpeg"], + body: .init(byteBuffer: .init(data: data)) + ) + } + + // WebSocket route for real-time events + router.ws(StreamingRoutes.ws) { [weak self] request, context in + // Auth check for WebSocket upgrade + guard let authHeader = request.headers[.authorization], + authHeader == "Bearer \(self?.apiKey ?? "")" else { + return .dontUpgrade + } + return .upgrade([:]) + } onUpgrade: { [weak self] inbound, outbound, context in + guard let self else { return } + for try await message in inbound.messages { + switch message { + case .text(let text): + self.handleWSMessage(text, outbound: outbound) + case .binary: + break + } + } + } + + let app = Application( + router: router, + configuration: .init(address: .hostname("127.0.0.1", port: requestedPort)) + ) + + serverTask = Task { + try await app.run() + } + + // Give the server a moment to bind + try await Task.sleep(for: .milliseconds(200)) + actualPort = requestedPort == 0 ? app.port : requestedPort + isRunning = true + logger.info("Streaming server started on port \(self.actualPort ?? 0)") + } + + func stop() { + serverTask?.cancel() + serverTask = nil + segmenters = [:] + actualPort = nil + isRunning = false + logger.info("Streaming server stopped") + } + + // MARK: - WebSocket + + private func handleWSMessage(_ text: String, outbound: WebSocketOutboundWriter) { + guard let data = text.data(using: .utf8) else { return } + let decoder = JSONDecoder() + + if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) { + logger.info("WS handshake: v\(handshake.protocolVersion), app \(handshake.appVersion)") + return + } + + if let command = try? decoder.decode(RemoteCommand.self, from: data) { + logger.info("WS command: \(String(describing: command))") + if case .refreshDB = command { + let event = HostEvent.dbReady + if let encoded = try? JSONEncoder().encode(event), + let str = String(data: encoded, encoding: .utf8) { + Task { try? await outbound.write(.text(str)) } + } + } + } + } + + // MARK: - Helpers + + private func getOrCreateSegmenter(trackId: Int64) throws -> HLSSegmenter { + if let cached = segmenters[trackId] { + return cached + } + let tracks = try db.fetchTracksByIds([trackId]) + guard let track = tracks.first else { + throw StreamingServerError.trackNotFound(trackId) + } + let fileURL = URL(fileURLWithPath: track.fileURL) + let segmenter = try HLSSegmenter(fileURL: fileURL) + segmenters[trackId] = segmenter + return segmenter + } +} + +enum StreamingServerError: Error { + case trackNotFound(Int64) +} + +struct AuthMiddleware: MiddlewareProtocol { + let apiKey: String + + func handle( + _ request: Request, + context: some RequestContext, + next: (Request, some RequestContext) async throws -> Response + ) async throws -> Response { + let authHeader = request.headers[.authorization] + guard authHeader == "Bearer \(apiKey)" else { + return Response(status: .unauthorized) + } + return try await next(request, context) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test \ + -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -20 +``` + +Expected: all StreamingServerTests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Music/Streaming/StreamingServer.swift MusicTests/StreamingServerTests.swift Music.xcodeproj +git commit -m "feat: add StreamingServer with Hummingbird HTTP endpoints" +``` + +--- + +## Task 13: Create TunnelManager + +**Files:** +- Create: `Music/Streaming/TunnelManager.swift` + +Manages the `cloudflared` child process. Supports both quick tunnels (random URL) and named tunnels (stable URL). + +- [ ] **Step 1: Create TunnelManager.swift** + +`Music/Streaming/TunnelManager.swift`: + +```swift +import Foundation +import os +import Observation + +@MainActor +@Observable +final class TunnelManager { + enum TunnelMode: String, Codable { + case quick + case named + } + + enum TunnelState: Equatable { + case stopped + case starting + case running(url: String) + case failed(message: String) + } + + var state: TunnelState = .stopped + var tunnelURL: String? { + if case .running(let url) = state { return url } + return nil + } + + private var process: Process? + private var outputPipe: Pipe? + private let logger = Logger(subsystem: "com.music.streaming", category: "tunnel") + + static func isCloudflaredInstalled() -> Bool { + FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") + || FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") + } + + static var cloudflaredPath: String? { + if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") { + return "/opt/homebrew/bin/cloudflared" + } + if FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") { + return "/usr/local/bin/cloudflared" + } + return nil + } + + func startQuickTunnel(localPort: Int) throws { + guard let path = Self.cloudflaredPath else { + state = .failed(message: "cloudflared not found. Install with: brew install cloudflared") + return + } + + state = .starting + + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = ["tunnel", "--url", "http://localhost:\(localPort)"] + + let pipe = Pipe() + process.standardError = pipe // cloudflared writes URL to stderr + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } + Task { @MainActor [weak self] in + self?.parseOutput(line) + } + } + + process.terminationHandler = { [weak self] proc in + Task { @MainActor [weak self] in + guard let self else { return } + if case .running = self.state { } else { + self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)") + } + self.state = .stopped + } + } + + try process.run() + self.process = process + self.outputPipe = pipe + logger.info("Started cloudflared quick tunnel on port \(localPort)") + } + + func startNamedTunnel(tunnelName: String, localPort: Int) throws { + guard let path = Self.cloudflaredPath else { + state = .failed(message: "cloudflared not found. Install with: brew install cloudflared") + return + } + + state = .starting + + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName] + + let pipe = Pipe() + process.standardError = pipe + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } + Task { @MainActor [weak self] in + self?.parseOutput(line) + } + } + + process.terminationHandler = { [weak self] _ in + Task { @MainActor [weak self] in + self?.state = .stopped + } + } + + try process.run() + self.process = process + self.outputPipe = pipe + logger.info("Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)") + } + + func stop() { + process?.terminate() + process = nil + outputPipe?.fileHandleForReading.readabilityHandler = nil + outputPipe = nil + state = .stopped + logger.info("Stopped cloudflared tunnel") + } + + private func parseOutput(_ output: String) { + // cloudflared prints the tunnel URL to stderr in a line like: + // "... https://xxx-yyy-zzz.trycloudflare.com ..." + // or for named tunnels, the configured hostname + let lines = output.components(separatedBy: .newlines) + for line in lines { + if let range = line.range(of: "https://[^ ]+", options: .regularExpression) { + let url = String(line[range]) + state = .running(url: url) + logger.info("Tunnel URL: \(url)") + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/Streaming/TunnelManager.swift Music.xcodeproj +git commit -m "feat: add TunnelManager for cloudflared process management" +``` + +--- + +## Task 14: Create StreamingPlaybackProvider + +**Files:** +- Create: `Music/Providers/StreamingPlaybackProvider.swift` + +Uses `AVPlayer` with HLS URLs and injects the API key as a custom HTTP header. This reuses AVPlayer the same way `AudioService` does, but points at remote URLs instead of local files. + +- [ ] **Step 1: Create StreamingPlaybackProvider.swift** + +`Music/Providers/StreamingPlaybackProvider.swift`: + +```swift +import AVFoundation +import Foundation +import Observation +import MusicShared + +@Observable +final class StreamingPlaybackProvider: PlaybackProvider { + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 + var volume: Float = 0.65 { + didSet { player?.volume = volume } + } + + private(set) var isScrubbing = false + + var onTrackFinished: (() -> Void)? + var onPlaybackStateChanged: (() -> Void)? + + private var player: AVPlayer? + private var timeObserver: Any? + private var endObserver: NSObjectProtocol? + private var seekInProgress = false + private var pendingSeekTime: Double? + + private let hostURL: String + private let apiKey: String + + init(hostURL: String, apiKey: String) { + self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL + self.apiKey = apiKey + } + + func urlForTrack(_ track: Track) -> URL? { + guard let trackId = track.id else { return nil } + return URL(string: "\(hostURL)\(StreamingRoutes.trackManifest(trackId: trackId))") + } + + func play(url: URL) { + cleanup() + + let headers = ["Authorization": "Bearer \(apiKey)"] + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + let item = AVPlayerItem(asset: asset) + player = AVPlayer(playerItem: item) + player?.volume = volume + + timeObserver = player?.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + guard let self, !self.isScrubbing else { return } + self.currentTime = time.seconds + if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { + self.duration = dur.seconds + } + self.onPlaybackStateChanged?() + } + + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: item, + queue: .main + ) { [weak self] _ in + self?.isPlaying = false + self?.currentTime = 0 + self?.onPlaybackStateChanged?() + self?.onTrackFinished?() + } + + player?.play() + isPlaying = true + onPlaybackStateChanged?() + } + + func playTrack(id: Int64) { + let urlString = "\(hostURL)\(StreamingRoutes.trackManifest(trackId: id))" + guard let url = URL(string: urlString) else { return } + play(url: url) + } + + func pause() { + player?.pause() + isPlaying = false + onPlaybackStateChanged?() + } + + func resume() { + player?.play() + isPlaying = true + onPlaybackStateChanged?() + } + + func togglePlayPause() { + if isPlaying { pause() } else { resume() } + } + + func seek(to time: Double) { + let clamped = max(0, min(time, duration)) + currentTime = clamped + player?.seek( + to: CMTime(seconds: clamped, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + func setVolume(_ level: Float) { + volume = level + } + + func beginScrubbing() { + isScrubbing = true + } + + func scrub(to time: Double) { + let clamped = max(0, min(time, duration)) + currentTime = clamped + // Chase-seek for smooth scrubbing + pendingSeekTime = clamped + guard !seekInProgress else { return } + performPendingSeek() + } + + func endScrubbing(at time: Double) { + let clamped = max(0, min(time, duration)) + currentTime = clamped + pendingSeekTime = nil + seekInProgress = false + + player?.seek( + to: CMTime(seconds: clamped, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self] _ in + DispatchQueue.main.async { + self?.isScrubbing = false + } + } + } + + func stop() { + cleanup() + isPlaying = false + currentTime = 0 + duration = 0 + onPlaybackStateChanged?() + } + + private func performPendingSeek() { + guard let time = pendingSeekTime else { return } + pendingSeekTime = nil + seekInProgress = true + + player?.seek( + to: CMTime(seconds: time, preferredTimescale: 600), + toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600), + toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600) + ) { [weak self] _ in + DispatchQueue.main.async { + guard let self else { return } + self.seekInProgress = false + if self.pendingSeekTime != nil { + self.performPendingSeek() + } + } + } + } + + private func cleanup() { + if let obs = timeObserver { + player?.removeTimeObserver(obs) + timeObserver = nil + } + if let obs = endObserver { + NotificationCenter.default.removeObserver(obs) + endObserver = nil + } + player?.pause() + player = nil + } + + nonisolated deinit {} +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/Providers/StreamingPlaybackProvider.swift Music.xcodeproj +git commit -m "feat: add StreamingPlaybackProvider with HLS AVPlayer" +``` + +--- + +## Task 15: Create StreamingClient + +**Files:** +- Create: `Music/Streaming/StreamingClient.swift` + +Handles the client-side connection lifecycle: auth validation, DB download, WebSocket for events. + +- [ ] **Step 1: Create StreamingClient.swift** + +`Music/Streaming/StreamingClient.swift`: + +```swift +import Foundation +import Observation +import MusicShared +import os + +@MainActor +@Observable +final class StreamingClient { + enum State: Equatable { + case disconnected + case connecting + case downloadingDB + case connected(hostName: String) + case error(message: String) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + } + + var state: State = .disconnected + var onDBReady: (() -> Void)? + + private var hostURL: String = "" + private var apiKey: String = "" + private var webSocketTask: URLSessionWebSocketTask? + private let logger = Logger(subsystem: "com.music.streaming", category: "client") + + static var streamingDBPath: String { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.appendingPathComponent("Music", isDirectory: true) + return appSupport.appendingPathComponent("streaming_db.sqlite").path + } + + func connect(hostURL: String, apiKey: String) async { + self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL + self.apiKey = apiKey + state = .connecting + + // Step 1: Validate auth + do { + let authResponse = try await authenticate() + logger.info("Authenticated with host: \(authResponse.hostName)") + + // Step 2: Download DB + state = .downloadingDB + try await downloadDatabase() + logger.info("Database downloaded") + + // Step 3: Connect WebSocket + connectWebSocket() + + state = .connected(hostName: authResponse.hostName) + } catch { + logger.error("Connection failed: \(error.localizedDescription)") + state = .error(message: error.localizedDescription) + } + } + + func disconnect() { + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + deleteStreamingDB() + state = .disconnected + } + + func requestDBRefresh() { + sendCommand(.refreshDB) + } + + // MARK: - Auth + + private func authenticate() async throws -> AuthResponse { + let url = URL(string: "\(hostURL)\(StreamingRoutes.auth)")! + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw StreamingClientError.invalidResponse + } + + if httpResponse.statusCode == 401 { + throw StreamingClientError.unauthorized + } + + guard httpResponse.statusCode == 200 else { + throw StreamingClientError.serverError(httpResponse.statusCode) + } + + return try JSONDecoder().decode(AuthResponse.self, from: data) + } + + // MARK: - DB Download + + private func downloadDatabase() async throws { + let url = URL(string: "\(hostURL)\(StreamingRoutes.db)")! + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw StreamingClientError.dbDownloadFailed + } + + let dirURL = URL(fileURLWithPath: Self.streamingDBPath).deletingLastPathComponent() + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + try data.write(to: URL(fileURLWithPath: Self.streamingDBPath)) + + logger.info("Database saved (\(data.count) bytes)") + } + + // MARK: - WebSocket + + private func connectWebSocket() { + let wsURLString = hostURL + .replacingOccurrences(of: "https://", with: "wss://") + .replacingOccurrences(of: "http://", with: "ws://") + guard let url = URL(string: "\(wsURLString)\(StreamingRoutes.ws)") else { return } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let task = URLSession.shared.webSocketTask(with: request) + task.resume() + self.webSocketTask = task + + // Send handshake + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let handshake = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: appVersion) + if let data = try? JSONEncoder().encode(handshake), + let string = String(data: data, encoding: .utf8) { + task.send(.string(string)) { [weak self] error in + if let error { + self?.logger.error("Failed to send handshake: \(error.localizedDescription)") + } + } + } + + receiveWebSocketMessages() + } + + private func receiveWebSocketMessages() { + webSocketTask?.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self else { return } + switch result { + case .success(let message): + self.handleWebSocketMessage(message) + self.receiveWebSocketMessages() + case .failure(let error): + self.logger.error("WebSocket error: \(error.localizedDescription)") + if self.state.isConnected { + self.state = .error(message: "Connection lost") + } + } + } + } + } + + private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { + let data: Data + switch message { + case .string(let text): + guard let d = text.data(using: .utf8) else { return } + data = d + case .data(let d): + data = d + @unknown default: + return + } + + do { + let event = try JSONDecoder().decode(HostEvent.self, from: data) + switch event { + case .playbackState: + break // Not used in streaming mode (client drives its own playback) + case .dbReady: + onDBReady?() + case .error(let message): + logger.error("Host error: \(message)") + } + } catch { + logger.error("Failed to decode event: \(error.localizedDescription)") + } + } + + private func sendCommand(_ command: RemoteCommand) { + guard let data = try? JSONEncoder().encode(command), + let string = String(data: data, encoding: .utf8) else { return } + webSocketTask?.send(.string(string)) { [weak self] error in + if let error { + self?.logger.error("Failed to send command: \(error.localizedDescription)") + } + } + } + + private func deleteStreamingDB() { + let path = Self.streamingDBPath + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + logger.info("Deleted streaming DB") + } + } +} + +enum StreamingClientError: LocalizedError { + case invalidResponse + case unauthorized + case serverError(Int) + case dbDownloadFailed + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid server response" + case .unauthorized: return "Invalid API key" + case .serverError(let code): return "Server error (\(code))" + case .dbDownloadFailed: return "Failed to download library" + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 3: Commit** + +```bash +git add Music/Streaming/StreamingClient.swift Music.xcodeproj +git commit -m "feat: add StreamingClient with auth, DB download, and WebSocket" +``` + +--- + +## Task 16: Wire Up MusicApp.swift with Streaming Host and Client + +**Files:** +- Modify: `Music/MusicApp.swift` + +Add streaming server/client state, menu items for stream host and stream client, and mode switching logic. + +- [ ] **Step 1: Add streaming state properties** + +In `MusicApp.swift`, add these `@State` properties after the existing ones (around line 16): + +```swift +@State private var streamingServer: StreamingServer? +@State private var tunnelManager = TunnelManager() +@State private var streamingClient = StreamingClient() +@State private var showStreamingSettings = false +@State private var streamHostURL = UserDefaults.standard.string(forKey: "streamHostURL") ?? "" +@State private var streamAPIKey = UserDefaults.standard.string(forKey: "streamAPIKey") ?? "" +``` + +- [ ] **Step 2: Add streaming menu commands** + +In the `.commands` block, after the existing "Connect to Remote..." button (around line 83), add: + +```swift +Divider() + +Button("Start Streaming Server...") { + startStreamingServer() +} +.disabled(streamingServer?.isRunning ?? false || remoteClient.connectionState.isConnected) + +Button("Stop Streaming Server") { + stopStreamingServer() +} +.disabled(!(streamingServer?.isRunning ?? false)) + +Divider() + +Button("Connect to Stream Host...") { + showStreamingSettings = true +} +.disabled(streamingClient.state.isConnected || hostServer?.isHosting ?? false) + +if streamingClient.state.isConnected { + Button("Disconnect from Stream") { + exitStreamingClientMode() + } +} +``` + +- [ ] **Step 3: Add streaming server methods** + +Add these methods to `MusicApp`: + +```swift +// MARK: - Streaming Host + +private func startStreamingServer() { + guard let db = dbService else { return } + + // Generate API key if first time + var key = UserDefaults.standard.string(forKey: "streamServerAPIKey") ?? "" + if key.isEmpty { + key = UUID().uuidString + UserDefaults.standard.set(key, forKey: "streamServerAPIKey") + } + + let server = StreamingServer(db: db, apiKey: key) + Task { + do { + try await server.start() + self.streamingServer = server + // Start tunnel + if TunnelManager.isCloudflaredInstalled() { + try tunnelManager.startQuickTunnel(localPort: server.actualPort ?? StreamingConstants.defaultPort) + } + } catch { + print("Failed to start streaming server: \(error)") + } + } +} + +private func stopStreamingServer() { + streamingServer?.stop() + streamingServer = nil + tunnelManager.stop() +} + +// MARK: - Streaming Client + +private func enterStreamingClientMode() { + guard !streamHostURL.isEmpty, !streamAPIKey.isEmpty else { return } + + UserDefaults.standard.set(streamHostURL, forKey: "streamHostURL") + UserDefaults.standard.set(streamAPIKey, forKey: "streamAPIKey") + + Task { + await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) + + if streamingClient.state.isConnected { + do { + let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) + self.libraryVM = LibraryViewModel(db: streamingDb) + self.playlistVM = PlaylistViewModel(db: streamingDb) + + let streamProvider = StreamingPlaybackProvider( + hostURL: streamHostURL, + apiKey: streamAPIKey + ) + playerVM?.setProvider(streamProvider) + playerVM?.trackResolver = { trackId in + self.libraryVM?.tracks.first(where: { $0.id == trackId }) + } + + streamingClient.onDBReady = { [weak self] in + guard let self else { return } + Task { + try? await self.refreshStreamingDB() + } + } + } catch { + print("Failed to load streaming DB: \(error)") + streamingClient.disconnect() + } + } + } +} + +private func exitStreamingClientMode() { + streamingClient.disconnect() + playerVM?.setProvider(audioService) + playerVM?.trackResolver = nil + guard let db = dbService else { return } + self.libraryVM = LibraryViewModel(db: db) + self.playlistVM = PlaylistViewModel(db: db) +} + +private func refreshStreamingDB() async throws { + await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) + if streamingClient.state.isConnected { + let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) + self.libraryVM = LibraryViewModel(db: streamingDb) + self.playlistVM = PlaylistViewModel(db: streamingDb) + } +} +``` + +- [ ] **Step 4: Update computeNetworkStatus() to include streaming modes** + +Add streaming cases to `computeNetworkStatus()`: + +```swift +private func computeNetworkStatus() -> NetworkStatus? { + if remoteClient.connectionState.isConnected { + let hostName: String + if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" } + return NetworkStatus( + mode: .remote(hostName: hostName), + onDisconnect: { [remoteClient] in remoteClient.disconnect() }, + onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) } + ) + } + if let server = hostServer, server.isHosting { + return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName)) + } + if let server = streamingServer, server.isRunning { + return NetworkStatus(mode: .streamHosting(tunnelURL: tunnelManager.tunnelURL)) + } + if case .connected(let hostName) = streamingClient.state { + return NetworkStatus( + mode: .streamClient(hostName: hostName), + onDisconnect: { [weak self] in self?.exitStreamingClientMode() }, + onRefreshLibrary: { [streamingClient] in streamingClient.requestDBRefresh() } + ) + } + return nil +} +``` + +- [ ] **Step 5: Add streaming settings sheet** + +Add a `.sheet` modifier for streaming connection settings. After the existing `.sheet(isPresented: $showConnectionSheet)` block: + +```swift +.sheet(isPresented: $showStreamingSettings) { + VStack(spacing: 16) { + Text("Connect to Streaming Host") + .font(.headline) + + TextField("Host URL", text: $streamHostURL) + .textFieldStyle(.roundedBorder) + + SecureField("API Key", text: $streamAPIKey) + .textFieldStyle(.roundedBorder) + + HStack { + Button("Cancel") { + showStreamingSettings = false + } + Button("Connect") { + showStreamingSettings = false + enterStreamingClientMode() + } + .disabled(streamHostURL.isEmpty || streamAPIKey.isEmpty) + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 400) +} +``` + +- [ ] **Step 6: Build to verify everything compiles** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 7: Run all tests to verify nothing is broken** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add Music/MusicApp.swift Music.xcodeproj +git commit -m "feat: wire up streaming host and client in MusicApp" +``` + +--- + +## Task 17: Update ContentView for Streaming Status Display + +**Files:** +- Modify: `Music/ContentView.swift` + +The network status bar in ContentView already displays `NetworkStatus` — we just need to make sure the new streaming modes show properly and that editing is disabled in streaming client mode. + +- [ ] **Step 1: Update the network status bar display** + +In `Music/ContentView.swift`, find the network status bar section (around line 29-56). The bar likely reads `networkStatus?.mode`. Add handling for the new cases. If the existing code uses a `switch` on `networkStatus.mode`, add: + +```swift +case .streamHosting(let url): + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + Text(url != nil ? "Streaming · \(url!)" : "Streaming server starting...") + } +case .streamClient(let host): + HStack { + Image(systemName: "music.note.tv") + Text("Streaming from \(host)") + Spacer() + if let onRefresh = networkStatus?.onRefreshLibrary { + Button("Refresh Library") { onRefresh() } + .buttonStyle(.borderless) + } + if let onDisconnect = networkStatus?.onDisconnect { + Button("Disconnect") { onDisconnect() } + .buttonStyle(.borderless) + } + } +``` + +If the code uses `networkStatus.statusMessage`, the `statusMessage` property added in Task 9 already handles all modes. + +- [ ] **Step 2: Disable editing in streaming client mode** + +Anywhere the UI allows playlist creation, track deletion, or library modification, check `networkStatus?.isRemoteMode`. This property already returns `true` for `.streamClient` (set in Task 9). Existing guards for remote mode should cover streaming client mode automatically. + +- [ ] **Step 3: Build and verify** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 +``` + +Expected: BUILD SUCCEEDED + +- [ ] **Step 4: Commit** + +```bash +git add Music/ContentView.swift +git commit -m "feat: update ContentView for streaming status display" +``` + +--- + +## Task 18: End-to-End Integration Test + +**Files:** +- Create: `MusicTests/StreamingIntegrationTests.swift` + +Full round-trip test: start server, connect client, verify DB download, request HLS manifest. + +- [ ] **Step 1: Write integration tests** + +`MusicTests/StreamingIntegrationTests.swift`: + +```swift +import Testing +import Foundation +@testable import Music +@testable import MusicShared + +@MainActor +struct StreamingIntegrationTests { + static let testAPIKey = "integration-test-key" + + // Full flow: start server, authenticate, download DB, request manifest. + // Steps: + // 1. Create an in-memory DB and insert a test track + // 2. Start StreamingServer on a random port + // 3. Authenticate via GET /auth + // 4. Download DB via GET /db + // 5. Open the downloaded DB and verify the track is present + // 6. Request HLS manifest for the track (will fail since file doesn't exist, but verifies routing) + @Test func fullConnectionFlow() async throws { + // 1. Setup + let db = try DatabaseService(inMemory: true) + var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song") + try db.insert(&track) + let trackId = try #require(track.id) + + // 2. Start server + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { Task { await server.stop() } } + let port = try #require(server.actualPort) + let baseURL = "http://localhost:\(port)" + + // 3. Authenticate + var authReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.auth)")!) + authReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + let (authData, authResp) = try await URLSession.shared.data(for: authReq) + let authHTTP = try #require(authResp as? HTTPURLResponse) + #expect(authHTTP.statusCode == 200) + let authResponse = try JSONDecoder().decode(AuthResponse.self, from: authData) + #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) + + // 4. Download DB + var dbReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.db)")!) + dbReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + let (dbData, dbResp) = try await URLSession.shared.data(for: dbReq) + let dbHTTP = try #require(dbResp as? HTTPURLResponse) + #expect(dbHTTP.statusCode == 200) + #expect(dbData.count > 0) + + // 5. Verify downloaded DB contains the track + let tempPath = FileManager.default.temporaryDirectory + .appendingPathComponent("integration_test_\(UUID().uuidString).sqlite").path + defer { try? FileManager.default.removeItem(atPath: tempPath) } + try dbData.write(to: URL(fileURLWithPath: tempPath)) + let downloadedDb = try DatabaseService(path: tempPath) + let tracks = try downloadedDb.fetchTracks(search: "", sortColumn: "title", ascending: true) + #expect(tracks.count == 1) + #expect(tracks[0].title == "Test Song") + + // 6. Request manifest (routing works, but file won't exist so expect 500) + var manifestReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.trackManifest(trackId: trackId))")!) + manifestReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") + let (_, manifestResp) = try await URLSession.shared.data(for: manifestReq) + let manifestHTTP = try #require(manifestResp as? HTTPURLResponse) + // File at /tmp/test.mp3 doesn't exist → server returns 500 + // This is expected — we're testing routing, not actual file serving + #expect(manifestHTTP.statusCode == 500 || manifestHTTP.statusCode == 200) + } + + // Verifies that requests without auth get 401. + @Test func unauthenticatedRequestsRejected() async throws { + let db = try DatabaseService(inMemory: true) + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { Task { await server.stop() } } + let port = try #require(server.actualPort) + + let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 401) + } + + // Verifies that wrong API key gets 401. + @Test func wrongApiKeyRejected() async throws { + let db = try DatabaseService(inMemory: true) + let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) + try await server.start() + defer { Task { await server.stop() } } + let port = try #require(server.actualPort) + + var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) + request.setValue("Bearer wrong-key", forHTTPHeaderField: "Authorization") + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 401) + } +} +``` + +- [ ] **Step 2: Run integration tests** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test \ + -only-testing:MusicTests/StreamingIntegrationTests 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run the full test suite** + +```bash +xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 +``` + +Expected: ALL tests pass — no regressions. + +- [ ] **Step 4: Commit** + +```bash +git add MusicTests/StreamingIntegrationTests.swift Music.xcodeproj +git commit -m "test: add end-to-end streaming integration tests" +``` + +--- + +## Summary + +| Task | What | Files | +|------|------|-------| +| 1 | Create MusicShared package, move RemoteProtocol | Package, protocol, tests | +| 2 | Add AppRole enum | `AppRole.swift` | +| 3 | Add constants, routes, API models | 3 files in MusicShared | +| 4 | Add HLSManifestGenerator (TDD) | Generator + tests | +| 5 | Define PlaybackProvider protocol | `PlaybackProvider.swift` | +| 6 | Conform AudioService to PlaybackProvider | 1-line change | +| 7 | Create RemotePlaybackProvider | New provider | +| 8 | Refactor PlayerViewModel + update tests | ViewModel + tests | +| 9 | Update NetworkStatus for streaming modes | `NetworkStatus.swift` | +| 10 | Add Hummingbird dependency | `Package.swift` | +| 11 | Create HLSSegmenter (TDD) | Segmenter + tests | +| 12 | Create StreamingServer | Hummingbird routes + tests | +| 13 | Create TunnelManager | cloudflared process | +| 14 | Create StreamingPlaybackProvider | AVPlayer + HLS | +| 15 | Create StreamingClient | HTTP + WebSocket client | +| 16 | Wire up MusicApp.swift | Mode switching, menus, sheets | +| 17 | Update ContentView | Status bar, edit guards | +| 18 | Integration tests | End-to-end round-trip | diff --git a/docs/superpowers/specs/2026-05-26-music-streaming-design.md b/docs/superpowers/specs/2026-05-26-music-streaming-design.md new file mode 100644 index 0000000..3ae07fd --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-music-streaming-design.md @@ -0,0 +1,276 @@ +# Music Streaming — Design Spec + +## Overview + +Add internet-based music streaming to the Music app. A **host** serves its MP3 library as HLS streams over HTTPS. A **client** downloads the host's database for local browsing and plays audio by streaming from the host. Audio plays on the client, not the host. + +This is distinct from the existing remote-mode design (LAN, audio on host). Here the client is an independent player that happens to source its audio and library from a remote host. + +## Architecture + +``` +┌──────────────────────────┐ ┌──────────────────────────┐ +│ HOST (Mac) │ │ CLIENT (Mac/iOS) │ +│ │ │ │ +│ Existing App │ │ Existing App │ +│ ┌────────────────────┐ │ │ ┌────────────────────┐ │ +│ │ DatabaseService │──┼── GET /db ──► │ Local DB copy │ │ +│ │ (source of truth) │ │ │ │ (read-only browse) │ │ +│ └────────────────────┘ │ │ └────────────────────┘ │ +│ ┌────────────────────┐ │ │ ┌────────────────────┐ │ +│ │ StreamingServer │ │◄── HLS ─────│ │ AVPlayer │ │ +│ │ - Hummingbird HTTP │ │ requests │ │ (buffered playback)│ │ +│ │ - HLS segmenter │ │ │ └────────────────────┘ │ +│ │ - WebSocket │ │ │ ┌────────────────────┐ │ +│ └────────────────────┘ │ │ │ WebSocket client │ │ +│ ┌────────────────────┐ │◄── cmds ────│ │ (RemoteCommand / │ │ +│ │ Cloudflare Tunnel │ │── events ──►│ │ HostEvent) │ │ +│ │ (cloudflared) │ │ │ └────────────────────┘ │ +│ └────────────────────┘ │ │ │ +└──────────────────────────┘ └──────────────────────────┘ + │ + ▼ + https://music.yourdomain.com + (Cloudflare edge) +``` + +### Key Difference from Remote Mode + +In remote mode, the client is a remote control — audio plays on the host. In streaming mode, the client is an independent player — it streams audio from the host and plays it locally. The host doesn't play anything when serving a streaming client. + +## MusicShared Swift Package + +A local Swift package inside the repo, holding code shared between host and client (and later, an iOS target). + +Contents: + +- **`RemoteProtocol.swift`** — moved from `Music/Remote/`. Contains `RemoteCommand`, `HostEvent`, `PlaybackStatePayload`, `HandshakeMessage`, `RemoteProtocolVersion`. +- **`HLSManifestGenerator.swift`** — pure function: given track duration and segment size, produces `.m3u8` playlist text. No I/O. +- **`APIModels.swift`** — shared DTOs: `AuthResponse` (host info, protocol version), `DBMetadata` (version, checksum for conditional re-download). +- **`Routes.swift`** — route path constants so host and client stay in sync. +- **`StreamingConstants.swift`** — segment duration (6s), default port (8420), protocol version. + +## HTTP Endpoints + +All endpoints require the `Authorization: Bearer ` header. Served by Hummingbird on the host. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/auth` | Validate API key, return host name + protocol version | +| `GET` | `/db` | Download the full SQLite database file | +| `GET` | `/tracks/:id/stream.m3u8` | HLS manifest for a track | +| `GET` | `/tracks/:id/segments/:index.mp3` | Individual MP3 audio segment | +| `GET` | `/ws` | WebSocket upgrade for real-time command/event channel | + +No REST API for browsing — the client downloads the full database and queries it locally using existing `DatabaseService` code. + +## HLS Streaming + +### On-the-Fly Segmentation + +When a client requests a track's manifest: + +1. Look up the track's file path in the database. +2. Read MP3 duration from file metadata (cached after first read). +3. Generate a `.m3u8` playlist with N segments of 6 seconds each. + +When a client requests a segment: + +1. Use `AVAssetReader` with a time range (`CMTimeRange`) for the requested segment (e.g., segment 2 = 12s–18s). +2. `AVAssetReader` handles frame-boundary alignment and VBR files correctly. +3. Return the extracted audio bytes. + +This avoids raw byte slicing, which breaks on VBR files and frame-boundary misalignment. + +### Manifest Format + +``` +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.0, +segments/0.mp3 +#EXTINF:6.0, +segments/1.mp3 +#EXTINF:4.2, +segments/2.mp3 +#EXT-X-ENDLIST +``` + +### Design Decisions + +- **MP3 byte-range slicing** instead of transcoding to AAC. Avoids CPU overhead; `AVPlayer` handles MP3 segments without issues. +- **6-second segments**: HLS standard. Short enough for responsive seeking, long enough to avoid excessive HTTP requests for a single listener. +- **No adaptive bitrate**: the source files are fixed-bitrate MP3s. No need for multiple quality renditions. + +## Cloudflare Tunnel + +The host uses `cloudflared` to expose its local Hummingbird server to the internet. + +### Quick Tunnel (Development) + +``` +cloudflared tunnel --url http://localhost:8420 +``` + +- Zero config, no account required. +- Generates a random `https://xxx-yyy-zzz.trycloudflare.com` URL. +- URL changes on every restart — must be copied to the client each time. + +### Named Tunnel (Recommended for Daily Use) + +One-time setup with a Cloudflare account and domain: + +``` +cloudflared tunnel create music +cloudflared tunnel route dns music music.yourdomain.com +``` + +Then the app launches: + +``` +cloudflared tunnel run --url http://localhost:8420 music +``` + +- Stable URL: `https://music.yourdomain.com`. +- Configure the client once, never touch it again. + +### App Integration + +- The host manages the `cloudflared` process as a child process (`Process` in Swift). +- On host start: launch `cloudflared`, parse the tunnel URL from stdout. +- On host stop: terminate the `cloudflared` process. +- The host UI displays the current tunnel URL for the user to share with the client. +- The app supports both modes: a toggle or setting to choose quick vs named tunnel. + +### Prerequisite + +`cloudflared` must be installed separately (`brew install cloudflared`). The app checks for its presence on host startup and shows a clear error with install instructions if missing. + +## Authentication + +Static API key for personal use. + +- The host generates a random API key on first setup (or the user sets one manually). +- The key is stored in the host's Keychain. +- The client stores the host URL + API key in its Keychain. +- Every HTTP request and WebSocket upgrade includes `Authorization: Bearer `. +- Invalid keys receive HTTP 401. No retry, no session tokens, no expiry — a static secret over HTTPS is sufficient for single-user. + +## Client-Side Playback + +### Connection Flow + +1. User enters host URL + API key in the connection settings (one-time). +2. Client calls `GET /auth` to validate credentials and check protocol version. +3. Client calls `GET /db` to download the SQLite database, saved to `Application Support/Music/streaming_db.sqlite`. +4. Client opens the DB with `DatabaseService` in read-only mode. +5. Client establishes WebSocket connection to `/ws`. +6. App transitions to streaming mode — existing views reload from the downloaded DB. + +### Playback + +When the user picks a track: + +1. Client constructs the HLS URL: `https:///tracks//stream.m3u8`. +2. Creates an `AVPlayer` with an `AVURLAsset`, injecting the API key via a custom `AVAssetResourceLoaderDelegate` or URL request headers. +3. `AVPlayer` fetches the manifest, then segments on demand. Buffering, seeking, and playback are handled natively. +4. Client sends `RemoteCommand` over WebSocket to keep the host aware of what's playing (for state sync if multiple clients in the future). + +### AudioService Abstraction + +The existing `AudioService` plays local files. For streaming, a parallel `StreamingAudioService` wraps `AVPlayer` with HLS URLs. Both conform to a shared protocol so `PlayerViewModel` works with either. + +### Database Refresh + +Same as remote mode: send `RemoteCommand.refreshDB` over WebSocket → host signals `HostEvent.dbReady` → client re-downloads the DB and reloads views. + +## WebSocket Channel + +Reuses the existing `RemoteCommand` / `HostEvent` protocol (JSON over WebSocket). + +### Client → Host + +| Command | Purpose | +|---------|---------| +| `play(trackId, queueIds)` | Inform host what the client is playing | +| `pause` | Client paused | +| `resume` | Client resumed | +| `next` / `previous` | Client changed track | +| `seek(position)` | Client seeked | +| `setVolume(level)` | Client volume changed | +| `toggleShuffle` | Client toggled shuffle | +| `refreshDB` | Request fresh database | + +In streaming mode, these commands are informational (the client controls its own playback). They keep the host aware of client state for logging and potential future multi-client coordination. + +### Host → Client + +| Event | Purpose | +|-------|---------| +| `dbReady` | New database available for download | +| `error(message)` | Server-side error (track file missing, etc.) | + +`playbackState` events are less critical in streaming mode since the client drives its own playback, but can be used for sync verification. + +### Handshake & Keep-Alive + +- On WebSocket connect: exchange `HandshakeMessage` with protocol version and app version. +- Ping/pong every 5 seconds. Connection declared lost after 3 missed pings (15s). + +## UI Changes + +### Host Mode + +- **"Start Streaming Server"** menu toggle — starts Hummingbird + `cloudflared`. +- Status indicator: "Streaming server running · `https://music.yourdomain.com`". +- Settings panel: API key display/regenerate, tunnel mode (quick/named), named tunnel config. + +### Client Mode + +- **Connection settings**: host URL + API key fields, "Connect" / "Disconnect" button. +- Status indicator: "Connected to [host]" or "Disconnected". +- "Refresh Library" action to re-download the database. +- All existing views (HomeView, TrackTableView, playlists, search, player controls) work unchanged against the local DB copy. +- Playlist creation/editing disabled (read-only snapshot). + +### Mode Selection + +A setting to choose the app's role: **Local** (default, current behavior), **Host** (serves library), or **Client** (streams from host). Persisted in UserDefaults. + +## Testing + +### Unit Tests + +- `HLSManifestGenerator`: correct `.m3u8` output for various track durations, edge cases (very short tracks, exact multiples of segment duration). +- `RemoteCommand` / `HostEvent` Codable round-trip (already partially covered in `RemoteProtocolTests.swift`). +- API key validation logic. +- Segment extraction: `AVAssetReader` produces valid audio for each segment time range, including edge cases (VBR files, last segment shorter than 6s). + +### Integration Tests + +- Loopback streaming: start Hummingbird server in-process, request manifest + segments over localhost, verify valid HLS output. +- Database download: verify downloaded DB matches source schema and row counts. +- Auth rejection: requests without or with wrong API key receive 401. +- WebSocket handshake: version mismatch is caught and reported. + +### Manual Test Scenarios + +- Happy path: start host → connect client → browse library → play track → audio streams and plays on client. +- Seek mid-track → playback resumes from correct position. +- Network interruption → client buffers, resumes when connection returns. +- Kill host mid-playback → client shows error cleanly. +- Add tracks on host → client refreshes DB → new tracks appear. +- Wrong API key → client shows auth error. +- `cloudflared` not installed → host shows clear install instructions. + +## Scope & Constraints + +- **Single client** for v1. No concurrent listener handling. +- **Read-only client**: no playlist or library modifications from the client. +- **MP3 only**: HLS segmentation assumes MP3 source files (matches current library). +- **`cloudflared` required**: not bundled, must be installed separately. +- **No offline mode**: client requires active connection to stream. Downloaded DB enables browsing but not playback without the host. +- **No transcoding**: segments served as raw MP3 byte ranges. +- **Hummingbird dependency**: added via Swift Package Manager for the embedded HTTP server. diff --git a/docs/superpowers/specs/2026-05-26-remote-mode-design.md b/docs/superpowers/specs/2026-05-26-remote-mode-design.md new file mode 100644 index 0000000..93b5de3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-remote-mode-design.md @@ -0,0 +1,270 @@ +# Remote Mode — Design Spec + +## Overview + +Add a Host/Remote mode to the Music app so a MacBook can control playback on a Mac Mini over the local network. The remote sees the full library and controls playback, but audio plays on the host. The remote is read-only — no playlist or library modifications for v1. + +## Architecture + +Two roles the app can operate in, one at a time: + +- **Host:** Runs a network server, advertises via Bonjour, serves its database, accepts playback commands, streams playback state. +- **Remote:** Discovers hosts via Bonjour, downloads the host's database, sends playback commands, displays synced playback state. + +``` +┌──────────────────────┐ ┌──────────────────────┐ +│ MAC MINI │ │ MACBOOK │ +│ (Host) │ │ (Remote) │ +│ │ │ │ +│ Existing App │ │ Existing App │ +│ ┌────────────────┐ │ │ ┌────────────────┐ │ +│ │ AudioService │ │◄────────│ │ RemoteClient │ │ +│ │ PlayerViewModel│ │ WebSocket│ │ │ │ +│ │ DatabaseService│──┼────────►│ │ Local DB copy │ │ +│ └────────────────┘ │ HTTP │ └────────────────┘ │ +│ ┌────────────────┐ │ │ │ +│ │ HostServer │ │ │ Reuses all existing │ +│ │ - HTTP (DB) │ │ │ ViewModels & Views │ +│ │ - WebSocket │ │ │ for browsing │ +│ │ - Bonjour │ │ │ │ +│ └────────────────┘ │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +### Playback Abstraction + +A `PlaybackController` protocol abstracts where playback happens: + +- `LocalPlaybackController` — wraps the existing `AudioService` + `PlayerViewModel` logic. This is what the app uses today. +- `RemotePlaybackController` — sends commands over WebSocket to the host, receives state updates, and updates the `PlayerViewModel` accordingly. + +Views and ViewModels call the same methods (`play`, `pause`, `next`, `seek`, etc.) regardless of which controller is active. The active controller decides whether that's local audio or a network command. + +## Host Server + +New service: `HostServer`. + +### Bonjour Advertisement + +- Uses `NWListener` with service type `_musicremote._tcp`. +- Service name is the computer's local name. +- Automatically discoverable on the local network when hosting is enabled. + +### HTTP — Database Download + +- When a remote connects, its first request is `GET /db`. +- The host reads `db.sqlite` from its Application Support directory and streams it as a binary response. +- Typically a few MB, under a second on WiFi. + +### WebSocket — Command & State Channel + +After the DB download, the remote establishes a WebSocket connection for bidirectional communication. + +**Remote → Host (commands):** + +| Command | Payload | +|---------|---------| +| `play` | `trackId`, `queueIds` (array of track IDs) | +| `pause` | — | +| `resume` | — | +| `next` | — | +| `previous` | — | +| `seek` | `position` (seconds) | +| `setVolume` | `level` (0.0–1.0) | +| `toggleShuffle` | — | +| `refreshDB` | — | + +**Host → Remote (events):** + +| Event | Payload | +|-------|---------| +| `playbackState` | `trackId`, `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` | +| `dbReady` | — (sent after refreshDB, signals new DB is available for download) | +| `error` | `message` (human-readable) | + +### State Update Frequency + +- Immediate on discrete events: play, pause, track change, volume change, shuffle toggle. +- Every ~1 second while playing for progress bar position. +- The remote interpolates locally between updates for smooth scrubber movement. + +### Connection Limits + +Single remote connection at a time for v1. A second connection attempt is rejected with a clear error. + +## Remote Client + +New service: `RemoteClient`. + +### Discovery + +- Uses `NWBrowser` to scan for `_musicremote._tcp` services. +- Presents discovered hosts by computer name in the connection sheet. +- Resolves the selected endpoint to get IP/port. + +### Connection Flow + +1. Connect to the host's HTTP endpoint. +2. Download `db.sqlite`, save to `Application Support/Music/remote_db.sqlite`. +3. Open the downloaded DB with `DatabaseService` in read-only mode. +4. Establish the WebSocket connection. +5. App transitions to remote mode — existing ViewModels reload from the downloaded DB. + +### Command Forwarding + +In remote mode, the `RemotePlaybackController` intercepts all playback calls and sends them as WebSocket commands instead of calling the local `AudioService`. + +### State Sync + +The remote listens for `playbackState` messages and updates the `PlayerViewModel`: +- Current track is looked up by ID from the local DB copy. +- `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` are set directly. +- SwiftUI observation triggers UI updates automatically. + +### DB Refresh + +A "Refresh Library" action sends `refreshDB`, the host signals `dbReady`, the remote re-downloads the DB and reloads the ViewModels. + +### Disconnection + +On disconnect (user-initiated or connection drop), the app returns to local mode. The temporary remote DB file is deleted. + +## Message Protocol + +JSON over WebSocket. Swift `Codable` enums for type safety. + +```json +// Remote → Host +{"type": "play", "payload": {"trackId": 42, "queueIds": [42, 43, 44, 45]}} +{"type": "pause"} +{"type": "resume"} +{"type": "next"} +{"type": "previous"} +{"type": "seek", "payload": {"position": 65.3}} +{"type": "setVolume", "payload": {"level": 0.75}} +{"type": "toggleShuffle"} +{"type": "refreshDB"} + +// Host → Remote +{"type": "playbackState", "payload": { + "trackId": 42, + "isPlaying": true, + "currentTime": 65.3, + "duration": 210.0, + "volume": 0.75, + "isShuffled": false +}} +{"type": "dbReady"} +{"type": "error", "payload": {"message": "Track file not found"}} +``` + +### Handshake + +On WebSocket connect, host and client exchange a handshake message with app version. Version mismatches are caught early and logged. + +### Keep-Alive + +WebSocket ping/pong at 5-second intervals. If 3 consecutive pings go unanswered, the connection is declared lost. + +## UI Changes + +### Menu Bar + +- **"Enable Host Mode"** — toggle menu item. Starts/stops the `HostServer`. +- **"Connect to Remote..."** — opens the connection sheet. + +### Connection Sheet (Remote Side) + +Modal sheet showing: +- List of discovered Bonjour hosts (computer name + connectivity indicator). +- "Connect" button for the selected host. +- Progress indicator during DB download. +- Error state with retry if connection fails. + +### Remote Mode Indicators + +When connected as a remote: +- A persistent banner/badge showing "Connected to [host name]" with a disconnect button. +- Playlist creation/editing UI disabled (greyed out context menus, hidden "New Playlist"). +- "Open Music Folder..." menu item disabled. +- "Refresh Library" action available (triggers DB re-download). + +### Host Mode Indicators + +When hosting: +- Status indicator showing "Hosting" or "Hosting · [remote name] connected". + +### Unchanged + +Track table, player controls, search bar, home view, playlist bar — all work as-is against the local DB copy and the `PlaybackController` abstraction. + +## Observability + +### Structured Logging + +`os.Logger` with subsystem `com.music.remote` and two categories: `host` and `client`. Logs are filterable in Console.app. + +| Level | Examples | +|-------|---------| +| Info | "Host started on port 8432", "Remote connected: Laurent's MacBook", "DB download complete (2.4 MB, 340ms)" | +| Debug | Command send/receive, state update cycle, Bonjour browse events, connection lifecycle transitions | +| Error | Connection refused, DB read failure, WebSocket decode failure, unexpected disconnect with reason | + +### Connection State Machine + +Every state transition is logged and drives user-visible status: + +``` +Disconnected → Discovering → Found Host → Downloading DB → Connecting WebSocket → Connected + ↑ │ + └──── Connection Lost ◄───────────────────────────────────────────────────────────┘ +``` + +User-visible status messages: "Searching for hosts...", "Connecting to [name]...", "Downloading library...", "Connected to [name]", "Connection lost — Reconnect?" + +### Error Messages + +Every error includes: +- A clean, human-readable summary for the user (shown in UI). +- The underlying `NWError` description in the log for debugging. + +Examples: "Host refused connection", "Download timed out after 10s", "Network changed", "Host stopped hosting". + +### Diagnostics + +- Version handshake on connect catches protocol mismatches early. +- WebSocket keep-alive detects stale connections within 15 seconds. +- All incoming commands logged at debug level on the host for traceability. + +## Testing + +### Unit Tests + +- `RemoteCommand` / `HostEvent` Codable round-trip for every message type. +- `RemotePlaybackController` sends correct WebSocket messages for each action. +- Connection state machine: valid transitions succeed, invalid transitions are rejected. +- `HostServer` command dispatch: incoming commands map to correct `PlayerViewModel` calls. + +### Integration Tests + +- Loopback connection: `HostServer` + `RemoteClient` in the same process over localhost — full flow from DB download through command/response round-trip. +- DB download integrity: downloaded DB matches source schema and row counts. +- State sync accuracy: play a track on host, verify remote receives correct `playbackState` values. + +Real network connections in integration tests — no mocks for the networking layer. + +### Manual Test Scenarios + +- Happy path: enable host → connect remote → browse → play → verify audio on host, UI synced on remote. +- Kill host app mid-playback → remote shows "Connection lost" cleanly. +- Disconnect WiFi on remote → reconnect flow works. +- Scan new folder on host while remote connected → remote can refresh and see new tracks. +- Attempt playlist creation on remote → properly disabled. + +## Scope & Constraints + +- **v1 only:** Single remote, read-only, no authentication, local network only. +- **No changes to existing playback logic:** The `HostServer` wraps `PlayerViewModel` and `AudioService`, it doesn't modify them. +- **No dependencies added:** All networking uses Apple's Network.framework. +- **Existing UI untouched:** Only additions are menu items, connection sheet, and status indicators. +- **Play counts track on the host:** Since the host is playing the audio, play count increments happen on the host's database. The remote's local DB copy is a read-only snapshot and is not written to.