From 1127993e26f8b1ea8e2e0eb46bb8380e440f1da2 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 21 Mar 2026 10:41:51 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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() }