From 1127993e26f8b1ea8e2e0eb46bb8380e440f1da2 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 21 Mar 2026 10:41:51 -0500 Subject: [PATCH 1/9] Fix audio interruption resume and headphone disconnect cleanup play(stationId:) now clears existing spin players before starting fresh, preventing stale AVPlayers from being reused after audio interruptions. Removed misleading interruption state saves from headphone disconnect handler and dead isSuspended property. Added scheduleFetcher injection for testability and 8 new tests covering all interruption codepaths. Co-Authored-By: Claude Opus 4.6 --- .../Streaming/StreamingStationPlayer.swift | 80 ++++---- .../StreamingStationPlayerTests.swift | 173 ++++++++++++++++++ 2 files changed, 217 insertions(+), 36 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift b/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift index 1fef633..6522c92 100644 --- a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift @@ -56,6 +56,9 @@ final public class StreamingStationPlayer: ObservableObject { private var authProvider: PlayolaAuthenticationProvider? private let playerFactory: () -> AVPlayerProviding private let audioSessionManager = AudioSessionManager() + var scheduleFetcher: (String, URL) async throws -> Schedule = { stationId, baseUrl in + try await ScheduleService.getSchedule(stationId: stationId, baseUrl: baseUrl) + } // MARK: - Internal State @@ -65,9 +68,8 @@ final public class StreamingStationPlayer: ObservableObject { private var schedulingTask: Task? // Audio interruption state - private var isSuspended = false - private var wasPlayingBeforeInterruption = false - private var interruptedStationId: String? + var wasPlayingBeforeInterruption = false + var interruptedStationId: String? // MARK: - Lifecycle @@ -116,19 +118,22 @@ final public class StreamingStationPlayer: ObservableObject { /// Begins streaming playback of the specified station. public func play(stationId: String, atDate: Date? = nil) async throws { - isSuspended = false wasPlayingBeforeInterruption = false interruptedStationId = nil schedulingTask?.cancel() + + for (_, player) in spinPlayers { + player.stop() + } + spinPlayers.removeAll() self.scheduleOffset = atDate?.timeIntervalSinceNow self.stationId = stationId self.state = .loading try await audioSessionManager.activate() - let schedule = try await ScheduleService.getSchedule( - stationId: stationId, baseUrl: baseUrl) + let schedule = try await scheduleFetcher(stationId, baseUrl) self.currentSchedule = schedule let currentSpins = schedule.current(offsetTimeInterval: scheduleOffset) @@ -240,8 +245,7 @@ final public class StreamingStationPlayer: ObservableObject { } private func performScheduleUpdate(stationId: String) async throws { - let updatedSchedule = try await ScheduleService.getSchedule( - stationId: stationId, baseUrl: baseUrl) + let updatedSchedule = try await scheduleFetcher(stationId, baseUrl) let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { $0.airtime < .now + StreamingStationPlayer.scheduleWindow @@ -283,8 +287,6 @@ final public class StreamingStationPlayer: ObservableObject { } if wasUsingHeadphones && isPlaying { - interruptedStationId = stationId - wasPlayingBeforeInterruption = true stop() } default: @@ -300,48 +302,54 @@ final public class StreamingStationPlayer: ObservableObject { switch type { case .began: - isSuspended = true - wasPlayingBeforeInterruption = isPlaying - interruptedStationId = stationId - schedulingTask?.cancel() - schedulingTask = nil + handleInterruptionBegan() case .ended: - isSuspended = false guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - if options.contains(.shouldResume) && wasPlayingBeforeInterruption { - resumeAfterInterruption() - } + handleInterruptionEnded(shouldResume: options.contains(.shouldResume)) @unknown default: break } } + #endif - private func resumeAfterInterruption() { - guard let stationToResume = interruptedStationId else { return } + func handleInterruptionBegan() { + wasPlayingBeforeInterruption = isPlaying + interruptedStationId = stationId + schedulingTask?.cancel() + schedulingTask = nil + } - Task { @MainActor in - do { - try await self.audioSessionManager.activate() - try await self.play(stationId: stationToResume) - } catch { - os_log( - "Failed to resume after interruption: %@", - log: StreamingStationPlayer.logger, type: .error, - error.localizedDescription) - await errorReporter.reportError( - error, context: "Failed to resume playback after interruption", level: .error) - } + func handleInterruptionEnded(shouldResume: Bool) { + if shouldResume && wasPlayingBeforeInterruption { + resumeAfterInterruption() + } + } - self.interruptedStationId = nil - self.wasPlayingBeforeInterruption = false + func resumeAfterInterruption() { + guard let stationToResume = interruptedStationId else { return } + + Task { @MainActor in + do { + try await self.audioSessionManager.activate() + try await self.play(stationId: stationToResume) + } catch { + os_log( + "Failed to resume after interruption: %@", + log: StreamingStationPlayer.logger, type: .error, + error.localizedDescription) + await errorReporter.reportError( + error, context: "Failed to resume playback after interruption", level: .error) } + + self.interruptedStationId = nil + self.wasPlayingBeforeInterruption = false } - #endif + } } // MARK: - StreamingSpinPlayerDelegate diff --git a/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift b/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift index 6a19f0c..46d8919 100644 --- a/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift +++ b/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift @@ -35,6 +35,17 @@ struct StreamingStationPlayerTests { return (stationPlayer, playerTracker) } + private func createMockSchedule(stationId: String = "test-station") -> Schedule { + let now = Date() + let spin = Spin.mockWith( + id: "current-spin", + airtime: now.addingTimeInterval(-10), + stationId: stationId, + audioBlock: AudioBlock.mockWith(durationMS: 60000, endOfMessageMS: 60000) + ) + return Schedule(stationId: stationId, spins: [spin]) + } + // MARK: - Initial State @Test("Initial state is idle") @@ -155,6 +166,168 @@ struct StreamingStationPlayerTests { player.state = .loading #expect(!player.isPlaying) } + // MARK: - Play Clears Existing Spin Players + + @Test("play() clears existing spin players before starting fresh") + func testPlayClearsExistingSpinPlayers() async throws { + let tracker = MockAVPlayerTracker() + let (player, _) = createStationPlayer(tracker: tracker) + + let mockSchedule = createMockSchedule() + player.scheduleFetcher = { _, _ in mockSchedule } + + // Manually add existing spin players to simulate pre-interruption state + let staleSpin = Spin.mockWith(id: "stale-spin-1") + let staleSpinPlayer = StreamingSpinPlayer( + delegate: player, + playerFactory: { tracker.createPlayer() } + ) + _ = await staleSpinPlayer.load(staleSpin) + player.spinPlayers["stale-spin-1"] = staleSpinPlayer + + let staleSpin2 = Spin.mockWith(id: "stale-spin-2") + let staleSpinPlayer2 = StreamingSpinPlayer( + delegate: player, + playerFactory: { tracker.createPlayer() } + ) + _ = await staleSpinPlayer2.load(staleSpin2) + player.spinPlayers["stale-spin-2"] = staleSpinPlayer2 + + #expect(player.spinPlayers.count == 2) + + try await player.play(stationId: "test-station") + + // Stale players should be gone, replaced with fresh ones + #expect(player.spinPlayers["stale-spin-1"] == nil) + #expect(player.spinPlayers["stale-spin-2"] == nil) + #expect(!player.spinPlayers.isEmpty) + } + + // MARK: - Audio Interruption Handling + + @Test("Interruption began sets correct state when playing") + func testInterruptionBeganWhilePlaying() { + let (player, _) = createStationPlayer() + player.stationId = "test-station" + player.state = .playing(Spin.mockWith()) + + player.handleInterruptionBegan() + + #expect(player.wasPlayingBeforeInterruption == true) + #expect(player.interruptedStationId == "test-station") + } + + @Test("Interruption began sets wasPlayingBeforeInterruption false when not playing") + func testInterruptionBeganWhileNotPlaying() { + let (player, _) = createStationPlayer() + player.stationId = "test-station" + player.state = .idle + + player.handleInterruptionBegan() + + #expect(player.wasPlayingBeforeInterruption == false) + #expect(player.interruptedStationId == "test-station") + } + + @Test("Interruption ended with shouldResume triggers resume") + func testInterruptionEndedWithShouldResume() async throws { + let tracker = MockAVPlayerTracker() + let (player, _) = createStationPlayer(tracker: tracker) + + let mockSchedule = createMockSchedule() + var fetchCount = 0 + player.scheduleFetcher = { _, _ in + fetchCount += 1 + return mockSchedule + } + + // Simulate interruption began state + player.wasPlayingBeforeInterruption = true + player.interruptedStationId = "test-station" + + player.handleInterruptionEnded(shouldResume: true) + + // Allow the Task in resumeAfterInterruption to execute + try await Task.sleep(for: .milliseconds(100)) + + #expect(fetchCount > 0) + #expect(player.stationId == "test-station") + } + + @Test("Interruption ended without shouldResume does not resume") + func testInterruptionEndedWithoutShouldResume() async throws { + let (player, _) = createStationPlayer() + + var fetchCount = 0 + player.scheduleFetcher = { _, _ in + fetchCount += 1 + return createMockSchedule() + } + + player.wasPlayingBeforeInterruption = true + player.interruptedStationId = "test-station" + + player.handleInterruptionEnded(shouldResume: false) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(fetchCount == 0) + } + + @Test("resumeAfterInterruption clears interruption flags") + func testResumeAfterInterruptionClearsFlags() async throws { + let tracker = MockAVPlayerTracker() + let (player, _) = createStationPlayer(tracker: tracker) + + let mockSchedule = createMockSchedule() + player.scheduleFetcher = { _, _ in mockSchedule } + + player.wasPlayingBeforeInterruption = true + player.interruptedStationId = "test-station" + + player.resumeAfterInterruption() + + try await Task.sleep(for: .milliseconds(100)) + + #expect(player.interruptedStationId == nil) + #expect(player.wasPlayingBeforeInterruption == false) + } + + @Test("resumeAfterInterruption with nil stationId does nothing") + func testResumeAfterInterruptionNilStationId() async throws { + let (player, _) = createStationPlayer() + + var fetchCount = 0 + player.scheduleFetcher = { _, _ in + fetchCount += 1 + return createMockSchedule() + } + + player.interruptedStationId = nil + + player.resumeAfterInterruption() + + try await Task.sleep(for: .milliseconds(100)) + + #expect(fetchCount == 0) + } + + // MARK: - Headphone Disconnect + + @Test("Headphone disconnect stops playback without setting interruption state") + func testHeadphoneDisconnectStopsWithoutInterruptionState() { + let (player, _) = createStationPlayer() + player.stationId = "test-station" + player.state = .playing(Spin.mockWith()) + + // Simulate headphone disconnect by calling stop() directly + // (this is what handleAudioRouteChange does now — just calls stop()) + player.stop() + + #expect(player.stationId == nil) + #expect(player.interruptedStationId == nil) + #expect(player.wasPlayingBeforeInterruption == false) + } } // MARK: - Mock Auth Provider From e39a5b45830f74f7e23e421a0bf12fb44b6ea8b4 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 21 Mar 2026 10:50:52 -0500 Subject: [PATCH 2/9] Add testability comments to internal-for-testing members --- .../Player/Streaming/StreamingStationPlayer.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift b/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift index 6522c92..30e35fb 100644 --- a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift @@ -56,6 +56,7 @@ final public class StreamingStationPlayer: ObservableObject { private var authProvider: PlayolaAuthenticationProvider? private let playerFactory: () -> AVPlayerProviding private let audioSessionManager = AudioSessionManager() + // internal for testability var scheduleFetcher: (String, URL) async throws -> Schedule = { stationId, baseUrl in try await ScheduleService.getSchedule(stationId: stationId, baseUrl: baseUrl) } @@ -67,7 +68,7 @@ final public class StreamingStationPlayer: ObservableObject { private var scheduleOffset: TimeInterval? private var schedulingTask: Task? - // Audio interruption state + // Audio interruption state (internal for testability) var wasPlayingBeforeInterruption = false var interruptedStationId: String? @@ -317,6 +318,7 @@ final public class StreamingStationPlayer: ObservableObject { } #endif + // internal for testability func handleInterruptionBegan() { wasPlayingBeforeInterruption = isPlaying interruptedStationId = stationId @@ -324,12 +326,14 @@ final public class StreamingStationPlayer: ObservableObject { schedulingTask = nil } + // internal for testability func handleInterruptionEnded(shouldResume: Bool) { if shouldResume && wasPlayingBeforeInterruption { resumeAfterInterruption() } } + // internal for testability func resumeAfterInterruption() { guard let stationToResume = interruptedStationId else { return } From 54c313366af1fe364ec886abfcd8aeb4c8e7f83d Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 7 Apr 2026 13:59:27 -0500 Subject: [PATCH 3/9] Revert to 0.14.0 and document streaming approach Revert all source code to the stable 0.14.0 state, removing the streaming player port (0.15.0) which introduced issues. Add STREAMING_APPROACH.md documenting the AVPlayer-based streaming architecture for future reference. --- .../PlayolaPlayerExample/ContentView.swift | 277 ++++++------- STREAMING_APPROACH.md | 106 +++++ .../Player/ListeningSessionReporter.swift | 29 +- .../Player/PlayolaStationPlayer.swift | 2 +- .../Player/ScheduleService.swift | 106 ----- .../Player/Streaming/AVPlayerProviding.swift | 110 ----- .../Streaming/FadeScheduleBuilder.swift | 97 ----- .../Streaming/StreamingSpinPlayer.swift | 339 --------------- .../Streaming/StreamingStationPlayer.swift | 391 ------------------ TODOS.md | 15 - .../FadeScheduleBuilderTests.swift | 203 --------- .../StreamingSpinPlayerTests.swift | 311 -------------- .../StreamingStationPlayerTests.swift | 339 --------------- 13 files changed, 229 insertions(+), 2096 deletions(-) create mode 100644 STREAMING_APPROACH.md delete mode 100644 Sources/PlayolaPlayer/Player/ScheduleService.swift delete mode 100644 Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift delete mode 100644 Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift delete mode 100644 Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift delete mode 100644 Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift delete mode 100644 TODOS.md delete mode 100644 Tests/PlayolaPlayerTests/FadeScheduleBuilderTests.swift delete mode 100644 Tests/PlayolaPlayerTests/StreamingSpinPlayerTests.swift delete mode 100644 Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift diff --git a/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift b/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift index a2ce7bf..3385984 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift @@ -8,11 +8,6 @@ import PlayolaPlayer import SwiftUI -enum PlayerMode: String, CaseIterable { - case streaming = "Streaming" - case download = "Download" -} - // Main thread responsiveness monitor class MainThreadMonitor: ObservableObject { @Published var isResponsive = true @@ -40,7 +35,7 @@ class MainThreadMonitor: ObservableObject { let elapsed = displayLink.timestamp - lastUpdate if elapsed >= 1.0 { fps = Double(frameCount) / elapsed - isResponsive = fps > 30 + isResponsive = fps > 30 // Consider unresponsive if below 30 FPS frameCount = 0 lastUpdate = displayLink.timestamp } @@ -48,62 +43,15 @@ class MainThreadMonitor: ObservableObject { } struct ContentView: View { - @ObservedObject var downloadPlayer = PlayolaStationPlayer.shared - @StateObject var streamingPlayer = StreamingStationPlayer() + @ObservedObject var player = PlayolaStationPlayer.shared @StateObject private var threadMonitor = MainThreadMonitor() @State private var showingStationPicker = false @State private var showingScheduleViewer = false @State private var selectedStationId: String = "9d79fd38-1940-4312-8fe8-3b9b50d49c6c" - @State private var playerMode: PlayerMode = .streaming - - private var isPlaying: Bool { - switch playerMode { - case .streaming: return streamingPlayer.isPlaying - case .download: return downloadPlayer.isPlaying - } - } - - private var nowPlayingSpin: Spin? { - switch playerMode { - case .streaming: - if case .playing(let spin) = streamingPlayer.state { return spin } - case .download: - if case .playing(let spin) = downloadPlayer.state { return spin } - } - return nil - } - - private var isLoading: Bool { - switch playerMode { - case .streaming: - if case .loading = streamingPlayer.state { return true } - case .download: - if case .loading = downloadPlayer.state { return true } - } - return false - } - - private var isIdle: Bool { - switch playerMode { - case .streaming: - if case .idle = streamingPlayer.state { return true } - case .download: - if case .idle = downloadPlayer.state { return true } - } - return false - } - - private var loadingProgress: Float? { - if case .download = playerMode, - case .loading(let progress) = downloadPlayer.state - { - return progress - } - return nil - } var body: some View { ZStack { + // Background gradient LinearGradient( gradient: Gradient(colors: [Color.black, Color.gray.opacity(0.3)]), startPoint: .topLeading, @@ -112,7 +60,7 @@ struct ContentView: View { .ignoresSafeArea() VStack(spacing: 30) { - // Header with thread monitor and player toggle + // Header with thread monitor HStack { VStack(alignment: .leading, spacing: 5) { Text("Main Thread Monitor") @@ -127,46 +75,25 @@ struct ContentView: View { } .padding() - // Player mode toggle - VStack(spacing: 8) { - Picker("Player Mode", selection: $playerMode) { - ForEach(PlayerMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .onChange(of: playerMode) { _, _ in - Task { await stopCurrentPlayer() } - } - - Text( - playerMode == .streaming - ? "AVPlayer streaming (fast startup)" - : "AVAudioEngine download (full file)" - ) - .font(.caption2) - .foregroundColor(.white.opacity(0.5)) - } - Spacer() + // Main content VStack(spacing: 25) { - // Album art placeholder + // Album art placeholder with animation ZStack { RoundedRectangle(cornerRadius: 20) .fill(Color.white.opacity(0.1)) .frame(width: 250, height: 250) - if isPlaying { + if player.isPlaying { Image(systemName: "music.note") .font(.system(size: 80)) .foregroundColor(.white.opacity(0.7)) - .rotationEffect(.degrees(isPlaying ? 360 : 0)) + .rotationEffect(.degrees(player.isPlaying ? 360 : 0)) .animation( - isPlaying + player.isPlaying ? Animation.linear(duration: 3).repeatForever(autoreverses: false) : .default, - value: isPlaying + value: player.isPlaying ) } else { Image(systemName: "radio") @@ -177,7 +104,7 @@ struct ContentView: View { // Now playing info VStack(spacing: 10) { - if let spin = nowPlayingSpin { + if case .playing(let spin) = player.state { Text(spin.audioBlock.title) .font(.title2) .fontWeight(.semibold) @@ -188,24 +115,19 @@ struct ContentView: View { .font(.headline) .foregroundColor(.white.opacity(0.7)) .lineLimit(1) - } else if isLoading { + } else if case .loading(let progress) = player.state { VStack(spacing: 15) { Text("Loading Station...") .font(.headline) .foregroundColor(.white.opacity(0.8)) - if let progress = loadingProgress { - ProgressView(value: progress) - .progressViewStyle(LinearProgressViewStyle(tint: .white)) - .frame(width: 200) - - Text("\(Int(progress * 100))%") - .font(.caption) - .foregroundColor(.white.opacity(0.6)) - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle(tint: .white)) + .frame(width: 200) + + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundColor(.white.opacity(0.6)) } } else { Text("Ready to Play") @@ -217,31 +139,49 @@ struct ContentView: View { // Offset playback controls VStack(spacing: 20) { + // Time offset buttons Text("Play from different times:") .font(.caption) .foregroundColor(.white.opacity(0.6)) HStack(spacing: 15) { - Button("5min ago") { playWithOffset(-300) } - .buttonStyle(OffsetButtonStyle()) - Button("1min ago") { playWithOffset(-60) } - .buttonStyle(OffsetButtonStyle()) - Button("10sec ago") { playWithOffset(-10) } - .buttonStyle(OffsetButtonStyle()) + Button("5min ago") { + playWithOffset(-300) // 5 minutes ago + } + .buttonStyle(OffsetButtonStyle()) + + Button("1min ago") { + playWithOffset(-60) // 1 minute ago + } + .buttonStyle(OffsetButtonStyle()) + + Button("10sec ago") { + playWithOffset(-10) // 10 seconds ago + } + .buttonStyle(OffsetButtonStyle()) } HStack(spacing: 15) { - Button("10sec future") { playWithOffset(10) } - .buttonStyle(OffsetButtonStyle()) - Button("1min future") { playWithOffset(60) } - .buttonStyle(OffsetButtonStyle()) - Button("5min future") { playWithOffset(300) } - .buttonStyle(OffsetButtonStyle()) + Button("10sec future") { + playWithOffset(10) // 10 seconds from now + } + .buttonStyle(OffsetButtonStyle()) + + Button("1min future") { + playWithOffset(60) // 1 minute from now + } + .buttonStyle(OffsetButtonStyle()) + + Button("5min future") { + playWithOffset(300) // 5 minutes from now + } + .buttonStyle(OffsetButtonStyle()) } } // Main playback controls HStack(spacing: 40) { + // Station picker Button( action: { showingStationPicker.toggle() }, label: { @@ -250,21 +190,23 @@ struct ContentView: View { .foregroundColor(.white.opacity(0.8)) }) + // Play/Stop button (current time) Button( action: { playOrPause() }, label: { ZStack { Circle() - .fill(currentButtonColor) + .fill(buttonColor(for: player.state)) .frame(width: 80, height: 80) - Image(systemName: currentButtonIcon) + Image(systemName: buttonIcon(for: player.state)) .font(.title) .foregroundColor(.white) - .offset(x: isIdle ? 3 : 0) + .offset(x: shouldOffsetIcon(for: player.state) ? 3 : 0) // Center play icon } }) + // Schedule viewer Button( action: { showingScheduleViewer.toggle() }, label: { @@ -279,46 +221,28 @@ struct ContentView: View { } } .sheet(isPresented: $showingStationPicker) { - StationPickerView( - selectedStationId: $selectedStationId, - playerMode: playerMode, - streamingPlayer: streamingPlayer - ) + StationPickerView(selectedStationId: $selectedStationId) } .sheet(isPresented: $showingScheduleViewer) { ScheduleViewer(selectedStationId: selectedStationId) } } - private var currentButtonColor: Color { - if isPlaying { return .red } - if isLoading { return .orange } - return .green - } - - private var currentButtonIcon: String { - if isIdle { return "play.fill" } - return "stop.fill" - } - - func stopCurrentPlayer() async { - await downloadPlayer.stop() - streamingPlayer.stop() - } - func playOrPause() { Task { - if isPlaying || isLoading { - await stopCurrentPlayer() - } else { + switch await player.state { + case .loading: + // Cancel loading + await player.stop() + case .playing: + // Stop playing + await player.stop() + case .idle: + // Start playing do { - switch playerMode { - case .streaming: - try await streamingPlayer.play(stationId: selectedStationId) - case .download: - try await downloadPlayer.play(stationId: selectedStationId) - } + try await player.play(stationId: selectedStationId) } catch { + // Handle errors gracefully (including cancellation during loading) print("Failed to start playback: \(error)") } } @@ -327,15 +251,18 @@ struct ContentView: View { func playWithOffset(_ offsetSeconds: TimeInterval) { Task { - await stopCurrentPlayer() + // Always stop current playback first + await player.stop() + + // Calculate the target date let atDate = Date().addingTimeInterval(offsetSeconds) + do { - switch playerMode { - case .streaming: - try await streamingPlayer.play(stationId: selectedStationId, atDate: atDate) - case .download: - try await downloadPlayer.play(stationId: selectedStationId, atDate: atDate) - } + try await player.play( + stationId: selectedStationId, + atDate: atDate + ) + print("Started playback with offset: \(offsetSeconds) seconds (at: \(atDate))") } catch { print("Failed to start offset playback: \(error)") } @@ -350,6 +277,7 @@ struct ThreadResponsivenessIndicator: View { var body: some View { VStack(spacing: 8) { + // Visual spinner that shows thread responsiveness ZStack { Circle() .stroke(Color.white.opacity(0.2), lineWidth: 3) @@ -433,8 +361,6 @@ struct StationInfo: Codable, Identifiable { struct StationPickerView: View { @Environment(\.dismiss) var dismiss @Binding var selectedStationId: String - var playerMode: PlayerMode - var streamingPlayer: StreamingStationPlayer @State private var stations: [StationInfo] = [] @State private var isLoading = true @State private var errorMessage: String? @@ -463,16 +389,10 @@ struct StationPickerView: View { action: { Task { do { + // Use playolaID for playola stations let stationId = station.playolaID ?? station.id selectedStationId = stationId - switch playerMode { - case .streaming: - streamingPlayer.stop() - try await streamingPlayer.play(stationId: stationId) - case .download: - await PlayolaStationPlayer.shared.stop() - try await PlayolaStationPlayer.shared.play(stationId: stationId) - } + try await PlayolaStationPlayer.shared.play(stationId: stationId) } catch { print("Failed to start playback: \(error)") } @@ -481,6 +401,7 @@ struct StationPickerView: View { }, label: { HStack { + // Show image if available if let imageURL = station.imageURL, let url = URL(string: imageURL) { AsyncImage(url: url) { image in image @@ -533,10 +454,12 @@ struct StationPickerView: View { let response = try JSONDecoder().decode(StationListsResponse.self, from: data) + // Get stations from in_development_list and artist_list var allStations: [StationInfo] = [] for list in response.stationLists { if list.id == "in_development_list" || list.id == "artist_list" { + // Filter to only include playola type stations let playolaStations = list.stations.filter { $0.type == "playola" } allStations.append(contentsOf: playolaStations) } @@ -555,6 +478,42 @@ struct StationPickerView: View { } } +func isLoading(_ state: PlayolaStationPlayer.State) -> Bool { + if case .loading = state { + return true + } + return false +} + +func buttonColor(for state: PlayolaStationPlayer.State) -> Color { + switch state { + case .loading: + return Color.orange + case .playing: + return Color.red + case .idle: + return Color.green + } +} + +func buttonIcon(for state: PlayolaStationPlayer.State) -> String { + switch state { + case .loading: + return "stop.fill" + case .playing: + return "stop.fill" + case .idle: + return "play.fill" + } +} + +func shouldOffsetIcon(for state: PlayolaStationPlayer.State) -> Bool { + if case .idle = state { + return true + } + return false +} + // Custom button style for offset buttons struct OffsetButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { diff --git a/STREAMING_APPROACH.md b/STREAMING_APPROACH.md new file mode 100644 index 0000000..52ad3a5 --- /dev/null +++ b/STREAMING_APPROACH.md @@ -0,0 +1,106 @@ +# Streaming Player Approach (0.15.0) + +This documents the AVPlayer-based streaming approach that was introduced in 0.15.0 and subsequently reverted. The code for this approach lives in the `0.15.0` git tag. + +## Overview + +The streaming approach replaces the download-first model with AVPlayer's native HTTP streaming. Instead of downloading an entire audio file before playback begins, it starts playback as soon as AVPlayer buffers enough data — typically under 1 second. + +## Architecture + +### Key Classes + +#### StreamingStationPlayer (Orchestrator) +`Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift` + +- Top-level controller for streaming a Playola station +- Fetches the schedule via `ScheduleService.getSchedule()` +- Manages a dictionary of `StreamingSpinPlayer` instances keyed by spin ID +- Polls the schedule every 20 seconds, loading spins within a 10-minute lookahead window +- Handles audio interruptions (headphone disconnect, phone calls) on iOS/tvOS +- Publishes state: `.idle`, `.loading`, `.playing(Spin)` + +#### StreamingSpinPlayer (Per-Spin Playback) +`Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift` + +- Manages playback of a single spin using AVPlayer +- State machine: `available → loading → loaded → playing → finished` +- Two playback modes: + - `playNow(from offsetSeconds:)` — for the currently-airing spin, seeks to the right position and plays immediately + - `schedulePlay(at date:)` — for upcoming spins, sets a timer to begin playback at the exact airtime +- Runs a fade timer at ~33ms intervals to apply volume automation + +#### AVPlayerProviding (Protocol) +`Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift` + +- Protocol wrapping AVPlayer for testability +- `AVPlayerWrapper` implementation creates an AVPlayer with `automaticallyWaitsToMinimizeStalling = true` +- Wraps AVPlayer's callback-based APIs with async/await using `withCheckedThrowingContinuation` + +#### FadeScheduleBuilder (Volume Automation) +`Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift` + +- Generates discrete volume interpolation steps from a spin's fade array +- Each fade is expanded into 48 steps over 1500ms (linear interpolation) +- Produces a `[FadeStep]` array sorted by time +- Helper methods: `volumeAtMS(_:in:startingVolume:)` for initial volume calculation, `firstUnprocessedIndex(in:afterMS:)` for resuming mid-fade + +#### ScheduleService +`Sources/PlayolaPlayer/Player/ScheduleService.swift` + +- Fetches and parses station schedules from the Playola API (`/v1/stations/{id}/schedule`) +- Shared between streaming and download players + +## How It Works + +### Playback Flow + +1. `play(stationId:)` is called on `StreamingStationPlayer` +2. Schedule is fetched; the currently-airing spin is identified +3. A `StreamingSpinPlayer` is created for that spin +4. `player.load(spin)` creates an AVPlayerItem from the spin's download URL and waits for `.readyToPlay` +5. The fade schedule is pre-built during loading +6. Based on timing: + - **Currently playing**: `playNow(from: elapsedSeconds)` seeks to the right offset and starts immediately + - **Future spin**: `schedulePlay(at: airtime)` defers playback via timer +7. A polling loop runs every 20 seconds, loading upcoming spins within a 10-minute window + +### Fade/Volume Handling + +- Fades are pre-calculated into discrete steps during the load phase +- A 33ms timer continuously reads the current playback position and applies the appropriate volume +- When starting mid-spin, `volumeAtMS()` determines the correct initial volume +- Each spin manages its own fade schedule independently (no true crossfading between spins) + +### Spin Lifecycle + +- Spin loads → buffers → plays → cleanup timer fires at `endtime + 1s` → removed from player dictionary + +## Comparison: Streaming vs Download + +| Aspect | Streaming (0.15.0) | Download (0.14.0) | +|--------|-------------------|-------------------| +| Startup latency | ~1 second | Full file download time | +| Audio engine | AVPlayer | AVAudioEngine | +| State feedback | `.loading` (no progress) | `.loading(Float)` with download % | +| File storage | Streaming buffer only | Full file cached to disk | +| Network handling | AVPlayer manages buffering/reconnects | Manual download with retry | +| Fade application | Timer-based volume on AVPlayer | Integrated in audio engine mixing | +| Spin pre-loading | Parallel within 10-min window | Sequential | + +## Why It Was Reverted + +The streaming approach was a port that introduced issues. The 0.14.0 download-based approach remains the stable working version. + +## Retrieving the Code + +```bash +# View the streaming code +git show 0.15.0:Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift + +# Check out the full 0.15.0 state +git checkout 0.15.0 + +# Or diff to see what was added +git diff 0.14.0 0.15.0 +``` diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index a3cabab..1362e0d 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -54,7 +54,6 @@ public class ListeningSessionReporter { var currentSessionStationId: String? var disposeBag = Set() weak var stationPlayer: PlayolaStationPlayer? - private let stationIdGetter: () -> String? var currentListeningSessionID: String? private let errorReporter = PlayolaErrorReporter.shared private let authProvider: PlayolaAuthenticationProvider? @@ -74,29 +73,8 @@ public class ListeningSessionReporter { self.authProvider = authProvider self.urlSession = urlSession self.baseURL = baseURL - self.stationIdGetter = { [weak stationPlayer] in stationPlayer?.stationId } - subscribeToStationId(stationPlayer.$stationId.eraseToAnyPublisher()) - } - - init( - stationIdPublisher: AnyPublisher, - stationIdGetter: @escaping () -> String?, - authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = URLSession.shared, - baseURL: URL = URL(string: "https://admin-api.playola.fm")! - ) { - self.stationPlayer = nil - self.authProvider = authProvider - self.urlSession = urlSession - self.baseURL = baseURL - self.stationIdGetter = stationIdGetter - - subscribeToStationId(stationIdPublisher) - } - - private func subscribeToStationId(_ publisher: AnyPublisher) { - publisher.sink { [weak self] stationId in + stationPlayer.$stationId.sink { [weak self] stationId in guard let self else { return } if let stationId { Task { @@ -119,6 +97,7 @@ public class ListeningSessionReporter { try await self.endListeningSession() self.stopPeriodicNotifications() } catch { + // Just log the error but don't fail critically since this is cleanup Task { await self.errorReporter.reportError( error, @@ -216,7 +195,7 @@ public class ListeningSessionReporter { withTimeInterval: 10.0, repeats: true, block: { [weak self] _ in guard let self else { return } - guard let stationId = self.stationIdGetter() else { + guard let stationId = self.stationPlayer?.stationId else { let error = ListeningSessionError.invalidResponse( "Missing stationId in periodic notification") Task { @@ -324,6 +303,7 @@ public class ListeningSessionReporter { return request } + // TODO: Find a better way of doing this. Protocols + ObservableObject has issues. #if DEBUG internal init( authProvider: PlayolaAuthenticationProvider? = nil, @@ -331,7 +311,6 @@ public class ListeningSessionReporter { baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = nil - self.stationIdGetter = { nil } self.authProvider = authProvider self.urlSession = urlSession self.baseURL = baseURL diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 5461481..437ec14 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -59,7 +59,7 @@ public enum StationPlayerError: Error, LocalizedError { /// ``` @MainActor final public class PlayolaStationPlayer: ObservableObject { - var baseUrl = URL(string: "https://admin-api.playola.fm")! + var baseUrl = URL(string: "https://admin-api.playola.fm/v1")! @Published public var stationId: String? private var interruptedStationId: String? var currentSchedule: Schedule? diff --git a/Sources/PlayolaPlayer/Player/ScheduleService.swift b/Sources/PlayolaPlayer/Player/ScheduleService.swift deleted file mode 100644 index a0a941b..0000000 --- a/Sources/PlayolaPlayer/Player/ScheduleService.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation -import PlayolaCore -import os.log - -enum ScheduleService { - private static let logger = OSLog( - subsystem: "PlayolaPlayer", - category: "ScheduleService") - - static func getSchedule( - stationId: String, - baseUrl: URL, - errorReporter: PlayolaErrorReporter = .shared - ) async throws -> Schedule { - let url = baseUrl.appending(path: "/v1/stations/\(stationId)/schedule") - .appending(queryItems: [ - URLQueryItem(name: "includeRelatedTexts", value: "true"), - URLQueryItem(name: "lockedIn", value: "true"), - ]) - - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - let error = StationPlayerError.networkError("Invalid response type") - Task { - await errorReporter.reportError( - error, - context: "Non-HTTP response received from schedule endpoint: \(url.absoluteString)", - level: .error) - } - throw error - } - - guard (200...299).contains(httpResponse.statusCode) else { - let responseText = String(data: data, encoding: .utf8) ?? "Unable to decode response" - let error = StationPlayerError.networkError("HTTP error: \(httpResponse.statusCode)") - - Task { - if httpResponse.statusCode == 404 { - await errorReporter.reportError( - error, - context: "Station not found: \(stationId) | Response: \(responseText.prefix(100))", - level: .error) - } else { - await errorReporter.reportError( - error, - context: - "HTTP \(httpResponse.statusCode) error getting schedule for station: \(stationId) | " - + "Response: \(responseText.prefix(100))", - level: .error) - } - } - throw error - } - - return try decodeSchedule(from: data, stationId: stationId, errorReporter: errorReporter) - } catch let error as StationPlayerError { - throw error - } catch { - Task { - await errorReporter.reportError( - error, context: "Failed to fetch schedule for station: \(stationId)", level: .error) - } - throw error - } - } - - private static func decodeSchedule( - from data: Data, stationId: String, errorReporter: PlayolaErrorReporter - ) throws -> Schedule { - let decoder = JSONDecoderWithIsoFull() - - do { - let spins = try decoder.decode([Spin].self, from: data) - guard !spins.isEmpty else { - let error = StationPlayerError.scheduleError( - "No spins returned in schedule for station ID: \(stationId)") - Task { - await errorReporter.reportError(error, level: .error) - } - throw error - } - return Schedule(stationId: spins[0].stationId, spins: spins) - } catch let decodingError as DecodingError { - let context: String - switch decodingError { - case .dataCorrupted(let reportedContext): - context = "Corrupted data: \(reportedContext.debugDescription)" - case .keyNotFound(let key, _): - context = "Missing key: \(key)" - case .typeMismatch(let type, _): - context = "Type mismatch for: \(type)" - default: - context = "Unknown decoding error" - } - - Task { - await errorReporter.reportError( - decodingError, context: "Failed to decode schedule: \(context)", level: .error) - } - - throw StationPlayerError.scheduleError("Invalid schedule data: \(context)") - } - } -} diff --git a/Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift b/Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift deleted file mode 100644 index e78e464..0000000 --- a/Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift +++ /dev/null @@ -1,110 +0,0 @@ -import AVFoundation -import Foundation - -/// Protocol wrapping AVPlayer for testability. -/// -/// Follows the same pattern as URLSessionProtocol in the codebase. -/// In production, AVPlayer conforms via a wrapper. In tests, MockAVPlayer conforms. -@MainActor -public protocol AVPlayerProviding: AnyObject { - var volume: Float { get set } - var currentTimeSeconds: Double { get } - func play() - func pause() - func seek(to time: CMTime) async -> Bool - - /// Loads a URL and waits until the player is ready to play. - /// Throws if the item fails to load. - func loadURL(_ url: URL) async throws - - /// Tears down the current item. - func clearItem() -} - -/// Production AVPlayer wrapper that handles AVPlayerItem KVO internally. -@MainActor -public class AVPlayerWrapper: AVPlayerProviding { - private var player: AVPlayer? - private var statusObservation: NSKeyValueObservation? - - public var volume: Float { - get { player?.volume ?? 0 } - set { player?.volume = newValue } - } - - public var currentTimeSeconds: Double { - player?.currentTime().seconds ?? 0 - } - - public func play() { - player?.play() - } - - public func pause() { - player?.pause() - } - - public func seek(to time: CMTime) async -> Bool { - guard let player else { return false } - return await withCheckedContinuation { continuation in - player.seek(to: time) { finished in - continuation.resume(returning: finished) - } - } - } - - public func loadURL(_ url: URL) async throws { - let item = AVPlayerItem(url: url) - let newPlayer = AVPlayer(playerItem: item) - newPlayer.automaticallyWaitsToMinimizeStalling = true - self.player = newPlayer - - try await waitForReady(item: item) - } - - private func waitForReady(item: AVPlayerItem) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.statusObservation = item.observe(\.status, options: [.new]) { - [weak self] observedItem, _ in - guard let self else { return } - - Task { @MainActor in - switch observedItem.status { - case .readyToPlay: - self.statusObservation?.invalidate() - self.statusObservation = nil - continuation.resume() - case .failed: - self.statusObservation?.invalidate() - self.statusObservation = nil - let error = - observedItem.error - ?? StationPlayerError.playbackError("AVPlayerItem failed to load") - continuation.resume(throwing: error) - case .unknown: - // Not yet determined — keep observing. - return - @unknown default: - self.statusObservation?.invalidate() - self.statusObservation = nil - continuation.resume( - throwing: StationPlayerError.playbackError( - "AVPlayerItem entered unexpected status")) - } - } - } - } - } - - public func clearItem() { - statusObservation?.invalidate() - statusObservation = nil - player?.pause() - player?.replaceCurrentItem(with: nil) - player = nil - } - - deinit { - statusObservation?.invalidate() - } -} diff --git a/Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift b/Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift deleted file mode 100644 index 013b822..0000000 --- a/Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import PlayolaCore - -public struct FadeStep: Sendable, Equatable { - public let timeMS: Int - public let volume: Float - - public init(timeMS: Int, volume: Float) { - self.timeMS = timeMS - self.volume = volume - } -} - -public enum FadeScheduleBuilder { - public static let fadeDurationMS: Double = 1500 - public static let fadeSteps: Int = 48 - - /// Expands a spin's fades into discrete interpolation steps. - /// - /// Each fade in the spin is expanded into `fadeSteps` evenly-spaced steps - /// over `fadeDurationMS` milliseconds, linearly interpolating from the previous - /// volume level to the fade's target volume. - /// - /// Example: A fade at 30000ms from 1.0 to 0.0 with 48 steps over 1500ms produces - /// 49 entries (steps 0...48) starting at timeMS=30000 and ending at timeMS=31500. - public static func buildFadeSchedule( - for spin: Spin, - fadeDurationMS: Double = FadeScheduleBuilder.fadeDurationMS, - fadeSteps: Int = FadeScheduleBuilder.fadeSteps - ) -> [FadeStep] { - let fades = spin.fades - guard !fades.isEmpty else { return [] } - - var schedule: [FadeStep] = [] - let sortedFades = fades.sorted { $0.atMS < $1.atMS } - var fromVolume = spin.startingVolume - let stepDurationMS = fadeDurationMS / Double(fadeSteps) - - var lastTimeMS = Int.min - - for fade in sortedFades { - let toVolume = fade.toVolume - let fadeStartMS = fade.atMS - - for step in 0...fadeSteps { - let progress = Float(step) / Float(fadeSteps) - let volume = fromVolume + (toVolume - fromVolume) * progress - let timeMS = fadeStartMS + Int(Double(step) * stepDurationMS) - - // Ensure monotonic time ordering — skip steps that overlap with previous fade - if timeMS <= lastTimeMS { - continue - } - schedule.append(FadeStep(timeMS: timeMS, volume: volume)) - lastTimeMS = timeMS - } - - fromVolume = toVolume - } - - return schedule - } - - /// Returns the volume at a given millisecond position based on a fade schedule. - /// - /// Walks backward through the schedule to find the last step at or before `ms`. - /// If `ms` is before all steps, returns `startingVolume`. - public static func volumeAtMS( - _ ms: Int, - in schedule: [FadeStep], - startingVolume: Float - ) -> Float { - var result = startingVolume - - for step in schedule { - if step.timeMS <= ms { - result = step.volume - } else { - break - } - } - - return result - } - - /// Returns the index of the first fade step that hasn't been applied yet - /// (i.e., the first step whose timeMS > currentMS). - public static func firstUnprocessedIndex( - in schedule: [FadeStep], - afterMS currentMS: Int - ) -> Int { - for (index, step) in schedule.enumerated() where step.timeMS > currentMS { - return index - } - return schedule.count - } -} diff --git a/Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift b/Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift deleted file mode 100644 index ff6acad..0000000 --- a/Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift +++ /dev/null @@ -1,339 +0,0 @@ -import AVFoundation -import Combine -import Foundation -import PlayolaCore -import os.log - -/// Delegate for StreamingSpinPlayer events. -@MainActor -protocol StreamingSpinPlayerDelegate: AnyObject { - func streamingPlayer(_ player: StreamingSpinPlayer, startedPlaying spin: Spin) - func streamingPlayer( - _ player: StreamingSpinPlayer, didChangeState state: StreamingSpinPlayer.State) - func streamingPlayer(_ player: StreamingSpinPlayer, didEncounterError error: Error) -} - -/// Handles playback of a single spin using AVPlayer for streaming. -/// -/// ``` -/// State Machine: -/// ┌───────────┐ load() ┌──────────┐ readyToPlay ┌────────┐ -/// │ available ├───────────►│ loading ├──────────────►│ loaded │ -/// └─────▲─────┘ └────┬─────┘ └───┬────┘ -/// │ │ .failed │ play/schedulePlay -/// │ ▼ ▼ -/// │ ┌─────────┐ ┌──────────┐ -/// │ │ error │ │ playing │ -/// │ └─────────┘ └────┬─────┘ -/// │ │ endtime+1s -/// │ clear() ▼ -/// └────────────────────────────────────────── finished -/// ``` -@MainActor -public class StreamingSpinPlayer { - public enum State: Equatable { - case available - case loading - case loaded - case playing - case finished - case error - } - - private static let logger = OSLog( - subsystem: "PlayolaPlayer", - category: "StreamingSpinPlayer") - - private static let fadeTimerInterval: TimeInterval = 1.0 / 30.0 // ~33ms - private static let cleanupBufferTime: TimeInterval = 1.0 - - // MARK: - Public Properties - let id = UUID() - var spin: Spin? - weak var delegate: StreamingSpinPlayerDelegate? - - public var state: State = .available { - didSet { - if oldValue != state { - delegate?.streamingPlayer(self, didChangeState: state) - } - } - } - - // MARK: - Audio - private var avPlayer: AVPlayerProviding? - - // MARK: - Fade Automation - private(set) var fadeSchedule: [FadeStep] = [] - private(set) var nextFadeIndex: Int = 0 - private var fadeTimer: Timer? - - // MARK: - Scheduling Timers - private var playTimer: Timer? - private var clearTimer: Timer? - - // MARK: - Dependencies - private let errorReporter = PlayolaErrorReporter.shared - private let playerFactory: () -> AVPlayerProviding - - // MARK: - Playback State - private var playbackStartOffsetMS: Int = 0 - - // MARK: - Lifecycle - - init( - delegate: StreamingSpinPlayerDelegate? = nil, - playerFactory: (() -> AVPlayerProviding)? = nil - ) { - self.delegate = delegate - self.playerFactory = playerFactory ?? { AVPlayerWrapper() } - } - - // MARK: - Loading - - /// Loads a spin for streaming playback. - /// - /// Creates an AVPlayer with the spin's download URL and waits for the player - /// to report readyToPlay status. - func load(_ spin: Spin) async -> Result { - os_log( - "Loading spin: %@", log: StreamingSpinPlayer.logger, type: .info, spin.id) - - guard let audioFileUrl = spin.audioBlock.downloadUrl else { - let error = StationPlayerError.playbackError("Invalid audio file URL in spin") - Task { - await errorReporter.reportError(error, context: "Missing download URL", level: .error) - } - self.state = .available - return .failure(error) - } - - self.state = .loading - self.spin = spin - - // Build fade schedule while player buffers - self.fadeSchedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - - let player = playerFactory() - self.avPlayer = player - - do { - try await player.loadURL(audioFileUrl) - os_log( - "Player ready for spin: %@", - log: StreamingSpinPlayer.logger, type: .info, spin.id) - self.state = .loaded - return .success(()) - } catch { - os_log( - "Player failed for spin: %@ - %@", - log: StreamingSpinPlayer.logger, type: .error, - spin.id, error.localizedDescription) - Task { - await self.errorReporter.reportError( - error, context: "AVPlayerItem failed to load", level: .error) - } - self.state = .error - return .failure(error) - } - } - - // MARK: - Playback - - /// Plays the loaded spin immediately from the specified position (in seconds). - func playNow(from offsetSeconds: Double) { - guard let avPlayer, state == .loaded || state == .playing else { - os_log( - "Cannot playNow - not loaded (state: %@)", - log: StreamingSpinPlayer.logger, type: .info, - String(describing: state)) - return - } - - guard let spin else { return } - - let offsetMS = Int(offsetSeconds * 1000) - - // If past endOfMessage, skip - if offsetMS >= spin.audioBlock.endOfMessageMS { - os_log( - "Offset %d past endOfMessage %d - skipping", - log: StreamingSpinPlayer.logger, type: .info, - offsetMS, spin.audioBlock.endOfMessageMS) - clear() - return - } - - self.playbackStartOffsetMS = offsetMS - - // Set volume from fade schedule at current position - let volume = FadeScheduleBuilder.volumeAtMS( - offsetMS, in: fadeSchedule, startingVolume: spin.startingVolume) - avPlayer.volume = volume - - // Seek to position and play - let seekTime = CMTime(seconds: offsetSeconds, preferredTimescale: 600) - Task { [weak self] in - guard let self else { return } - _ = await avPlayer.seek(to: seekTime) - - // Guard against clear() called during the await - guard self.state == .loaded || self.state == .playing else { return } - - avPlayer.play() - self.state = .playing - delegate?.streamingPlayer(self, startedPlaying: spin) - - // Start fade automation from the right index - nextFadeIndex = FadeScheduleBuilder.firstUnprocessedIndex( - in: fadeSchedule, afterMS: offsetMS) - startFadeTimer() - setupClearTimer() - } - } - - /// Schedules playback to start at a specific date (for future spins). - func schedulePlay(at scheduledDate: Date) { - guard state == .loaded else { - os_log( - "Cannot schedulePlay - not loaded (state: %@)", - log: StreamingSpinPlayer.logger, type: .info, - String(describing: state)) - return - } - - guard let spin else { return } - - os_log( - "Scheduling play at %@ for spin: %@", - log: StreamingSpinPlayer.logger, type: .info, - ISO8601DateFormatter().string(from: scheduledDate), spin.id) - - // Set initial volume - avPlayer?.volume = spin.startingVolume - - self.playbackStartOffsetMS = 0 - - self.playTimer = Timer( - fire: scheduledDate, - interval: 0, - repeats: false - ) { [weak self] timer in - timer.invalidate() - guard let self else { return } - - Task { @MainActor in - guard self.playTimer === timer, let avPlayer = self.avPlayer, let spin = self.spin else { - return - } - - avPlayer.play() - self.state = .playing - self.delegate?.streamingPlayer(self, startedPlaying: spin) - self.nextFadeIndex = 0 - self.startFadeTimer() - self.setupClearTimer() - } - } - - RunLoop.main.add(self.playTimer!, forMode: .default) - } - - // MARK: - Fade Timer - - private func startFadeTimer() { - guard !fadeSchedule.isEmpty, nextFadeIndex < fadeSchedule.count else { return } - - fadeTimer = Timer.scheduledTimer( - withTimeInterval: StreamingSpinPlayer.fadeTimerInterval, - repeats: true - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.processFades() - } - } - } - - private func processFades() { - guard state == .playing, let avPlayer else { - stopFadeTimer() - return - } - - let currentTimeMS = Int(avPlayer.currentTimeSeconds * 1000) - - while nextFadeIndex < fadeSchedule.count { - let step = fadeSchedule[nextFadeIndex] - if step.timeMS <= currentTimeMS { - avPlayer.volume = step.volume - nextFadeIndex += 1 - } else { - break - } - } - - if nextFadeIndex >= fadeSchedule.count { - stopFadeTimer() - } - } - - private func stopFadeTimer() { - fadeTimer?.invalidate() - fadeTimer = nil - } - - // MARK: - Clear Timer - - private func setupClearTimer() { - guard let spin else { return } - - clearTimer = Timer( - fire: spin.endtime.addingTimeInterval(StreamingSpinPlayer.cleanupBufferTime), - interval: 0, - repeats: false - ) { [weak self] timer in - timer.invalidate() - guard let self else { return } - Task { @MainActor in - guard self.clearTimer === timer else { return } - self.clearTimer = nil - self.state = .finished - self.clear() - } - } - - RunLoop.main.add(clearTimer!, forMode: .default) - } - - // MARK: - Cleanup - - func stop() { - os_log( - "StreamingSpinPlayer.stop() - ID: %@, spin: %@", - log: StreamingSpinPlayer.logger, type: .info, - id.uuidString, spin?.id ?? "nil") - clear() - } - - func clear() { - avPlayer?.pause() - avPlayer?.clearItem() - avPlayer = nil - - stopFadeTimer() - - playTimer?.invalidate() - playTimer = nil - - clearTimer?.invalidate() - clearTimer = nil - - fadeSchedule = [] - nextFadeIndex = 0 - playbackStartOffsetMS = 0 - - spin = nil - state = .available - } -} diff --git a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift b/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift deleted file mode 100644 index 30e35fb..0000000 --- a/Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift +++ /dev/null @@ -1,391 +0,0 @@ -import AVFoundation -import Combine -import Foundation -import PlayolaCore -import os.log - -/// A streaming audio player for Playola stations using AVPlayer. -/// -/// Unlike `PlayolaStationPlayer` which downloads entire files before playback, -/// `StreamingStationPlayer` streams audio via AVPlayer for faster startup. -/// -/// ``` -/// Lifecycle: -/// play(stationId:) → fetch schedule → load current spin → start playback -/// → poll every 20s for upcoming spins -/// stop() → cancel tasks → stop all players → nil stationId -/// -/// Spin Management: -/// spinPlayers: [String: StreamingSpinPlayer] — keyed by spin ID -/// - Current spin: playNow(from: offset) -/// - Future spins: schedulePlay(at: airtime) -/// - Finished spins: cleared automatically via clearTimer -/// ``` -@MainActor -final public class StreamingStationPlayer: ObservableObject { - public enum State: Sendable { - case loading - case playing(Spin) - case idle - } - - private static let logger = OSLog( - subsystem: "PlayolaPlayer", - category: "StreamingStationPlayer") - - private static let schedulePollingInterval: TimeInterval = 20.0 - private static let scheduleWindow: TimeInterval = 600 // 10 minutes - - // MARK: - Public Properties - - @Published public var stationId: String? - @Published public var state: StreamingStationPlayer.State = .idle - - public var isPlaying: Bool { - switch state { - case .playing: return true - default: return false - } - } - - // MARK: - Dependencies - - var baseUrl = URL(string: "https://admin-api.playola.fm")! - var listeningSessionReporter: ListeningSessionReporter? - private let errorReporter = PlayolaErrorReporter.shared - private var authProvider: PlayolaAuthenticationProvider? - private let playerFactory: () -> AVPlayerProviding - private let audioSessionManager = AudioSessionManager() - // internal for testability - var scheduleFetcher: (String, URL) async throws -> Schedule = { stationId, baseUrl in - try await ScheduleService.getSchedule(stationId: stationId, baseUrl: baseUrl) - } - - // MARK: - Internal State - - var spinPlayers: [String: StreamingSpinPlayer] = [:] - private var currentSchedule: Schedule? - private var scheduleOffset: TimeInterval? - private var schedulingTask: Task? - - // Audio interruption state (internal for testability) - var wasPlayingBeforeInterruption = false - var interruptedStationId: String? - - // MARK: - Lifecycle - - public init(playerFactory: (() -> AVPlayerProviding)? = nil) { - self.playerFactory = playerFactory ?? { AVPlayerWrapper() } - - #if os(iOS) || os(tvOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioSessionInterruption(_:)), - name: AVAudioSession.interruptionNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioRouteChange(_:)), - name: AVAudioSession.routeChangeNotification, - object: nil - ) - #endif - } - - deinit { - #if os(iOS) || os(tvOS) - NotificationCenter.default.removeObserver(self) - #endif - } - - /// Configure with authentication provider. - public func configure( - authProvider: PlayolaAuthenticationProvider, - baseURL: URL = URL(string: "https://admin-api.playola.fm")! - ) { - self.authProvider = authProvider - self.baseUrl = baseURL - self.listeningSessionReporter = ListeningSessionReporter( - stationIdPublisher: $stationId.eraseToAnyPublisher(), - stationIdGetter: { [weak self] in self?.stationId }, - authProvider: authProvider, - baseURL: baseURL - ) - } - - // MARK: - Playback - - /// Begins streaming playback of the specified station. - public func play(stationId: String, atDate: Date? = nil) async throws { - wasPlayingBeforeInterruption = false - interruptedStationId = nil - - schedulingTask?.cancel() - - for (_, player) in spinPlayers { - player.stop() - } - spinPlayers.removeAll() - self.scheduleOffset = atDate?.timeIntervalSinceNow - self.stationId = stationId - self.state = .loading - - try await audioSessionManager.activate() - - let schedule = try await scheduleFetcher(stationId, baseUrl) - self.currentSchedule = schedule - - let currentSpins = schedule.current(offsetTimeInterval: scheduleOffset) - guard let firstSpin = currentSpins.first else { - let error = StationPlayerError.scheduleError("No available spins to play") - Task { - await errorReporter.reportError( - error, - context: "Schedule for station \(stationId) contains no current spins | " - + "Total spins: \(schedule.spins.count)", - level: .error) - } - throw error - } - - try await loadAndPlaySpin(firstSpin) - - schedulingTask = Task { - await scheduleUpcomingSpins() - } - } - - /// Stops playback and releases resources. - public func stop() { - os_log("Stop called", log: StreamingStationPlayer.logger, type: .info) - - schedulingTask?.cancel() - schedulingTask = nil - - for (_, player) in spinPlayers { - player.stop() - } - spinPlayers.removeAll() - - self.stationId = nil - self.currentSchedule = nil - self.state = .idle - } - - // MARK: - Spin Loading - - private func loadAndPlaySpin(_ spin: Spin) async throws { - let player = getOrCreateSpinPlayer(for: spin) - - let result = await player.load(spin) - - // Guard against stop() called during the await - guard stationId != nil else { - player.clear() - spinPlayers.removeValue(forKey: spin.id) - return - } - - switch result { - case .success: - let timing = spin.playbackTiming - switch timing { - case .playing: - let elapsedSeconds = Date().timeIntervalSince(spin.airtime) - player.playNow(from: elapsedSeconds) - self.state = .playing(spin) - case .future: - player.schedulePlay(at: spin.airtime) - case .tooLateToStart, .past: - player.clear() - spinPlayers.removeValue(forKey: spin.id) - } - case .failure(let error): - spinPlayers.removeValue(forKey: spin.id) - throw error - } - } - - private func getOrCreateSpinPlayer(for spin: Spin) -> StreamingSpinPlayer { - if let existing = spinPlayers[spin.id] { - return existing - } - let player = StreamingSpinPlayer( - delegate: self, - playerFactory: playerFactory - ) - spinPlayers[spin.id] = player - return player - } - - // MARK: - Schedule Polling - - private func scheduleUpcomingSpins() async { - guard let stationId else { return } - - while !Task.isCancelled { - do { - try await performScheduleUpdate(stationId: stationId) - } catch is CancellationError { - return - } catch { - os_log( - "Schedule update failed: %@", - log: StreamingStationPlayer.logger, type: .error, - error.localizedDescription) - } - - do { - try await Task.sleep(for: .seconds(StreamingStationPlayer.schedulePollingInterval)) - } catch { - return - } - } - } - - private func performScheduleUpdate(stationId: String) async throws { - let updatedSchedule = try await scheduleFetcher(stationId, baseUrl) - - let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { - $0.airtime < .now + StreamingStationPlayer.scheduleWindow - } - - for spin in spinsToLoad { - try Task.checkCancellation() - if spinPlayers[spin.id] == nil { - do { - try await loadAndPlaySpin(spin) - } catch { - os_log( - "Failed to load spin %@: %@", - log: StreamingStationPlayer.logger, type: .error, - spin.id, error.localizedDescription) - } - } - } - } - - // MARK: - Audio Interruptions - - #if os(iOS) || os(tvOS) - @objc public func handleAudioRouteChange(_ notification: Notification) { - guard let userInfo = notification.userInfo, - let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, - let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) - else { return } - - switch reason { - case .oldDeviceUnavailable: - guard - let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] - as? AVAudioSessionRouteDescription - else { return } - - let wasUsingHeadphones = previousRoute.outputs.contains { - [.headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE].contains($0.portType) - } - - if wasUsingHeadphones && isPlaying { - stop() - } - default: - break - } - } - - @objc public func handleAudioSessionInterruption(_ notification: Notification) { - guard let userInfo = notification.userInfo, - let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue) - else { return } - - switch type { - case .began: - handleInterruptionBegan() - - case .ended: - guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { - return - } - let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - handleInterruptionEnded(shouldResume: options.contains(.shouldResume)) - - @unknown default: - break - } - } - #endif - - // internal for testability - func handleInterruptionBegan() { - wasPlayingBeforeInterruption = isPlaying - interruptedStationId = stationId - schedulingTask?.cancel() - schedulingTask = nil - } - - // internal for testability - func handleInterruptionEnded(shouldResume: Bool) { - if shouldResume && wasPlayingBeforeInterruption { - resumeAfterInterruption() - } - } - - // internal for testability - func resumeAfterInterruption() { - guard let stationToResume = interruptedStationId else { return } - - Task { @MainActor in - do { - try await self.audioSessionManager.activate() - try await self.play(stationId: stationToResume) - } catch { - os_log( - "Failed to resume after interruption: %@", - log: StreamingStationPlayer.logger, type: .error, - error.localizedDescription) - await errorReporter.reportError( - error, context: "Failed to resume playback after interruption", level: .error) - } - - self.interruptedStationId = nil - self.wasPlayingBeforeInterruption = false - } - } -} - -// MARK: - StreamingSpinPlayerDelegate - -extension StreamingStationPlayer: StreamingSpinPlayerDelegate { - func streamingPlayer(_ player: StreamingSpinPlayer, startedPlaying spin: Spin) { - os_log( - "Started playing: %@", - log: StreamingStationPlayer.logger, type: .info, spin.id) - self.state = .playing(spin) - } - - func streamingPlayer( - _ player: StreamingSpinPlayer, didChangeState state: StreamingSpinPlayer.State - ) { - if state == .finished { - if let spinId = player.spin?.id ?? spinPlayers.first(where: { $0.value === player })?.key { - spinPlayers.removeValue(forKey: spinId) - } - if spinPlayers.isEmpty { - self.state = .idle - } - } - } - - func streamingPlayer(_ player: StreamingSpinPlayer, didEncounterError error: Error) { - os_log( - "Spin player error: %@", - log: StreamingStationPlayer.logger, type: .error, - error.localizedDescription) - Task { - await errorReporter.reportError(error, context: "StreamingSpinPlayer error", level: .error) - } - } -} diff --git a/TODOS.md b/TODOS.md deleted file mode 100644 index 5be5ea1..0000000 --- a/TODOS.md +++ /dev/null @@ -1,15 +0,0 @@ -# TODOs - -## P3: Server-side audio normalization -Pre-normalize audio files to a target LUFS during upload/ingest on the backend. -Currently, the AVAudioEngine-based player applies client-side normalization via -`AVAudioUnitEQ.globalGain` (±24dB). The new streaming player and web player skip -normalization entirely. Server-side pre-normalization would solve loudness consistency -for all clients permanently. - -**Effort**: L (human) → M (CC) -**Depends on**: Backend audio pipeline access -**Context**: Added during streaming player implementation (2026-03-20). The streaming -player (AVPlayer-based) cannot do client-side normalization because AVPlayer doesn't -connect to AVAudioEngine. The web player also doesn't normalize. This is the clean -long-term solution. diff --git a/Tests/PlayolaPlayerTests/FadeScheduleBuilderTests.swift b/Tests/PlayolaPlayerTests/FadeScheduleBuilderTests.swift deleted file mode 100644 index 5b5d709..0000000 --- a/Tests/PlayolaPlayerTests/FadeScheduleBuilderTests.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Foundation -import Testing - -@testable import PlayolaCore -@testable import PlayolaPlayer - -struct FadeScheduleBuilderTests { - - // MARK: - buildFadeSchedule - - @Test("Empty fades array produces empty schedule") - func testBuildFadeScheduleNoFades() { - let spin = Spin.mockWith(fades: []) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - #expect(schedule.isEmpty) - } - - @Test("Single fade produces fadeSteps+1 entries") - func testBuildFadeScheduleSingleFade() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [Fade(atMS: 30000, toVolume: 0.0)] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - #expect(schedule.count == FadeScheduleBuilder.fadeSteps + 1) - } - - @Test("Single fade starts at fade atMS and ends at atMS + fadeDuration") - func testBuildFadeScheduleTiming() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [Fade(atMS: 30000, toVolume: 0.0)] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - - #expect(schedule.first?.timeMS == 30000) - #expect(schedule.last?.timeMS == 31500) - } - - @Test("Single fade interpolates from startingVolume to target") - func testBuildFadeScheduleInterpolation() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [Fade(atMS: 10000, toVolume: 0.0)] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - - // First step should be startingVolume - #expect(schedule.first!.volume == 1.0) - // Last step should be target volume - #expect(schedule.last!.volume == 0.0) - // Midpoint should be approximately 0.5 - let midIndex = FadeScheduleBuilder.fadeSteps / 2 - #expect(abs(schedule[midIndex].volume - 0.5) < 0.02) - } - - @Test("Multiple fades chain correctly") - func testBuildFadeScheduleMultipleFades() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [ - Fade(atMS: 10000, toVolume: 0.0), - Fade(atMS: 20000, toVolume: 1.0), - ] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - let stepsPerFade = FadeScheduleBuilder.fadeSteps + 1 - - #expect(schedule.count == stepsPerFade * 2) - - // First fade: 1.0 → 0.0 - #expect(schedule[0].volume == 1.0) - #expect(schedule[stepsPerFade - 1].volume == 0.0) - - // Second fade: 0.0 → 1.0 - #expect(schedule[stepsPerFade].volume == 0.0) - #expect(schedule[stepsPerFade * 2 - 1].volume == 1.0) - } - - @Test("Fades are sorted by atMS regardless of input order") - func testBuildFadeScheduleSortsInput() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [ - Fade(atMS: 20000, toVolume: 1.0), - Fade(atMS: 10000, toVolume: 0.0), - ] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - - // First fade should start at 10000 - #expect(schedule.first?.timeMS == 10000) - } - - @Test("Custom fade duration and steps are respected") - func testBuildFadeScheduleCustomParams() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [Fade(atMS: 5000, toVolume: 0.5)] - ) - let schedule = FadeScheduleBuilder.buildFadeSchedule( - for: spin, fadeDurationMS: 1000, fadeSteps: 10) - - #expect(schedule.count == 11) - #expect(schedule.first?.timeMS == 5000) - #expect(schedule.last?.timeMS == 6000) - } - - // MARK: - volumeAtMS - - @Test("volumeAtMS before any fade returns startingVolume") - func testVolumeAtMSBeforeFades() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 1.0), - FadeStep(timeMS: 11500, volume: 0.0), - ] - let volume = FadeScheduleBuilder.volumeAtMS(5000, in: schedule, startingVolume: 0.8) - #expect(volume == 0.8) - } - - @Test("volumeAtMS at exact fade time returns that fade's volume") - func testVolumeAtMSExactMatch() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 0.5), - FadeStep(timeMS: 11000, volume: 0.0), - ] - let volume = FadeScheduleBuilder.volumeAtMS(10000, in: schedule, startingVolume: 1.0) - #expect(volume == 0.5) - } - - @Test("volumeAtMS after all fades returns last fade's volume") - func testVolumeAtMSAfterAllFades() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 0.5), - FadeStep(timeMS: 11500, volume: 0.0), - ] - let volume = FadeScheduleBuilder.volumeAtMS(20000, in: schedule, startingVolume: 1.0) - #expect(volume == 0.0) - } - - @Test("volumeAtMS with empty schedule returns startingVolume") - func testVolumeAtMSEmptySchedule() { - let volume = FadeScheduleBuilder.volumeAtMS(5000, in: [], startingVolume: 0.7) - #expect(volume == 0.7) - } - - // MARK: - firstUnprocessedIndex - - @Test("firstUnprocessedIndex returns 0 when all fades are in the future") - func testFirstUnprocessedIndexAllFuture() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 0.5), - FadeStep(timeMS: 11000, volume: 0.0), - ] - let index = FadeScheduleBuilder.firstUnprocessedIndex(in: schedule, afterMS: 5000) - #expect(index == 0) - } - - @Test("firstUnprocessedIndex returns count when all fades are past") - func testFirstUnprocessedIndexAllPast() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 0.5), - FadeStep(timeMS: 11000, volume: 0.0), - ] - let index = FadeScheduleBuilder.firstUnprocessedIndex(in: schedule, afterMS: 20000) - #expect(index == schedule.count) - } - - @Test("firstUnprocessedIndex returns correct index mid-schedule") - func testFirstUnprocessedIndexMidSchedule() { - let schedule = [ - FadeStep(timeMS: 10000, volume: 0.8), - FadeStep(timeMS: 10500, volume: 0.5), - FadeStep(timeMS: 11000, volume: 0.2), - FadeStep(timeMS: 11500, volume: 0.0), - ] - let index = FadeScheduleBuilder.firstUnprocessedIndex(in: schedule, afterMS: 10500) - #expect(index == 2) - } - - // MARK: - Overlapping Fades - - @Test("Overlapping fades produce monotonic time ordering") - func testOverlappingFadesMonotonic() { - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [ - Fade(atMS: 10000, toVolume: 0.5), - Fade(atMS: 10500, toVolume: 0.0), // Starts before first fade ends (10000 + 1500) - ] - ) - - let schedule = FadeScheduleBuilder.buildFadeSchedule(for: spin) - - // Verify all times are strictly increasing - for i in 1.. schedule[i - 1].timeMS, - "Times must increase: [\(i)] \(schedule[i].timeMS) <= [\(i-1)] \(schedule[i-1].timeMS)" - ) - } - } -} diff --git a/Tests/PlayolaPlayerTests/StreamingSpinPlayerTests.swift b/Tests/PlayolaPlayerTests/StreamingSpinPlayerTests.swift deleted file mode 100644 index 1600fdb..0000000 --- a/Tests/PlayolaPlayerTests/StreamingSpinPlayerTests.swift +++ /dev/null @@ -1,311 +0,0 @@ -import AVFoundation -import Foundation -import Testing - -@testable import PlayolaCore -@testable import PlayolaPlayer - -// MARK: - Mock AVPlayer - -@MainActor -final class MockAVPlayer: AVPlayerProviding { - var volume: Float = 1.0 - var currentTimeSeconds: Double = 0.0 - - var playCallCount = 0 - var pauseCallCount = 0 - var seekTarget: CMTime? - var loadedURL: URL? - var clearItemCallCount = 0 - - var shouldFailLoad = false - var loadError: Error = StationPlayerError.playbackError("Mock load failure") - - func play() { - playCallCount += 1 - } - - func pause() { - pauseCallCount += 1 - } - - func seek(to time: CMTime) async -> Bool { - seekTarget = time - return true - } - - func loadURL(_ url: URL) async throws { - loadedURL = url - if shouldFailLoad { - throw loadError - } - } - - func clearItem() { - clearItemCallCount += 1 - } -} - -// MARK: - Mock Delegate - -@MainActor -final class MockStreamingSpinPlayerDelegate: StreamingSpinPlayerDelegate { - var startedPlayingSpins: [Spin] = [] - var stateChanges: [StreamingSpinPlayer.State] = [] - var errors: [Error] = [] - - func streamingPlayer(_ player: StreamingSpinPlayer, startedPlaying spin: Spin) { - startedPlayingSpins.append(spin) - } - - func streamingPlayer( - _ player: StreamingSpinPlayer, didChangeState state: StreamingSpinPlayer.State - ) { - stateChanges.append(state) - } - - func streamingPlayer(_ player: StreamingSpinPlayer, didEncounterError error: Error) { - errors.append(error) - } -} - -// MARK: - Tests - -@MainActor -struct StreamingSpinPlayerTests { - - private struct PlayerTestContext { - let player: StreamingSpinPlayer - let mock: MockAVPlayer - let delegate: MockStreamingSpinPlayerDelegate - } - - private func createPlayer(mockPlayer: MockAVPlayer? = nil) -> PlayerTestContext { - let mock = mockPlayer ?? MockAVPlayer() - let delegate = MockStreamingSpinPlayerDelegate() - let player = StreamingSpinPlayer( - delegate: delegate, - playerFactory: { mock } - ) - return PlayerTestContext(player: player, mock: mock, delegate: delegate) - } - - // MARK: - Initial State - - @Test("Initial state is available") - func testInitialState() { - let ctx = createPlayer() - #expect(ctx.player.state == .available) - #expect(ctx.player.spin == nil) - } - - // MARK: - Load - - @Test("Load with nil downloadUrl fails immediately") - func testLoadNilDownloadUrl() async { - let ctx = createPlayer() - let audioBlock = AudioBlock( - id: "test", title: "Test", artist: "Test", durationMS: 30000, - endOfMessageMS: 28000, beginningOfOutroMS: 25000, endOfIntroMS: 5000, - lengthOfOutroMS: 5000, downloadUrl: nil, s3Key: "key", s3BucketName: "bucket", - type: "song", createdAt: Date(), updatedAt: Date(), album: nil, popularity: nil, - youTubeId: nil, isrc: nil, spotifyId: nil, imageUrl: nil as URL? - ) - let spin = Spin.mockWith(audioBlock: audioBlock) - - let result = await ctx.player.load(spin) - - switch result { - case .success: - Issue.record("Expected failure for nil downloadUrl") - case .failure: - #expect(ctx.player.state == .available) - } - } - - @Test("Successful load transitions to loaded state") - func testLoadSuccess() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - let spin = Spin.mockWith() - - let result = await ctx.player.load(spin) - - switch result { - case .success: - #expect(ctx.player.state == .loaded) - #expect(ctx.player.spin?.id == spin.id) - #expect(mock.loadedURL != nil) - #expect(ctx.delegate.stateChanges.contains(.loading)) - #expect(ctx.delegate.stateChanges.contains(.loaded)) - case .failure(let error): - Issue.record("Expected success but got: \(error)") - } - } - - @Test("Failed load transitions to error state") - func testLoadFailure() async { - let mock = MockAVPlayer() - mock.shouldFailLoad = true - let ctx = createPlayer(mockPlayer: mock) - let spin = Spin.mockWith() - - let result = await ctx.player.load(spin) - - switch result { - case .success: - Issue.record("Expected failure") - case .failure: - #expect(ctx.player.state == .error) - #expect(ctx.delegate.stateChanges.contains(.loading)) - #expect(ctx.delegate.stateChanges.contains(.error)) - } - } - - @Test("Load builds fade schedule") - func testFadeScheduleBuiltDuringLoad() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith( - startingVolume: 1.0, - fades: [Fade(atMS: 10000, toVolume: 0.0)] - ) - - _ = await ctx.player.load(spin) - - #expect(!ctx.player.fadeSchedule.isEmpty) - #expect(ctx.player.fadeSchedule.count == FadeScheduleBuilder.fadeSteps + 1) - } - - // MARK: - Clear - - @Test("Clear resets all state") - func testClear() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith() - _ = await ctx.player.load(spin) - - ctx.player.clear() - - #expect(ctx.player.state == .available) - #expect(ctx.player.spin == nil) - #expect(mock.pauseCallCount == 1) - #expect(mock.clearItemCallCount == 1) - #expect(ctx.player.fadeSchedule.isEmpty) - } - - // MARK: - Stop - - @Test("Stop calls clear") - func testStop() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith() - _ = await ctx.player.load(spin) - - ctx.player.stop() - - #expect(ctx.player.state == .available) - #expect(ctx.player.spin == nil) - #expect(mock.pauseCallCount == 1) - } - - // MARK: - PlayNow - - @Test("PlayNow with offset past endOfMessage clears player") - func testPlayNowPastEndOfMessage() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith( - audioBlock: AudioBlock.mockWith(endOfMessageMS: 30000) - ) - _ = await ctx.player.load(spin) - - ctx.player.playNow(from: 31.0) // 31 seconds > 30s endOfMessage - - #expect(ctx.player.state == .available) - #expect(ctx.player.spin == nil) - } - - @Test("PlayNow when not loaded is a no-op") - func testPlayNowNotLoaded() { - let ctx = createPlayer() - ctx.player.spin = Spin.mockWith() - - ctx.player.playNow(from: 5.0) - - #expect(ctx.mock.playCallCount == 0) - #expect(ctx.player.state == .available) - } - - @Test("PlayNow sets volume from fade schedule") - func testPlayNowSetsVolume() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith( - startingVolume: 0.8, - fades: [] - ) - _ = await ctx.player.load(spin) - - ctx.player.playNow(from: 0.0) - - // Give the async Task inside playNow a moment - try? await Task.sleep(for: .milliseconds(50)) - - #expect(mock.volume == 0.8) - } - - // MARK: - SchedulePlay - - @Test("SchedulePlay when not loaded is a no-op") - func testSchedulePlayNotLoaded() { - let ctx = createPlayer() - - ctx.player.schedulePlay(at: Date().addingTimeInterval(10)) - - #expect(ctx.mock.playCallCount == 0) - } - - @Test("SchedulePlay sets initial volume") - func testSchedulePlaySetsVolume() async { - let mock = MockAVPlayer() - let ctx = createPlayer(mockPlayer: mock) - - let spin = Spin.mockWith(startingVolume: 0.6) - _ = await ctx.player.load(spin) - - ctx.player.schedulePlay(at: Date().addingTimeInterval(10)) - - #expect(mock.volume == 0.6) - } - - // MARK: - State Transitions - - @Test("Delegate receives state changes") - func testDelegateReceivesStateChanges() { - let ctx = createPlayer() - - ctx.player.state = .loading - ctx.player.state = .loaded - ctx.player.state = .playing - - #expect(ctx.delegate.stateChanges == [.loading, .loaded, .playing]) - } - - @Test("Duplicate state changes are not reported") - func testNoDuplicateStateChanges() { - let ctx = createPlayer() - - ctx.player.state = .loading - ctx.player.state = .loading // duplicate - - #expect(ctx.delegate.stateChanges == [.loading]) - } -} diff --git a/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift b/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift deleted file mode 100644 index 46d8919..0000000 --- a/Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift +++ /dev/null @@ -1,339 +0,0 @@ -import AVFoundation -import Combine -import Foundation -import Testing - -@testable import PlayolaCore -@testable import PlayolaPlayer - -// MARK: - Mock Schedule Service - -/// Captures the mock AVPlayers created during tests so we can inspect them. -@MainActor -final class MockAVPlayerTracker { - var createdPlayers: [MockAVPlayer] = [] - - func createPlayer() -> MockAVPlayer { - let player = MockAVPlayer() - createdPlayers.append(player) - return player - } -} - -// MARK: - Tests - -@MainActor -struct StreamingStationPlayerTests { - - private func createStationPlayer(tracker: MockAVPlayerTracker? = nil) -> ( - StreamingStationPlayer, MockAVPlayerTracker - ) { - let playerTracker = tracker ?? MockAVPlayerTracker() - let stationPlayer = StreamingStationPlayer( - playerFactory: { playerTracker.createPlayer() } - ) - return (stationPlayer, playerTracker) - } - - private func createMockSchedule(stationId: String = "test-station") -> Schedule { - let now = Date() - let spin = Spin.mockWith( - id: "current-spin", - airtime: now.addingTimeInterval(-10), - stationId: stationId, - audioBlock: AudioBlock.mockWith(durationMS: 60000, endOfMessageMS: 60000) - ) - return Schedule(stationId: stationId, spins: [spin]) - } - - // MARK: - Initial State - - @Test("Initial state is idle") - func testInitialState() { - let (player, _) = createStationPlayer() - #expect(player.stationId == nil) - switch player.state { - case .idle: - break - default: - Issue.record("Expected idle state") - } - #expect(!player.isPlaying) - } - - // MARK: - Stop - - @Test("Stop resets all state") - func testStop() { - let (player, _) = createStationPlayer() - player.stationId = "test-station" - - player.stop() - - #expect(player.stationId == nil) - switch player.state { - case .idle: - break - default: - Issue.record("Expected idle state after stop") - } - #expect(player.spinPlayers.isEmpty) - } - - @Test("Stop clears all spin players") - func testStopClearsSpinPlayers() async { - let tracker = MockAVPlayerTracker() - let (player, _) = createStationPlayer(tracker: tracker) - - // Manually add a spin player to simulate state - let spin = Spin.mockWith() - let spinPlayer = StreamingSpinPlayer( - delegate: player, - playerFactory: { tracker.createPlayer() } - ) - _ = await spinPlayer.load(spin) - player.spinPlayers[spin.id] = spinPlayer - - player.stop() - - #expect(player.spinPlayers.isEmpty) - } - - // MARK: - Configure - - @Test("Configure sets baseUrl") - func testConfigureSetsBaseUrl() { - let (player, _) = createStationPlayer() - let customURL = URL(string: "https://custom-api.example.com")! - - player.configure( - authProvider: StreamingMockAuthProvider(), - baseURL: customURL - ) - - #expect(player.baseUrl == customURL) - } - - @Test("Configure creates listening session reporter") - func testConfigureCreatesReporter() { - let (player, _) = createStationPlayer() - - player.configure(authProvider: StreamingMockAuthProvider()) - - #expect(player.listeningSessionReporter != nil) - } - - // MARK: - Spin Player Delegate - - @Test("Finished spin player is removed from spinPlayers") - func testFinishedSpinPlayerRemoved() async { - let tracker = MockAVPlayerTracker() - let (stationPlayer, _) = createStationPlayer(tracker: tracker) - - let spin = Spin.mockWith() - let spinPlayer = StreamingSpinPlayer( - delegate: stationPlayer, - playerFactory: { tracker.createPlayer() } - ) - _ = await spinPlayer.load(spin) - stationPlayer.spinPlayers[spin.id] = spinPlayer - - // Simulate the spin finishing - stationPlayer.streamingPlayer(spinPlayer, didChangeState: .finished) - - #expect(stationPlayer.spinPlayers[spin.id] == nil) - } - - // MARK: - isPlaying - - @Test("isPlaying returns true when state is playing") - func testIsPlayingTrue() { - let (player, _) = createStationPlayer() - player.state = .playing(Spin.mockWith()) - #expect(player.isPlaying) - } - - @Test("isPlaying returns false when state is idle") - func testIsPlayingFalseIdle() { - let (player, _) = createStationPlayer() - player.state = .idle - #expect(!player.isPlaying) - } - - @Test("isPlaying returns false when state is loading") - func testIsPlayingFalseLoading() { - let (player, _) = createStationPlayer() - player.state = .loading - #expect(!player.isPlaying) - } - // MARK: - Play Clears Existing Spin Players - - @Test("play() clears existing spin players before starting fresh") - func testPlayClearsExistingSpinPlayers() async throws { - let tracker = MockAVPlayerTracker() - let (player, _) = createStationPlayer(tracker: tracker) - - let mockSchedule = createMockSchedule() - player.scheduleFetcher = { _, _ in mockSchedule } - - // Manually add existing spin players to simulate pre-interruption state - let staleSpin = Spin.mockWith(id: "stale-spin-1") - let staleSpinPlayer = StreamingSpinPlayer( - delegate: player, - playerFactory: { tracker.createPlayer() } - ) - _ = await staleSpinPlayer.load(staleSpin) - player.spinPlayers["stale-spin-1"] = staleSpinPlayer - - let staleSpin2 = Spin.mockWith(id: "stale-spin-2") - let staleSpinPlayer2 = StreamingSpinPlayer( - delegate: player, - playerFactory: { tracker.createPlayer() } - ) - _ = await staleSpinPlayer2.load(staleSpin2) - player.spinPlayers["stale-spin-2"] = staleSpinPlayer2 - - #expect(player.spinPlayers.count == 2) - - try await player.play(stationId: "test-station") - - // Stale players should be gone, replaced with fresh ones - #expect(player.spinPlayers["stale-spin-1"] == nil) - #expect(player.spinPlayers["stale-spin-2"] == nil) - #expect(!player.spinPlayers.isEmpty) - } - - // MARK: - Audio Interruption Handling - - @Test("Interruption began sets correct state when playing") - func testInterruptionBeganWhilePlaying() { - let (player, _) = createStationPlayer() - player.stationId = "test-station" - player.state = .playing(Spin.mockWith()) - - player.handleInterruptionBegan() - - #expect(player.wasPlayingBeforeInterruption == true) - #expect(player.interruptedStationId == "test-station") - } - - @Test("Interruption began sets wasPlayingBeforeInterruption false when not playing") - func testInterruptionBeganWhileNotPlaying() { - let (player, _) = createStationPlayer() - player.stationId = "test-station" - player.state = .idle - - player.handleInterruptionBegan() - - #expect(player.wasPlayingBeforeInterruption == false) - #expect(player.interruptedStationId == "test-station") - } - - @Test("Interruption ended with shouldResume triggers resume") - func testInterruptionEndedWithShouldResume() async throws { - let tracker = MockAVPlayerTracker() - let (player, _) = createStationPlayer(tracker: tracker) - - let mockSchedule = createMockSchedule() - var fetchCount = 0 - player.scheduleFetcher = { _, _ in - fetchCount += 1 - return mockSchedule - } - - // Simulate interruption began state - player.wasPlayingBeforeInterruption = true - player.interruptedStationId = "test-station" - - player.handleInterruptionEnded(shouldResume: true) - - // Allow the Task in resumeAfterInterruption to execute - try await Task.sleep(for: .milliseconds(100)) - - #expect(fetchCount > 0) - #expect(player.stationId == "test-station") - } - - @Test("Interruption ended without shouldResume does not resume") - func testInterruptionEndedWithoutShouldResume() async throws { - let (player, _) = createStationPlayer() - - var fetchCount = 0 - player.scheduleFetcher = { _, _ in - fetchCount += 1 - return createMockSchedule() - } - - player.wasPlayingBeforeInterruption = true - player.interruptedStationId = "test-station" - - player.handleInterruptionEnded(shouldResume: false) - - try await Task.sleep(for: .milliseconds(100)) - - #expect(fetchCount == 0) - } - - @Test("resumeAfterInterruption clears interruption flags") - func testResumeAfterInterruptionClearsFlags() async throws { - let tracker = MockAVPlayerTracker() - let (player, _) = createStationPlayer(tracker: tracker) - - let mockSchedule = createMockSchedule() - player.scheduleFetcher = { _, _ in mockSchedule } - - player.wasPlayingBeforeInterruption = true - player.interruptedStationId = "test-station" - - player.resumeAfterInterruption() - - try await Task.sleep(for: .milliseconds(100)) - - #expect(player.interruptedStationId == nil) - #expect(player.wasPlayingBeforeInterruption == false) - } - - @Test("resumeAfterInterruption with nil stationId does nothing") - func testResumeAfterInterruptionNilStationId() async throws { - let (player, _) = createStationPlayer() - - var fetchCount = 0 - player.scheduleFetcher = { _, _ in - fetchCount += 1 - return createMockSchedule() - } - - player.interruptedStationId = nil - - player.resumeAfterInterruption() - - try await Task.sleep(for: .milliseconds(100)) - - #expect(fetchCount == 0) - } - - // MARK: - Headphone Disconnect - - @Test("Headphone disconnect stops playback without setting interruption state") - func testHeadphoneDisconnectStopsWithoutInterruptionState() { - let (player, _) = createStationPlayer() - player.stationId = "test-station" - player.state = .playing(Spin.mockWith()) - - // Simulate headphone disconnect by calling stop() directly - // (this is what handleAudioRouteChange does now — just calls stop()) - player.stop() - - #expect(player.stationId == nil) - #expect(player.interruptedStationId == nil) - #expect(player.wasPlayingBeforeInterruption == false) - } -} - -// MARK: - Mock Auth Provider - -@MainActor -private final class StreamingMockAuthProvider: PlayolaAuthenticationProvider { - nonisolated func getCurrentToken() async -> String? { "mock-token" } - nonisolated func refreshToken() async -> String? { "mock-refreshed-token" } -} From d9f5928dab6f906126d981f9255998d19110ec67 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 7 Apr 2026 18:22:28 -0500 Subject: [PATCH 4/9] Fix app hang by awaiting audio session before engine start (#87) * Fix app hang by awaiting audio session before engine start The Sentry hang (2000ms+) was caused by AVAudioEngine.start() being called before the audio session was configured. configureAudioSession() fired a Task (fire-and-forget) then engine.start() ran immediately, causing the engine to stall on the main thread. Add ensureAudioSessionConfigured() async method to PlayolaMainMixer and await it in handleSuccessfulDownload before playback begins. Also remove the Thread.sleep retry loop from start() which was masking the unconfigured session issue while blocking the main thread. * Handle audio session config failure and add precondition asserts Replace try? with proper error handling in handleSuccessfulDownload so a failed audio session config bails out instead of proceeding to engine.start() with an unconfigured session. Add assertions in playNow and prepareAudioEngine to catch direct callers that skip ensureAudioSessionConfigured(). * Add .context to .gitignore --- .gitignore | 1 + .../Player/PlayolaMainMixer.swift | 52 ++++++++++--------- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 22 +++++++- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index db1f0f9..23d2965 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ fastlane/test_output fastlane/report.xml .build +.context diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index b87c5c2..ebf5a50 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -70,7 +70,7 @@ open class PlayolaMainMixer: NSObject { self.mixerNode.removeTap(onBus: 0) } - /// Configures the shared audio session for playback + /// Configures the shared audio session for playback (fire-and-forget) public func configureAudioSession() { guard !audioSessionManager.isConfigured else { return } @@ -91,6 +91,28 @@ open class PlayolaMainMixer: NSObject { } } + /// Configures the shared audio session for playback and waits for completion. + /// Use this before engine.start() to avoid stalling the audio engine. + @MainActor + public func ensureAudioSessionConfigured() async throws { + guard !audioSessionManager.isConfigured else { return } + + do { + try await audioSessionManager.configureForPlayback() + try await audioSessionManager.activate() + os_log("Audio session successfully configured", log: PlayolaMainMixer.logger, type: .info) + } catch { + let deviceName = DeviceInfoProvider.deviceName + let systemVersion = DeviceInfoProvider.systemVersion + await errorReporter.reportError( + error, + context: + "Failed to configure audio session | Device: \(deviceName) | OS: \(systemVersion)", + level: .critical) + throw error + } + } + /// Deactivates the audio session when it's no longer needed public func deactivateAudioSession() { guard audioSessionManager.isConfigured else { return } @@ -118,32 +140,12 @@ open class PlayolaMainMixer: NSObject { extension PlayolaMainMixer { @MainActor public func start() throws { - let maxRetries = 3 - var retryCount = 0 - var lastError: Error? - - while retryCount < maxRetries { - do { - try engine.start() - return - } catch { - lastError = error - retryCount += 1 - - if retryCount < maxRetries { - os_log( - "Audio engine start failed, retry %d of %d: %@", - log: PlayolaMainMixer.logger, type: .error, - retryCount, maxRetries, error.localizedDescription) - Thread.sleep(forTimeInterval: 0.1) - } - } - } - - if let error = lastError { + do { + try engine.start() + } catch { Task { await errorReporter.reportError( - error, context: "Failed to start audio engine after \(maxRetries) attempts", + error, context: "Failed to start audio engine", level: .critical) } throw error diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 1ea61f1..88d1680 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -362,7 +362,12 @@ public class SpinPlayer { ) // Record that we're starting mid-file so fades can be shifted appropriately self.playbackStartOffset = from - // Make sure audio session is configured before playback + // Callers must await ensureAudioSessionConfigured() before calling playNow. + // The fire-and-forget fallback avoids a crash but may still race. + assert( + playolaMainMixer.audioSessionManager.isConfigured, + "Audio session must be configured before calling playNow — call ensureAudioSessionConfigured() first" + ) playolaMainMixer.configureAudioSession() try engine.start() @@ -515,6 +520,16 @@ public class SpinPlayer { Task { @MainActor in await self.loadFile(with: localUrl) + // Ensure audio session is configured before starting the engine + do { + try await self.playolaMainMixer.ensureAudioSessionConfigured() + } catch { + // ensureAudioSessionConfigured already reported to Sentry; skip playback + self.clear() + continuation.resume(returning: .failure(error)) + return + } + // Determine what to do based on the spin's timing state switch spin.playbackTiming { case .future: @@ -850,6 +865,11 @@ public class SpinPlayer { ISO8601DateFormatter().string(from: scheduledDate) ) + // Callers must await ensureAudioSessionConfigured() before calling schedulePlay. + assert( + playolaMainMixer.audioSessionManager.isConfigured, + "Audio session must be configured before calling schedulePlay — call ensureAudioSessionConfigured() first" + ) playolaMainMixer.configureAudioSession() try engine.start() } From 2b13a3e0e767b0ef6bca005f17d744d933d9aa13 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Fri, 24 Apr 2026 07:54:04 -0500 Subject: [PATCH 5/9] Start audio engine off main thread on interruption resume (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Start audio engine off the main thread on interruption resume AVAudioEngine.start() makes a synchronous mach_msg to the audio server to bring up the hardware. When resuming after a phone-call interruption the hardware is cold and this call can exceed the 2s main-thread watchdog, producing Sentry "App Hanging" reports with AUIOClient_StartIO at the top of the stack. Make PlayolaMainMixer.start() async and dispatch the blocking engine start to a background queue via withCheckedThrowingContinuation, so the main thread is await-suspended rather than blocked. restartEngine() follows suit. resumeAfterInterruption now awaits the restart. AVAudioEngine is documented thread-safe for start/stop; the topology is only mutated at init/setup time on the main actor, so there is no race. @preconcurrency import AVFoundation silences the expected Sendable warning on the AVAudioEngine capture. * Keep restartEngine and start on MainActor Per review: only engine.start() needs to leave the main thread. Restore @MainActor on start() and restartEngine() so engine.stop() and engine.prepare() stay on the main actor — the continuation inside start() still dispatches the blocking engine.start() call to a background queue. --- .../Player/PlayolaMainMixer.swift | 33 +++++++++++++------ .../Player/PlayolaStationPlayer.swift | 2 +- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index ebf5a50..8386e2f 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -5,7 +5,7 @@ // Created by Brian D Keane on 1/6/25. // -import AVFoundation +@preconcurrency import AVFoundation import Foundation import PlayolaCore import os.log @@ -138,22 +138,35 @@ open class PlayolaMainMixer: NSObject { } extension PlayolaMainMixer { + /// Starts the audio engine off the main thread to avoid blocking on + /// AUIOClient_StartIO during cold hardware initialization (e.g. resuming + /// from a phone-call interruption). AVAudioEngine.start() is thread-safe; + /// only the start call itself is dispatched off main. @MainActor - public func start() throws { + public func start() async throws { + let engine = self.engine do { - try engine.start() - } catch { - Task { - await errorReporter.reportError( - error, context: "Failed to start audio engine", - level: .critical) + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + try engine.start() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } } + } catch { + await errorReporter.reportError( + error, context: "Failed to start audio engine", + level: .critical) throw error } } @MainActor - public func restartEngine() throws { + public func restartEngine() async throws { os_log("Restarting audio engine", log: PlayolaMainMixer.logger, type: .info) if engine.isRunning { @@ -161,7 +174,7 @@ extension PlayolaMainMixer { } engine.prepare() - try start() + try await start() } public var isEngineRunning: Bool { diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 437ec14..8c5d335 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -739,7 +739,7 @@ final public class PlayolaStationPlayer: ObservableObject { Task { @MainActor in do { try await PlayolaMainMixer.shared.audioSessionManager.activate() - try PlayolaMainMixer.shared.restartEngine() + try await PlayolaMainMixer.shared.restartEngine() try await self.play(stationId: stationToResume) } catch { os_log( From 3d8b5b94765dc1b82429f48dcf0f79b68fd27357 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 29 Apr 2026 08:43:11 -0700 Subject: [PATCH 6/9] Remove client-side audio normalization (#90) Normalization is now handled server-side, so the client no longer needs to calculate per-file gain or apply it via an EQ stage. Simplifies the audio graph from playerNode -> normalizationEQ -> trackMixer -> mainMixer to playerNode -> trackMixer -> mainMixer. - Delete AudioNormalizationCalculator and its tests - Delete the now-orphaned AudioBufferTestUtilities helper - Drop the zero-band AVAudioUnitEQ gain stage from SpinPlayer - Update hardResetTrackMixer to reconnect playerNode directly to the new mixer - Remove normalization mentions from README features/architecture and CLAUDE.md --- CLAUDE.md | 1 - README.md | 4 +- .../Player/AudioNormalizationCalculator.swift | 144 ---------------- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 28 +--- .../AudioNormalizationCalculatorTests.swift | 121 -------------- .../AudioNormalizationErrorTests.swift | 108 ------------ .../AudioNormalizationWaveformTests.swift | 133 --------------- .../AudioBufferTestUtilities.swift | 157 ------------------ 8 files changed, 4 insertions(+), 692 deletions(-) delete mode 100644 Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift delete mode 100644 Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift delete mode 100644 Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift delete mode 100644 Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift delete mode 100644 Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift diff --git a/CLAUDE.md b/CLAUDE.md index 938fbb8..c7d7346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,6 @@ - Build example app for simulator: `cd PlayolaPlayerExample && xcodebuild -project PlayolaPlayerExample.xcodeproj -scheme PlayolaPlayerExample -destination 'platform=iOS Simulator,id=F8C70EF6-2658-40DE-ABB2-13B4EA4CFBD3' build -quiet` - Run all tests: `swift test` - Run specific test: `swift test --filter PlayolaPlayerTests/testName` -- Run specific test suite: `swift test --filter AudioNormalizationCalculatorTests` - Generate Xcode project: `swift package generate-xcodeproj` ## Code Style Guidelines diff --git a/README.md b/README.md index 31bfa02..e12f71f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ PlayolaPlayer is a Swift Package Manager library that handles audio streaming, s - Stream audio from Playola radio stations - Schedule audio files to play at specific times - Cache files locally with automatic cleanup -- Normalize audio volume levels - Smooth transitions and crossfades between audio files - Error reporting and logging - Swift Concurrency (async/await) @@ -592,7 +591,6 @@ File management ensures smooth playback: Built on AVAudioEngine for audio processing: - Real-time mixing of multiple audio sources - Volume control and fading between tracks -- Audio normalization for consistent volume levels - Session management for handling interruptions and route changes ### How Separate Files Become Continuous Radio @@ -1137,7 +1135,7 @@ swift package generate-xcodeproj swift test # Run specific test suite -swift test --filter AudioNormalizationCalculatorTests +swift test --filter PlayolaStationPlayerTests # Run specific test swift test --filter PlayolaPlayerTests/testSpecificFunction diff --git a/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift b/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift deleted file mode 100644 index 9cdd811..0000000 --- a/Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// AudioNormalizationCalculator.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 2/7/25. -// -import AVFoundation - -struct AudioNormalizationCalculator { - enum AudioError: Error { - case fileConversion - case bufferConversion - } - - let file: AVAudioFile - var amplitude: Float? - - public func playerVolume(_ adjustedVolume: Float) -> Float { - return (amplitude ?? 1.0) * adjustedVolume - } - - /// Creates an AudioNormalizationCalculator with amplitude calculated on a background thread - static func create(_ file: AVAudioFile) async -> AudioNormalizationCalculator { - return await withCheckedContinuation { continuation in - Task.detached(priority: .userInitiated) { - var calculator = AudioNormalizationCalculator(file: file, calculateNow: false) - do { - let samples = try calculator.getSamples() - calculator.amplitude = calculator.getAmplitude(samples) - } catch { - // Report error on main thread - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get audio samples for normalization | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description) | Length: \(file.length)", - level: .warning) - } - continuation.resume(returning: calculator) - } - } - } - - /// Synchronous initializer for backwards compatibility (e.g., tests) - init(_ file: AVAudioFile) { - self.file = file - do { - let samples = try getSamples() - self.amplitude = getAmplitude(samples) - } catch { - // Replace print with proper error reporting - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get audio samples for normalization | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description) | Length: \(file.length)", - level: .warning) - } - } - } - - /// Private initializer used by async factory - private init(file: AVAudioFile, calculateNow: Bool) { - self.file = file - if calculateNow { - do { - let samples = try getSamples() - self.amplitude = getAmplitude(samples) - } catch { - // Error will be handled by caller - } - } - } - - func getSamples() throws -> [Float] { - guard - let buffer = AVAudioPCMBuffer( - pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length)) - else { - let detailedError = AudioError.bufferConversion - Task { - await PlayolaErrorReporter.shared.reportError( - detailedError, - context: - "Failed to create PCM buffer | File format: \(file.processingFormat.description) | " - + "Frame capacity: \(file.length)", - level: .error) - } - throw detailedError - } - - do { - try file.read(into: buffer) - } catch { - // Enhance the error with context before throwing - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to read audio file into buffer | File: \(file.url.lastPathComponent) | " - + "Format: \(file.processingFormat.description)", - level: .error) - } - throw error - } - - guard let channelData = buffer.floatChannelData?.pointee else { - let error = AudioError.bufferConversion - Task { - await PlayolaErrorReporter.shared.reportError( - error, - context: - "Failed to get float channel data from buffer | Buffer length: \(buffer.frameLength)", - level: .error) - } - throw error - } - - let floatArray = Array(UnsafeBufferPointer(start: channelData, count: Int(buffer.frameLength))) - return floatArray - } - - func getAmplitude(_ samples: [Float]) -> Float? { - let absArray = samples.map { abs($0) } - return absArray.max() - } -} - -// MARK: - Loudness helpers -extension AudioNormalizationCalculator { - /// Compute the dB offset required to reach target loudness from a given peak amplitude. - /// If `amplitude` is nil or non‑positive, returns 0 dB. - /// Formula: gain(dB) = -20 * log10(amplitude) - static func requiredDbOffsetDb(forAmplitude amplitude: Float?) -> Float { - guard let amplitude, amplitude > 0 else { return 0 } - return Float(-20.0 * log10(Double(amplitude))) - } - - /// Instance convenience accessor that uses the calculator's measured amplitude. - var requiredDbOffsetDb: Float { - Self.requiredDbOffsetDb(forAmplitude: amplitude) - } -} diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 88d1680..812afc3 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -21,7 +21,6 @@ import os.log /// with support for: /// - Precise scheduling at specific timestamps /// - Volume fading and crossfading -/// - Audio normalization /// - Notifying delegates of playback events @MainActor public class SpinPlayer { @@ -97,9 +96,6 @@ public class SpinPlayer { private let engine: AVAudioEngine! = PlayolaMainMixer.shared.engine /// The node responsible for playing the audio file private let playerNode = AVAudioPlayerNode() - /// Per-file loudness normalization stage. We use an EQ solely for its `globalGain` which - /// supports boosts up to +24 dB (unlike AVAudioMixerNode which tops out at 1.0 and cannot boost). - private let normalizationEQ = AVAudioUnitEQ(numberOfBands: 0) // use only globalGain /// Insert a per-player track mixer so we can automate volume on the audio thread private var trackMixer = AVAudioMixerNode() @@ -114,7 +110,6 @@ public class SpinPlayer { private var didCaptureStart = false // MARK: - File Management - private var normalizationCalculator: AudioNormalizationCalculator? /// The currently playing audio file private var currentFile: AVAudioFile? { didSet { @@ -152,17 +147,11 @@ public class SpinPlayer { /// Make connections engine.attach(playerNode) - engine.attach(normalizationEQ) engine.attach(trackMixer) - // Graph: playerNode -> normalizationEQ (globalGain) -> per-track mixer -> main mixer + // Graph: playerNode -> per-track mixer -> main mixer engine.connect( playerNode, - to: normalizationEQ, - format: TapProperties.default.format - ) - engine.connect( - normalizationEQ, to: trackMixer, format: TapProperties.default.format ) @@ -173,7 +162,6 @@ public class SpinPlayer { ) // Baselines - normalizationEQ.globalGain = 0.0 // dB; set per-file on load when normalization is applied trackMixer.outputVolume = 1.0 // Always keep the player node at unity gain playerNode.volume = 1.0 @@ -231,9 +219,9 @@ public class SpinPlayer { trackMixer = newMixer engine.attach(newMixer) - // Reconnect normalizationEQ -> trackMixer -> main mixer + // Reconnect playerNode -> trackMixer -> main mixer engine.connect( - normalizationEQ, + playerNode, to: newMixer, format: TapProperties.default.format ) @@ -1028,16 +1016,6 @@ public class SpinPlayer { private func createAudioFileFromValidatedURL(_ url: URL, fileSize: Int) async throws { do { currentFile = try AVAudioFile(forReading: url) - normalizationCalculator = await AudioNormalizationCalculator.create(currentFile!) - // Use calculator's dB helper; clamp to a practical range for safety - let rawDb = Double(self.normalizationCalculator?.requiredDbOffsetDb ?? 0) - let gainDb = min(24.0, max(-24.0, rawDb)) - self.normalizationEQ.globalGain = Float(gainDb) - os_log( - "🎚️ Set normalizationEQ.globalGain = %.2f dB for %@", - log: SpinPlayer.logger, type: .info, - gainDb, url.lastPathComponent - ) logSuccessfulLoad(url) } catch let audioError as NSError { await handleAudioFileCreationError(audioError, url: url, fileSize: fileSize) diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift deleted file mode 100644 index 7d42106..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationCalculatorTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// AudioNormalizationCalculatorTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// - -import AVFoundation -import Testing -import XCTest - -@testable import PlayolaPlayer - -// Create a protocol that both AVAudioFile and our mock implement -protocol AudioFileReadable { - var length: AVAudioFramePosition { get } - var processingFormat: AVAudioFormat { get } - var url: URL { get } - func read(into buffer: AVAudioPCMBuffer) throws -} - -// Make AVAudioFile conform to our protocol -extension AVAudioFile: AudioFileReadable {} - -// Make our mock conform to the protocol -extension AVAudioFileMock: AudioFileReadable {} - -struct AudioNormalizationCalculatorTests { - - // Test with silence (all zero values) - @Test("Calculator handles silence (zero amplitude)") - func testSilenceHandling() throws { - // Create an audio file mock with all zeros (silence) - let silenceSamples = Array(repeating: Float(0), count: 1000) - let mockAudioFile = AVAudioFileMock(samples: silenceSamples) - - // Create a property to simulate the calculator's behavior with zero amplitude - let amplitude: Float = 0.0 - - // Test volume adjustments with zero amplitude - let adjustedVolume = 1.0 / max(amplitude, 1.0) - #expect(adjustedVolume == 1.0) - - let playerVolume = amplitude * 0.5 - #expect(playerVolume == 0.0) - } - - // Test with normal audio (mixed positive and negative values) - @Test("Calculator detects correct amplitude from audio samples") - func testNormalAudio() throws { - // Create an audio file mock with samples containing mixed amplitudes - // Max value is 0.8, which should be detected as the amplitude - let mixedSamples: [Float] = [0.1, 0.5, -0.3, 0.8, -0.6, 0.2, -0.8, 0.3] - let mockAudioFile = AVAudioFileMock(samples: mixedSamples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.8 - - // Test volume adjustments - // For a file with amplitude 0.8, adjustedVolume should divide by 0.8 - // and playerVolume should multiply by 0.8 - let adjustedVolume = 0.64 / expectedAmplitude - #expect(abs(adjustedVolume - 0.8) < 0.001) - - let playerVolume = expectedAmplitude * 1.0 - #expect(abs(playerVolume - 0.8) < 0.001) - } - - // Test with loud audio (values close to or at the maximum) - @Test("Calculator handles loud audio correctly") - func testLoudAudio() throws { - // Create samples with values close to the maximum (1.0) - let loudSamples: [Float] = [0.95, 0.98, -0.92, 0.99, -0.97] - let mockAudioFile = AVAudioFileMock(samples: loudSamples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.99 - - // Test volume adjustments for loud audio - let adjustedVolume = 0.5 / expectedAmplitude - let expectedAdjustedVolume = 0.5 / 0.99 - #expect(abs(Double(adjustedVolume) - expectedAdjustedVolume) < 0.001) - - let playerVolume = expectedAmplitude * 0.5 - let expectedPlayerVolume = 0.5 * 0.99 - #expect(abs(Double(playerVolume) - expectedPlayerVolume) < 0.001) - } - - // Test the relationship between adjustedVolume and playerVolume - @Test("adjustedVolume and playerVolume are inverse operations") - func testVolumeAdjustmentInverse() throws { - // Create samples with a known amplitude - let samples: [Float] = [0.0, 0.7, -0.4, 0.2] - let mockAudioFile = AVAudioFileMock(samples: samples) - - // Calculate the expected amplitude - let expectedAmplitude: Float = 0.7 - - // Test inverse relationship - let originalVolume: Float = 0.5 - let adjusted = originalVolume / expectedAmplitude - let restored = adjusted * expectedAmplitude - - // The restored value should be very close to the original - #expect(abs(restored - originalVolume) < 0.001) - } - - @Test("requiredDbOffsetDb returns +6.02 dB for amplitude 0.5") - func testRequiredDbOffsetDbHalf() throws { - let db = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: 0.5) - #expect(abs(Double(db) - 6.0206) < 0.01) - } - - @Test("requiredDbOffsetDb returns 0 dB for nil/zero amplitude") - func testRequiredDbOffsetDbZeroOrNil() throws { - let dbNil = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: nil) - let dbZero = AudioNormalizationCalculator.requiredDbOffsetDb(forAmplitude: 0.0) - #expect(dbNil == 0) - #expect(dbZero == 0) - } -} diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift deleted file mode 100644 index 81f90a5..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationErrorTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// AudioNormalizationErrorTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. - -import AVFoundation -import Testing -import XCTest - -@testable import PlayolaPlayer - -struct AudioNormalizationErrorTests { - - @Test("Calculator handles read errors gracefully") - func testReadErrorHandling() throws { - // Create a custom audio file mock that will fail on read - let mockAudioFile = AVAudioFileMock(samples: [0.5, 0.6, 0.7]) - mockAudioFile.shouldThrowReadError = true - - // For testing purpose, check that our mock behaves as expected - // Let's try a simpler approach - just verify an error is thrown by trying to read - // and catching the error - var didThrow = false - do { - let buffer = AVAudioPCMBuffer(pcmFormat: mockAudioFile.processingFormat, frameCapacity: 100)! - try mockAudioFile.read(into: buffer) - } catch { - didThrow = true - } - #expect(didThrow) - - // Since we can't directly test the AudioNormalizationCalculator with our mock due to - // the inheritance issues, we'll test the core functionality: - // 1. When read() fails, getSamples() should throw an error - // 2. When getSamples() throws, amplitude should be nil - // 3. When amplitude is nil, adjustedVolume and playerVolume should return default values - - // Simulate the expected behavior: - let amplitude: Float? = nil // Would be nil after a read error - - // When amplitude is nil, adjustedVolume should return the input value - let inputVolume: Float = 0.7 - let expectedAdjustedVolume = inputVolume / (amplitude ?? 1.0) - #expect(expectedAdjustedVolume == inputVolume) - - // When amplitude is nil, playerVolume should return the input value - let expectedPlayerVolume = (amplitude ?? 1.0) * inputVolume - #expect(expectedPlayerVolume == inputVolume) - } -} - -// Enhanced mock that can provide more detailed error information -class EnhancedAudioFileMock: AVAudioFileMock { - enum FailureMode { - case none - case readError - case bufferCreationError - case channelDataError - } - - var failureMode: FailureMode = .none - var errorMessage: String = "" - - init(samples: [Float], failureMode: FailureMode = .none, errorMessage: String = "") { - super.init(samples: samples) - self.failureMode = failureMode - self.errorMessage = errorMessage - } - - override func read(into buffer: AVAudioPCMBuffer) throws { - switch failureMode { - case .none: - // Normal operation - try super.read(into: buffer) - - case .readError: - // Simulate a read error - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -1, - userInfo: [NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Read error" : errorMessage] - ) - - case .bufferCreationError: - // Simulate a buffer error after successful read - try super.read(into: buffer) - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -2, - userInfo: [ - NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Buffer creation error" : errorMessage - ] - ) - - case .channelDataError: - // Simulate channel data access error - try super.read(into: buffer) - throw NSError( - domain: "AVAudioFileErrorDomain", - code: -3, - userInfo: [ - NSLocalizedDescriptionKey: errorMessage.isEmpty ? "Channel data error" : errorMessage - ] - ) - } - } -} diff --git a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift b/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift deleted file mode 100644 index 1b5f2bb..0000000 --- a/Tests/PlayolaPlayerTests/AudioNormalizationCalculatorTests/AudioNormalizationWaveformTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -import AVFoundation -import Testing -// -// AudioNormalizationWaveformTests.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// -import XCTest - -@testable import PlayolaPlayer - -struct AudioNormalizationWaveformTests { - - // For each test we'll directly test the math that the AudioNormalizationCalculator would perform - - @Test("Testing with a sine wave") - func testSineWave() throws { - // Generate a sine wave with amplitude 0.8 - let sineWaveSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .sine, - count: 1000, - amplitude: 0.8 - ) - - // Calculate the actual maximum amplitude of the samples - let actualMaxAmplitude = sineWaveSamples.map { abs($0) }.max() ?? 0 - - // Typically a sine wave won't hit exactly the requested amplitude due to discrete sampling - #expect(actualMaxAmplitude <= 0.8) - #expect(actualMaxAmplitude > 0.7) - - // Test the volume adjustment equations directly - let inputVolume: Float = 0.5 - let adjustedVolume = inputVolume / actualMaxAmplitude - - // Verify that adjustedVolume * amplitude = original input - #expect(abs(adjustedVolume * actualMaxAmplitude - inputVolume) < 0.001) - } - - @Test("Testing with a square wave") - func testSquareWave() throws { - // Generate a square wave with amplitude 0.6 - let squareWaveSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .square, - count: 1000, - amplitude: 0.6 - ) - - // Square waves have consistent amplitude - let actualMaxAmplitude = squareWaveSamples.map { abs($0) }.max() ?? 0 - #expect(abs(actualMaxAmplitude - 0.6) < 0.001) - - // Test the volume adjustment math - let playerVolume = actualMaxAmplitude * 0.8 // 0.8 is the requested volume - #expect(abs(playerVolume - 0.48) < 0.001) - } - - @Test("Testing with a ramp wave") - func testRampWave() throws { - // Generate a ramp from -0.7 to 0.7 - let rampSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .ramp, - count: 1000, - amplitude: 0.7 - ) - - // Calculate the actual maximum amplitude - let actualMaxAmplitude = rampSamples.map { abs($0) }.max() ?? 0 - #expect(abs(actualMaxAmplitude - 0.7) < 0.001) - - // The adjusted volume should be the input volume divided by the amplitude - let adjustedVolume = 0.5 / actualMaxAmplitude - #expect(abs(adjustedVolume - (0.5 / 0.7)) < 0.001) - } - - @Test("Testing with random noise") - func testNoise() throws { - // Generate random noise with max amplitude 0.5 - let noiseSamples = AudioBufferTestUtilities.generateTestSamples( - pattern: .noise, - count: 1000, - amplitude: 0.5 - ) - - // Find the actual maximum amplitude in the noise - let actualMaxAmplitude = noiseSamples.map { abs($0) }.max() ?? 0 - #expect(actualMaxAmplitude <= 0.5) - - // The player volume at a requested level of 1.0 should be the same as the amplitude - let playerVolume = actualMaxAmplitude * 1.0 - #expect(playerVolume == actualMaxAmplitude) - } - - @Test("Testing consistent volume normalization across waveforms") - func testConsistentNormalization() throws { - // Generate different waveforms with different amplitudes - let sineWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .sine, count: 1000, amplitude: 0.9) - let squareWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .square, count: 1000, amplitude: 0.7) - let rampWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .ramp, count: 1000, amplitude: 0.5) - let noiseWave = AudioBufferTestUtilities.generateTestSamples( - pattern: .noise, count: 1000, amplitude: 0.3) - - // Find the maximum amplitude of each waveform - let sineAmplitude = sineWave.map { abs($0) }.max() ?? 0 - let squareAmplitude = squareWave.map { abs($0) }.max() ?? 0 - let rampAmplitude = rampWave.map { abs($0) }.max() ?? 0 - let noiseAmplitude = noiseWave.map { abs($0) }.max() ?? 0 - - // Target volume we want for all waveforms - let targetVolume: Float = 0.8 - - // Calculate the adjusted input volume needed for each waveform to reach the target - let sineAdjusted = targetVolume / sineAmplitude - let squareAdjusted = targetVolume / squareAmplitude - let rampAdjusted = targetVolume / rampAmplitude - let noiseAdjusted = targetVolume / noiseAmplitude - - // Verify that with these adjusted volumes, all waveforms would play at the same volume - let sineResult = sineAmplitude * sineAdjusted - let squareResult = squareAmplitude * squareAdjusted - let rampResult = rampAmplitude * rampAdjusted - let noiseResult = noiseAmplitude * noiseAdjusted - - #expect(abs(sineResult - targetVolume) < 0.001) - #expect(abs(squareResult - targetVolume) < 0.001) - #expect(abs(rampResult - targetVolume) < 0.001) - #expect(abs(noiseResult - targetVolume) < 0.001) - } -} diff --git a/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift b/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift deleted file mode 100644 index fae3b1d..0000000 --- a/Tests/PlayolaPlayerTests/Test Utilities/AudioBufferTestUtilities.swift +++ /dev/null @@ -1,157 +0,0 @@ -import AVFoundation -// -// AudioBufferTestUtilities.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 3/22/25. -// -import Foundation - -@testable import PlayolaPlayer - -/// Provides test utilities for working with audio data in tests -class AudioBufferTestUtilities { - - /// Creates a PCM buffer with the given samples - /// - Parameters: - /// - samples: Array of sample values to include in the buffer - /// - format: The audio format to use (defaults to stereo 44.1kHz float32) - /// - Returns: An initialized AVAudioPCMBuffer or nil if creation fails - static func createBuffer( - samples: [Float], - format: AVAudioFormat? = nil - ) -> AVAudioPCMBuffer? { - let audioFormat = format ?? AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! - - guard - let buffer = AVAudioPCMBuffer( - pcmFormat: audioFormat, - frameCapacity: AVAudioFrameCount(samples.count)) - else { - return nil - } - - // Copy samples to the buffer - if let channelData = buffer.floatChannelData { - for i in 0.. [Float] { - guard let channelData = buffer.floatChannelData, - channel < buffer.format.channelCount - else { - return [] - } - - var samples = [Float]() - let channelDataPtr = channelData[channel] - - for i in 0.. [Float] { - switch pattern { - case .sine: - return generateSineWave(sampleCount: count, amplitude: amplitude) - case .ramp: - return generateRamp(sampleCount: count, amplitude: amplitude) - case .square: - return generateSquareWave(sampleCount: count, amplitude: amplitude) - case .noise: - return generateNoise(sampleCount: count, amplitude: amplitude) - } - } - - // MARK: - Sample Generation Patterns - - enum SamplePattern { - case sine // Sine wave - case ramp // Linear ramp up - case square // Square wave - case noise // Random noise - } - - private static func generateSineWave(sampleCount: Int, amplitude: Float) -> [Float] { - var samples = [Float]() - let frequency: Float = 440.0 // A4 note - let sampleRate: Float = 44100.0 - - for i in 0.. [Float] { - var samples = [Float]() - - for i in 0.. [Float] { - var samples = [Float]() - let frequency: Float = 440.0 // A4 note - let sampleRate: Float = 44100.0 - let period = Int(sampleRate / frequency) - - for i in 0.. [Float] { - var samples = [Float]() - - for _ in 0.. Date: Sat, 23 May 2026 09:48:04 -0500 Subject: [PATCH 7/9] Cap all URLSessions to TLS 1.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopgap for an iOS 26 networking bug: URLSession's TLS 1.3 ClientHello includes the X25519MLKEM768 post-quantum hybrid key share (~1.6 KB), and some middleboxes (antivirus SSL inspection, parental-control routers, captive portals) drop the larger ClientHello, surfacing as NSURLErrorSecureConnectionFailed (-1200). Capping to TLS 1.2 keeps the ClientHello small enough to pass through. Routes the library's URLSessions through a new internal tls12Session (or makeTLS12Configuration() when a delegate is needed) so audio downloads from S3, schedule fetches, and listening-session reports all succeed on affected networks. Hardcoded rather than configurable on purpose — this is temporary and should be easy to revert when Apple ships a fix or a supported opt-out for the post-quantum hybrid key share. --- .../Player/FileDownloaderAsync.swift | 4 +-- .../Player/ListeningSessionReporter.swift | 4 +-- .../Player/PlayolaStationPlayer.swift | 2 +- .../PlayolaPlayer/Player/TLS12Session.swift | 25 +++++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 Sources/PlayolaPlayer/Player/TLS12Session.swift diff --git a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift index 8f9c24b..d87bdcf 100644 --- a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift +++ b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift @@ -34,7 +34,7 @@ public actor FileDownloaderAsync { public func download(from url: URL, to destinationURL: URL) async throws -> DownloadResult { guard !isCancelled else { throw URLError(.cancelled) } - let configuration = URLSessionConfiguration.default + let configuration = makeTLS12Configuration() configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 300 configuration.waitsForConnectivity = true @@ -84,7 +84,7 @@ public actor FileDownloaderAsync { logger: logger ) - let configuration = URLSessionConfiguration.default + let configuration = makeTLS12Configuration() configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 300 configuration.waitsForConnectivity = true diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index 1362e0d..ea3c9cc 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -66,7 +66,7 @@ public class ListeningSessionReporter { init( stationPlayer: PlayolaStationPlayer, authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = URLSession.shared, + urlSession: URLSessionProtocol = tls12Session, baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = stationPlayer @@ -307,7 +307,7 @@ public class ListeningSessionReporter { #if DEBUG internal init( authProvider: PlayolaAuthenticationProvider? = nil, - urlSession: URLSessionProtocol = URLSession.shared, + urlSession: URLSessionProtocol = tls12Session, baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.stationPlayer = nil diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 8c5d335..acb1c61 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -402,7 +402,7 @@ final public class PlayolaStationPlayer: ObservableObject { let url = createScheduleURL(for: stationId) do { - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await tls12Session.data(from: url) let httpResponse = try validateHTTPResponse(response, url: url) try await validateStatusCode(httpResponse, data: data, stationId: stationId) diff --git a/Sources/PlayolaPlayer/Player/TLS12Session.swift b/Sources/PlayolaPlayer/Player/TLS12Session.swift new file mode 100644 index 0000000..930a977 --- /dev/null +++ b/Sources/PlayolaPlayer/Player/TLS12Session.swift @@ -0,0 +1,25 @@ +import Foundation + +// TEMPORARY: Global TLS 1.2 cap. +// +// On iOS 26, URLSession sends a TLS 1.3 ClientHello that includes the X25519MLKEM768 +// post-quantum hybrid key share (~1.6 KB). Some users sit behind middleboxes +// (antivirus SSL inspection, parental-control routers, captive portals, etc.) that +// drop the larger ClientHello and surface as NSURLErrorSecureConnectionFailed (-1200). +// Capping URLSession to TLS 1.2 keeps the ClientHello small enough for these +// middleboxes to pass through. +// +// Every URLSession in this library is routed through `tls12Session` (or a +// configuration derived from `makeTLS12Configuration()` when a delegate is needed) +// so audio downloads, schedule fetches, and listening-session reports all succeed +// on these networks. +// +// REVERT WHEN: Apple ships an iOS 26 fix (watch 26.5+ release notes) OR exposes +// a supported opt-out for the post-quantum hybrid key share so we can keep TLS 1.3. +internal func makeTLS12Configuration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + configuration.tlsMaximumSupportedProtocolVersion = .TLSv12 + return configuration +} + +internal let tls12Session: URLSession = URLSession(configuration: makeTLS12Configuration()) From e53fbd89bf03ff4c3e17dc16b9588ed9ffe085a1 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 23 May 2026 09:58:18 -0500 Subject: [PATCH 8/9] Pin TLS minimum to 1.2 as well Greptile P2: makeTLS12Configuration() only pinned the maximum TLS version, leaving the minimum at its default (.TLSv10). ATS already enforces a TLS 1.2 floor for HTTPS on iOS, but pinning the minimum explicitly removes any ambiguity about the supported range and makes the intent ("TLS 1.2, exclusively") obvious from the code. --- Sources/PlayolaPlayer/Player/TLS12Session.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/PlayolaPlayer/Player/TLS12Session.swift b/Sources/PlayolaPlayer/Player/TLS12Session.swift index 930a977..445c70b 100644 --- a/Sources/PlayolaPlayer/Player/TLS12Session.swift +++ b/Sources/PlayolaPlayer/Player/TLS12Session.swift @@ -18,6 +18,7 @@ import Foundation // a supported opt-out for the post-quantum hybrid key share so we can keep TLS 1.3. internal func makeTLS12Configuration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.default + configuration.tlsMinimumSupportedProtocolVersion = .TLSv12 configuration.tlsMaximumSupportedProtocolVersion = .TLSv12 return configuration } From eda952bab7bd7ec73ca05ad7fade9157ae5328f4 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 23 May 2026 20:05:09 -0500 Subject: [PATCH 9/9] fix: align baseUrl default and move cold-start engine.start off main - PlayolaStationPlayer.baseUrl default no longer includes /v1, so the shared instance constructs schedule URLs as /v1/stations/{id}/schedule instead of /v1/v1/stations/{id}/schedule when configure() is never called. Aligns with configure()'s baseURL parameter default and the /v1 prefix that createScheduleURL still appends. - SpinPlayer.handleSuccessfulDownload now awaits playolaMainMixer.start() after ensureAudioSessionConfigured(), so engine.start() runs off-main on the cold first-play path (not just on interruption resume). playNow and prepareAudioEngine now skip the sync engine.start() when the engine is already running. --- .../Player/PlayolaStationPlayer.swift | 2 +- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index acb1c61..2f893b3 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -59,7 +59,7 @@ public enum StationPlayerError: Error, LocalizedError { /// ``` @MainActor final public class PlayolaStationPlayer: ObservableObject { - var baseUrl = URL(string: "https://admin-api.playola.fm/v1")! + var baseUrl = URL(string: "https://admin-api.playola.fm")! @Published public var stationId: String? private var interruptedStationId: String? var currentSchedule: Schedule? diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 812afc3..dae02f7 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -357,7 +357,9 @@ public class SpinPlayer { "Audio session must be configured before calling playNow — call ensureAudioSessionConfigured() first" ) playolaMainMixer.configureAudioSession() - try engine.start() + if !engine.isRunning { + try engine.start() + } guard let audioFile = validateAndGetCurrentFile() else { return } scheduleAndPlaySegment(audioFile: audioFile, from: from) @@ -508,11 +510,14 @@ public class SpinPlayer { Task { @MainActor in await self.loadFile(with: localUrl) - // Ensure audio session is configured before starting the engine + // Ensure audio session is configured and engine is started before playback. + // Starting the engine here (off-main via playolaMainMixer.start) avoids the + // AUIOClient_StartIO main-thread hang on cold first-play. do { try await self.playolaMainMixer.ensureAudioSessionConfigured() + try await self.playolaMainMixer.start() } catch { - // ensureAudioSessionConfigured already reported to Sentry; skip playback + // ensureAudioSessionConfigured / start already reported to Sentry; skip playback self.clear() continuation.resume(returning: .failure(error)) return @@ -859,7 +864,9 @@ public class SpinPlayer { "Audio session must be configured before calling schedulePlay — call ensureAudioSessionConfigured() first" ) playolaMainMixer.configureAudioSession() - try engine.start() + if !engine.isRunning { + try engine.start() + } } private func validateAudioFile() throws {