-
Notifications
You must be signed in to change notification settings - Fork 0
Add AVPlayer-based streaming player #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
0247464
update env basic auth
briankeane 77499f7
Merge pull request #81 from playola-radio/chore/update-env-vars
briankeane b185747
Add AVPlayer-based streaming player for faster audio startup
briankeane b2e77e0
Add streaming/download toggle to example app and fix station switching
briankeane c54dc5e
Fix SwiftLint violations: large tuple and for-where
briankeane 4658b78
Fix concurrency bugs, memory leak, and race conditions from review
briankeane 5284796
Fix review round 2: idle state, @StateObject, overlapping fades
briankeane 98c58cf
Guard against stop() during loadAndPlaySpin await
briankeane 03e2ce3
Add PR review comment reaction guideline to CLAUDE.md
briankeane File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
277 changes: 159 additions & 118 deletions
277
PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| 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)") | ||
| } | ||
| } | ||
| } |
110 changes: 110 additions & 0 deletions
110
Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| 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<Void, Error>) 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() | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.