Skip to content
277 changes: 159 additions & 118 deletions PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift

Large diffs are not rendered by default.

84 changes: 32 additions & 52 deletions Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ public class ListeningSessionReporter {
return DeviceInfoProvider.identifierForVendor?.uuidString
}
var timer: Timer?
let basicToken = "aW9zQXBwOnNwb3RpZnlTdWNrc0FCaWcx" // TODO: De-hard-code this
var currentSessionStationId: String?
var disposeBag = Set<AnyCancellable>()
weak var stationPlayer: PlayolaStationPlayer?
private let stationIdGetter: () -> String?
var currentListeningSessionID: String?
private let errorReporter = PlayolaErrorReporter.shared
private let authProvider: PlayolaAuthenticationProvider?
Expand All @@ -74,8 +74,29 @@ public class ListeningSessionReporter {
self.authProvider = authProvider
self.urlSession = urlSession
self.baseURL = baseURL
self.stationIdGetter = { [weak stationPlayer] in stationPlayer?.stationId }

stationPlayer.$stationId.sink { [weak self] stationId in
subscribeToStationId(stationPlayer.$stationId.eraseToAnyPublisher())
}

init(
stationIdPublisher: AnyPublisher<String?, Never>,
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<String?, Never>) {
publisher.sink { [weak self] stationId in
guard let self else { return }
if let stationId {
Task {
Expand All @@ -98,7 +119,6 @@ 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,
Expand Down Expand Up @@ -196,7 +216,7 @@ public class ListeningSessionReporter {
withTimeInterval: 10.0, repeats: true,
block: { [weak self] _ in
guard let self else { return }
guard let stationId = self.stationPlayer?.stationId else {
guard let stationId = self.stationIdGetter() else {
let error = ListeningSessionError.invalidResponse(
"Missing stationId in periodic notification")
Task {
Expand Down Expand Up @@ -239,17 +259,15 @@ public class ListeningSessionReporter {

// Check if we've exceeded retry limits
if refreshAttempts >= maxRefreshAttempts {
let error = ListeningSessionError.authenticationFailed("Max refresh attempts exceeded")
Task {
await errorReporter.reportError(
ListeningSessionError.authenticationFailed("Max refresh attempts exceeded"),
error,
context: "Exceeded maximum refresh attempts (\(maxRefreshAttempts))",
level: .warning
)
}

// Fall back to Basic auth
try await attemptWithBasicAuth(url: url, requestBody: requestBody)
return
throw error
}

// Attempt token refresh
Expand All @@ -276,43 +294,7 @@ public class ListeningSessionReporter {
"HTTP status code after refresh: \(retryHttpResponse.statusCode)")
}
} else {
// Refresh failed - try with Basic auth as fallback
try await attemptWithBasicAuth(url: url, requestBody: requestBody)
}
}

private func attemptWithBasicAuth(url: URL, requestBody: ListeningSessionRequest) async throws {
// Create request with Basic auth (bypassing the auth provider)
var request = URLRequest(url: url)
request.httpMethod = "POST"

do {
request.httpBody = try JSONEncoder().encode(requestBody)
} catch {
throw ListeningSessionError.encodingError(
"Failed to encode request body: \(error.localizedDescription)")
}

request.addValue("Basic \(basicToken)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")

let (_, response) = try await urlSession.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw ListeningSessionError.invalidResponse("Invalid HTTP response with Basic auth")
}

if (200...299).contains(httpResponse.statusCode) {
// Success with Basic auth
Task {
await errorReporter.reportError(
ListeningSessionError.authenticationFailed("Fell back to Basic auth"),
context: "Authentication failed, using Basic auth fallback",
level: .warning
)
}
} else {
throw ListeningSessionError.authenticationFailed("Both Bearer token and Basic auth failed")
throw ListeningSessionError.authenticationFailed("Token refresh failed")
}
}

Expand All @@ -333,25 +315,23 @@ public class ListeningSessionReporter {
"Failed to encode request body: \(error.localizedDescription)")
}

// Use Bearer token if user is authenticated, otherwise fall back to Basic auth
if let userToken = await authProvider?.getCurrentToken() {
request.addValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
} else {
request.addValue("Basic \(basicToken)", forHTTPHeaderField: "Authorization")
guard let userToken = await authProvider?.getCurrentToken() else {
throw ListeningSessionError.authenticationFailed("No authentication token available")
}
request.addValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")

request.addValue("application/json", forHTTPHeaderField: "Content-Type")
return request
}

// TODO: Find a better way of doing this. Protocols + ObservableObject has issues.
#if DEBUG
internal init(
authProvider: PlayolaAuthenticationProvider? = nil,
urlSession: URLSessionProtocol = URLSession.shared,
baseURL: URL = URL(string: "https://admin-api.playola.fm")!
) {
self.stationPlayer = nil
self.stationIdGetter = { nil }
self.authProvider = authProvider
self.urlSession = urlSession
self.baseURL = baseURL
Expand Down
2 changes: 1 addition & 1 deletion Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public enum StationPlayerError: Error, LocalizedError {
/// ```
@MainActor
final public class PlayolaStationPlayer: ObservableObject {
var baseUrl = URL(string: "https://admin-api.playola.fm/v1")!
var baseUrl = URL(string: "https://admin-api.playola.fm")!
@Published public var stationId: String?
private var interruptedStationId: String?
var currentSchedule: Schedule?
Expand Down
106 changes: 106 additions & 0 deletions Sources/PlayolaPlayer/Player/ScheduleService.swift
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 Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift
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"))
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}
}
}

public func clearItem() {
statusObservation?.invalidate()
statusObservation = nil
player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil
}

deinit {
statusObservation?.invalidate()
}
}
Loading
Loading