From 934c7bf7e956f719740cca04c446810672cfc1f9 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Thu, 25 Sep 2025 12:17:49 -0500 Subject: [PATCH 01/17] add basurl input for PlayolaStationPlaer --- .../PlayolaPlayer/Player/PlayolaStationPlayer.swift | 4 ++-- .../PlayolaStationPlayerTests.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 947d5e3..590979b 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -10,8 +10,6 @@ import Combine import Foundation import os.log -let baseUrl = URL(string: "https://admin-api.playola.fm/v1")! - /// Errors specific to the station player public enum StationPlayerError: Error, LocalizedError { case networkError(String) @@ -60,6 +58,7 @@ public enum StationPlayerError: Error, LocalizedError { /// ``` @MainActor final public class PlayolaStationPlayer: ObservableObject { + var baseUrl = URL(string: "https://admin-api.playola.fm/v1")! @Published public var stationId: String? // TODO: Change this to Station model private var interruptedStationId: String? var currentSchedule: Schedule? @@ -110,6 +109,7 @@ final public class PlayolaStationPlayer: ObservableObject { self.authProvider = authProvider self.listeningSessionReporter = ListeningSessionReporter( stationPlayer: self, authProvider: authProvider, baseURL: baseURL) + self.baseUrl = baseURL } public enum State: Sendable { diff --git a/Tests/PlayolaPlayerTests/PlayolaStationPlayerTests.swift b/Tests/PlayolaPlayerTests/PlayolaStationPlayerTests.swift index 6f0803c..004a8e3 100644 --- a/Tests/PlayolaPlayerTests/PlayolaStationPlayerTests.swift +++ b/Tests/PlayolaPlayerTests/PlayolaStationPlayerTests.swift @@ -18,6 +18,18 @@ struct PlayolaStationPlayerTests { #expect(player.listeningSessionReporter?.baseURL == customBaseURL) } + @Test("Configure updates the player baseUrl") + func testConfigureUpdatesPlayerBaseURL() async throws { + let customBaseURL = URL(string: "http://localhost:6000/v1")! + let mockAuthProvider = MockAuthProvider() + let mockFileDownloadManager = MockFileDownloadManager() + let player = PlayolaStationPlayer(fileDownloadManager: mockFileDownloadManager) + + player.configure(authProvider: mockAuthProvider, baseURL: customBaseURL) + + #expect(player.baseUrl == customBaseURL) + } + @Test("Configure uses default production baseURL when not specified") func testConfigureUsesDefaultBaseURL() async throws { let mockAuthProvider = MockAuthProvider() From 657027ce8d26900d3d509ddb527cac98be136055 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Thu, 25 Sep 2025 12:38:39 -0500 Subject: [PATCH 02/17] fix /v1 missing from baseUrl interpolation --- Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 590979b..5e989b3 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -400,7 +400,7 @@ final public class PlayolaStationPlayer: ObservableObject { } private func createScheduleURL(for stationId: String) -> URL { - return baseUrl.appending(path: "/stations/\(stationId)/schedule") + return baseUrl.appending(path: "/v1/stations/\(stationId)/schedule") .appending(queryItems: [URLQueryItem(name: "includeRelatedTexts", value: "true")]) } From 694f34ddd301c1d16169560a415567c1b4194b5c Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 18 Nov 2025 14:33:11 -0600 Subject: [PATCH 03/17] add release date to stations --- Sources/PlayolaPlayer/Models/Station.swift | 32 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/PlayolaPlayer/Models/Station.swift b/Sources/PlayolaPlayer/Models/Station.swift index 340c0f2..c62f28e 100644 --- a/Sources/PlayolaPlayer/Models/Station.swift +++ b/Sources/PlayolaPlayer/Models/Station.swift @@ -13,14 +13,23 @@ public struct Station: Codable, Sendable { public let imageUrl: URL? public let description: String public let active: Bool? + public let releaseDate: Date? public let createdAt: Date public let updatedAt: Date // Custom coding keys to handle the imageUrl conversion private enum CodingKeys: String, CodingKey { - case id, name, curatorName, description, active, createdAt, updatedAt + case id, name, curatorName, description, active, releaseDate, createdAt, updatedAt case imageUrlString = "imageUrl" } + private static let releaseDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -30,6 +39,17 @@ public struct Station: Codable, Sendable { curatorName = try container.decode(String.self, forKey: .curatorName) description = try container.decode(String.self, forKey: .description) active = try container.decodeIfPresent(Bool.self, forKey: .active) + if let releaseDateString = try container.decodeIfPresent(String.self, forKey: .releaseDate) { + if let parsed = DateFormatter.iso8601Full.date(from: releaseDateString) { + releaseDate = parsed + } else if let dateOnly = Station.releaseDateFormatter.date(from: releaseDateString) { + releaseDate = dateOnly + } else { + releaseDate = nil + } + } else { + releaseDate = nil + } createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) @@ -49,6 +69,10 @@ public struct Station: Codable, Sendable { try container.encode(curatorName, forKey: .curatorName) try container.encode(description, forKey: .description) try container.encodeIfPresent(active, forKey: .active) + if let releaseDate { + let encodedDate = DateFormatter.iso8601Full.string(from: releaseDate) + try container.encode(encodedDate, forKey: .releaseDate) + } try container.encode(createdAt, forKey: .createdAt) try container.encode(updatedAt, forKey: .updatedAt) @@ -59,7 +83,7 @@ public struct Station: Codable, Sendable { // Original initializer updated to convert String to URL public init( id: String, name: String, curatorName: String, imageUrl: String?, description: String, - active: Bool? = nil, createdAt: Date, updatedAt: Date + active: Bool? = nil, releaseDate: Date? = nil, createdAt: Date, updatedAt: Date ) { self.id = id self.name = name @@ -67,6 +91,7 @@ public struct Station: Codable, Sendable { self.imageUrl = imageUrl != nil ? URL(string: imageUrl!) : nil self.description = description self.active = active + self.releaseDate = releaseDate self.createdAt = createdAt self.updatedAt = updatedAt } @@ -74,7 +99,7 @@ public struct Station: Codable, Sendable { // New convenience initializer that accepts URL directly public init( id: String, name: String, curatorName: String, imageUrl: URL?, description: String, - active: Bool? = nil, createdAt: Date, updatedAt: Date + active: Bool? = nil, releaseDate: Date? = nil, createdAt: Date, updatedAt: Date ) { self.id = id self.name = name @@ -82,6 +107,7 @@ public struct Station: Codable, Sendable { self.imageUrl = imageUrl self.description = description self.active = active + self.releaseDate = releaseDate self.createdAt = createdAt self.updatedAt = updatedAt } From d8df2a7022aa4e6d0567e25f622d7808ed9df09a Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Mon, 1 Dec 2025 12:58:02 -0600 Subject: [PATCH 04/17] add spinGroupId to spin --- Sources/PlayolaPlayer/Models/Spin.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/PlayolaPlayer/Models/Spin.swift b/Sources/PlayolaPlayer/Models/Spin.swift index 3559692..5a6bf4e 100644 --- a/Sources/PlayolaPlayer/Models/Spin.swift +++ b/Sources/PlayolaPlayer/Models/Spin.swift @@ -50,6 +50,9 @@ public struct Spin: Codable, Sendable { /// Volume transitions that should occur during playback public let fades: [Fade] + /// Optional group identifier for spins that should be grouped together + public let spinGroupId: String? + /// Related texts associated with this spin (e.g., DJ notes, song explanations) public let relatedTexts: [RelatedText]? @@ -65,6 +68,7 @@ public struct Spin: Codable, Sendable { updatedAt: Date, audioBlock: AudioBlock, fades: [Fade], + spinGroupId: String? = nil, relatedTexts: [RelatedText]? = nil, dateProvider: DateProviderProtocol? = nil ) { @@ -76,6 +80,7 @@ public struct Spin: Codable, Sendable { self.updatedAt = updatedAt self.audioBlock = audioBlock self.fades = fades.sorted { $0.atMS < $1.atMS } + self.spinGroupId = spinGroupId self.relatedTexts = relatedTexts self.dateProvider = dateProvider ?? DateProvider() } @@ -205,6 +210,7 @@ public struct Spin: Codable, Sendable { updatedAt: updatedAt, audioBlock: audioBlock, fades: fades, + spinGroupId: spinGroupId, relatedTexts: relatedTexts ) @@ -215,8 +221,8 @@ public struct Spin: Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case id, stationId, airtime, createdAt, updatedAt, audioBlock, fades, startingVolume, - relatedTexts + case id, stationId, airtime, createdAt, updatedAt, audioBlock, fades, spinGroupId, + startingVolume, relatedTexts } // Custom decoder to handle dateProvider @@ -232,6 +238,7 @@ public struct Spin: Codable, Sendable { audioBlock = try container.decode(AudioBlock.self, forKey: .audioBlock) let decodedFades = try container.decode([Fade].self, forKey: .fades) fades = decodedFades.sorted { $0.atMS < $1.atMS } + spinGroupId = try container.decodeIfPresent(String.self, forKey: .spinGroupId) relatedTexts = try container.decodeIfPresent([RelatedText].self, forKey: .relatedTexts) // Initialize dateProvider with default value @@ -250,6 +257,7 @@ public struct Spin: Codable, Sendable { try container.encode(updatedAt, forKey: .updatedAt) try container.encode(audioBlock, forKey: .audioBlock) try container.encode(fades, forKey: .fades) + try container.encodeIfPresent(spinGroupId, forKey: .spinGroupId) try container.encodeIfPresent(relatedTexts, forKey: .relatedTexts) } } @@ -270,6 +278,7 @@ extension Spin { /// - audioBlock: Optional override for audio block /// - startingVolume: Optional override for starting volume /// - fades: Optional override for fades + /// - spinGroupId: Optional override for spin group ID /// - createdAt: Optional override for created date /// - updatedAt: Optional override for updated date /// - relatedTexts: Optional override for related texts @@ -282,6 +291,7 @@ extension Spin { audioBlock: AudioBlock? = nil, startingVolume: Float? = nil, fades: [Fade]? = nil, + spinGroupId: String? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, relatedTexts: [RelatedText]? = nil, @@ -300,6 +310,7 @@ extension Spin { updatedAt: updatedAt ?? mockSpin.updatedAt, audioBlock: audioBlock ?? mockSpin.audioBlock, fades: fades ?? mockSpin.fades, + spinGroupId: spinGroupId ?? mockSpin.spinGroupId, relatedTexts: relatedTexts ?? mockSpin.relatedTexts ) From 7af7231c7bd96092e8749dce2e25033badfd9f4a Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Dec 2025 15:23:01 -0600 Subject: [PATCH 05/17] claude attempt at audio-return --- Sources/PlayolaPlayer/Models/Station.swift | 26 +++++ .../Player/PlayolaMainMixer.swift | 22 +++- .../Player/PlayolaStationPlayer.swift | 108 +++++++++++++----- 3 files changed, 126 insertions(+), 30 deletions(-) diff --git a/Sources/PlayolaPlayer/Models/Station.swift b/Sources/PlayolaPlayer/Models/Station.swift index c62f28e..ff12e6f 100644 --- a/Sources/PlayolaPlayer/Models/Station.swift +++ b/Sources/PlayolaPlayer/Models/Station.swift @@ -122,3 +122,29 @@ extension Station: Hashable, Equatable { return lhs.id == rhs.id } } + +extension Station { + public static func mockWith( + id: String = "mock-station-id", + name: String = "Mock Station", + curatorName: String = "Mock Curator", + imageUrl: URL? = nil, + description: String = "A mock station for testing", + active: Bool? = true, + releaseDate: Date? = nil, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) -> Station { + Station( + id: id, + name: name, + curatorName: curatorName, + imageUrl: imageUrl, + description: description, + active: active, + releaseDate: releaseDate, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 1ae00a7..10173ba 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -39,7 +39,7 @@ open class PlayolaMainMixer: NSObject { open var delegate: PlayolaMainMixerDelegate? private let errorReporter = PlayolaErrorReporter.shared - private let audioSessionManager: AudioSessionManager + let audioSessionManager: AudioSessionManager private static let logger = OSLog(subsystem: "fm.playola.playolaCore", category: "MainMixer") @@ -134,14 +134,12 @@ extension PlayolaMainMixer { "Audio engine start failed, retry %d of %d: %@", log: PlayolaMainMixer.logger, type: .error, retryCount, maxRetries, error.localizedDescription) - Thread.sleep(forTimeInterval: 0.1) // Short delay before retry + Thread.sleep(forTimeInterval: 0.1) } } } - // If we get here, all retries failed if let error = lastError { - Task { await errorReporter.reportError( error, context: "Failed to start audio engine after \(maxRetries) attempts", @@ -151,6 +149,22 @@ extension PlayolaMainMixer { } } + @MainActor + public func restartEngine() throws { + os_log("Restarting audio engine", log: PlayolaMainMixer.logger, type: .info) + + if engine.isRunning { + engine.stop() + } + + engine.prepare() + try start() + } + + public var isEngineRunning: Bool { + return engine.isRunning + } + public func attach(_ node: AVAudioPlayerNode) { engine.attach(node) } diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 5e989b3..6c54063 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -59,7 +59,7 @@ public enum StationPlayerError: Error, LocalizedError { @MainActor final public class PlayolaStationPlayer: ObservableObject { var baseUrl = URL(string: "https://admin-api.playola.fm/v1")! - @Published public var stationId: String? // TODO: Change this to Station model + @Published public var stationId: String? private var interruptedStationId: String? var currentSchedule: Schedule? let fileDownloadManager: FileDownloadManaging @@ -68,16 +68,17 @@ final public class PlayolaStationPlayer: ObservableObject { private var authProvider: PlayolaAuthenticationProvider? /// Time offset for playing station from a different point in time - /// Negative values play from the past, positive values play from the future private var scheduleOffset: TimeInterval? - // Track active download IDs for potential cancellation private var activeDownloadIds: [String: UUID] = [:] - // Track active async tasks for proper cancellation private var schedulingTask: Task? private var playTask: Task? + // Audio interruption state + private var isSuspended = false + private var wasPlayingBeforeInterruption = false + public weak var delegate: PlayolaStationPlayerDelegate? // Thread-safe access to spin players - all access must be on main actor @@ -148,8 +149,18 @@ final public class PlayolaStationPlayer: ObservableObject { ) NotificationCenter.default.addObserver( - self, selector: #selector(handleAudioRouteChange(_:)), - name: AVAudioSession.routeChangeNotification, object: nil) + self, + selector: #selector(handleAudioRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioEngineConfigurationChange(_:)), + name: .AVAudioEngineConfigurationChange, + object: PlayolaMainMixer.shared.engine + ) #endif } @@ -615,7 +626,6 @@ final public class PlayolaStationPlayer: ObservableObject { } #if os(iOS) - /// Handle audio route changes such as connecting/disconnecting headphones @objc public func handleAudioRouteChange(_ notification: Notification) { guard let userInfo = notification.userInfo, let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, @@ -624,26 +634,37 @@ final public class PlayolaStationPlayer: ObservableObject { return } - // Check if the audio route changed switch reason { case .newDeviceAvailable: - // New device (like headphones) was connected os_log("New audio route device available", log: PlayolaStationPlayer.logger, type: .info) case .oldDeviceUnavailable: - // Old device (like headphones) was disconnected - // You might want to pause playback here os_log("Audio route device disconnected", log: PlayolaStationPlayer.logger, type: .info) + guard + let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] + as? AVAudioSessionRouteDescription + else { return } + + let wasUsingHeadphones = previousRoute.outputs.contains { + [.headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE].contains($0.portType) + } + + if wasUsingHeadphones && isPlaying { + os_log( + "Headphones disconnected while playing - pausing", log: PlayolaStationPlayer.logger, + type: .info) + interruptedStationId = stationId + wasPlayingBeforeInterruption = true + stop() + } default: - // Handle other route changes if needed os_log( "Audio route changed for reason: %d", log: PlayolaStationPlayer.logger, type: .info, reasonValue) } } - /// Handle audio session interruptions such as phone calls @objc public func handleAudioSessionInterruption(_ notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -654,26 +675,23 @@ final public class PlayolaStationPlayer: ObservableObject { switch type { case .began: - // Audio session was interrupted - might need to pause playback - os_log("Audio session interrupted", log: PlayolaStationPlayer.logger, type: .info) - self.interruptedStationId = stationId - stop() + os_log( + "Audio session interrupted - suspending", log: PlayolaStationPlayer.logger, type: .info) + isSuspended = true + wasPlayingBeforeInterruption = isPlaying + interruptedStationId = stationId case .ended: - // Interruption ended - might need to resume playback + os_log("Audio session interruption ended", log: PlayolaStationPlayer.logger, type: .info) + isSuspended = false + guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - if options.contains(.shouldResume) { - // The system indicates that we can resume audio - if let interruptedStationId { - Task { @MainActor [interruptedStationId] in - try? await self.play(stationId: interruptedStationId) - } - self.interruptedStationId = nil - } + if options.contains(.shouldResume) && wasPlayingBeforeInterruption { + resumeAfterInterruption() } @unknown default: @@ -682,6 +700,44 @@ final public class PlayolaStationPlayer: ObservableObject { type: .error, typeValue) } } + + @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { + os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) + + guard !isSuspended else { + os_log( + "Ignoring config change while suspended", log: PlayolaStationPlayer.logger, type: .info) + return + } + + if wasPlayingBeforeInterruption { + resumeAfterInterruption() + } + } + + private func resumeAfterInterruption() { + guard let stationToResume = interruptedStationId else { return } + + os_log("Resuming playback after interruption", log: PlayolaStationPlayer.logger, type: .info) + + Task { @MainActor in + do { + try await PlayolaMainMixer.shared.audioSessionManager.activate() + try PlayolaMainMixer.shared.restartEngine() + try await self.play(stationId: stationToResume) + } catch { + os_log( + "Failed to resume after interruption: %@", + log: PlayolaStationPlayer.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 deinit { From ba4d813ff82ee27e43db3887eac9c73052143b45 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Mon, 15 Dec 2025 13:37:53 -0600 Subject: [PATCH 06/17] limit to lockedIn spins. Increase polling frequency to 20 secs --- .../Player/PlayolaStationPlayer.swift | 100 ++++++++++-------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 6c54063..77de0ab 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -328,6 +328,8 @@ final public class PlayolaStationPlayer: ObservableObject { return _spinPlayers.contains { $0.spin?.id == spin.id } } + private let schedulePollingInterval: UInt64 = 20_000_000_000 // 20 seconds in nanoseconds + @MainActor private func scheduleUpcomingSpins() async { guard let stationId else { @@ -338,59 +340,62 @@ final public class PlayolaStationPlayer: ObservableObject { return } - do { - // Check if task is cancelled before proceeding - try Task.checkCancellation() - - let updatedSchedule = try await getUpdatedSchedule(stationId: stationId) - - // Log how many spins are in the updated schedule - os_log( - "Retrieved schedule: %d total, %d current", log: PlayolaStationPlayer.logger, type: .info, - updatedSchedule.spins.count, - updatedSchedule.current(offsetTimeInterval: scheduleOffset).count) + while !Task.isCancelled { + do { + let updatedSchedule = try await getUpdatedSchedule(stationId: stationId) - // Extend the time window to load more upcoming spins (10 minutes instead of 6) - let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { - $0.airtime < .now + TimeInterval(600) - } + os_log( + "Retrieved schedule: %d total, %d current", log: PlayolaStationPlayer.logger, type: .info, + updatedSchedule.spins.count, + updatedSchedule.current(offsetTimeInterval: scheduleOffset).count) + + // Server returns only locked-in spins via lockedIn=true param. + // This filter is a client-side safety net to prevent scheduling spins too far out. + let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { + $0.airtime < .now + TimeInterval(600) + } - os_log( - "Loading %d upcoming spins", log: PlayolaStationPlayer.logger, type: .info, - spinsToLoad.count) + os_log( + "Loading %d upcoming spins", log: PlayolaStationPlayer.logger, type: .info, + spinsToLoad.count) + + for spin in spinsToLoad { + try Task.checkCancellation() + + if !isScheduled(spin: spin) { + os_log( + "Scheduling new spin: %@ at %@", log: PlayolaStationPlayer.logger, type: .info, + spin.id, + ISO8601DateFormatter().string(from: spin.airtime)) + try await scheduleSpin(spin: spin) + } + } - for spin in spinsToLoad { - // Check cancellation before each spin - try Task.checkCancellation() + let scheduledSpinsCount = _spinPlayers.filter { $0.spin != nil }.count + os_log( + "Total scheduled spins: %d", log: PlayolaStationPlayer.logger, type: .info, + scheduledSpinsCount) - if !isScheduled(spin: spin) { + } catch { + if error is CancellationError { + os_log("📛 Schedule update cancelled", log: PlayolaStationPlayer.logger, type: .info) + return + } else { + Task { + await errorReporter.reportError( + error, context: "Failed to schedule upcoming spins", level: .error) + } os_log( - "Scheduling new spin: %@ at %@", log: PlayolaStationPlayer.logger, type: .info, spin.id, - ISO8601DateFormatter().string(from: spin.airtime)) - try await scheduleSpin(spin: spin) + "Schedule update failed: %@", log: PlayolaStationPlayer.logger, type: .error, + error.localizedDescription) } } - // Log already scheduled spins - let scheduledSpinsCount = _spinPlayers.filter { $0.spin != nil }.count - os_log( - "Total scheduled spins: %d", log: PlayolaStationPlayer.logger, type: .info, - scheduledSpinsCount) - - } catch { - // Check if this was a cancellation - if error is CancellationError { - os_log("📛 Schedule update cancelled", log: PlayolaStationPlayer.logger, type: .info) - } else { - Task { - await errorReporter.reportError( - error, context: "Failed to schedule upcoming spins", level: .error) - } - - // Log more details about the error - os_log( - "Schedule update failed: %@", log: PlayolaStationPlayer.logger, type: .error, - error.localizedDescription) + // Wait before next poll + do { + try await Task.sleep(nanoseconds: schedulePollingInterval) + } catch { + return // Task was cancelled during sleep } } } @@ -412,7 +417,10 @@ final public class PlayolaStationPlayer: ObservableObject { private func createScheduleURL(for stationId: String) -> URL { return baseUrl.appending(path: "/v1/stations/\(stationId)/schedule") - .appending(queryItems: [URLQueryItem(name: "includeRelatedTexts", value: "true")]) + .appending(queryItems: [ + URLQueryItem(name: "includeRelatedTexts", value: "true"), + URLQueryItem(name: "lockedIn", value: "true"), + ]) } private func validateHTTPResponse(_ response: URLResponse, url: URL) throws -> HTTPURLResponse { From f1967239d44513eff210772f5b117766250917db Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Mon, 15 Dec 2025 13:40:27 -0600 Subject: [PATCH 07/17] appease linter --- .../Player/PlayolaStationPlayer.swift | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 77de0ab..4f180b2 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -334,72 +334,69 @@ final public class PlayolaStationPlayer: ObservableObject { private func scheduleUpcomingSpins() async { guard let stationId else { let error = StationPlayerError.invalidStationId("No station ID available") - Task { - await errorReporter.reportError(error, level: .warning) - } + Task { await errorReporter.reportError(error, level: .warning) } return } while !Task.isCancelled { do { - let updatedSchedule = try await getUpdatedSchedule(stationId: stationId) - - os_log( - "Retrieved schedule: %d total, %d current", log: PlayolaStationPlayer.logger, type: .info, - updatedSchedule.spins.count, - updatedSchedule.current(offsetTimeInterval: scheduleOffset).count) - - // Server returns only locked-in spins via lockedIn=true param. - // This filter is a client-side safety net to prevent scheduling spins too far out. - let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { - $0.airtime < .now + TimeInterval(600) - } - - os_log( - "Loading %d upcoming spins", log: PlayolaStationPlayer.logger, type: .info, - spinsToLoad.count) - - for spin in spinsToLoad { - try Task.checkCancellation() - - if !isScheduled(spin: spin) { - os_log( - "Scheduling new spin: %@ at %@", log: PlayolaStationPlayer.logger, type: .info, - spin.id, - ISO8601DateFormatter().string(from: spin.airtime)) - try await scheduleSpin(spin: spin) - } - } - - let scheduledSpinsCount = _spinPlayers.filter { $0.spin != nil }.count - os_log( - "Total scheduled spins: %d", log: PlayolaStationPlayer.logger, type: .info, - scheduledSpinsCount) - + try await performScheduleUpdate(stationId: stationId) + } catch is CancellationError { + os_log("📛 Schedule update cancelled", log: PlayolaStationPlayer.logger, type: .info) + return } catch { - if error is CancellationError { - os_log("📛 Schedule update cancelled", log: PlayolaStationPlayer.logger, type: .info) - return - } else { - Task { - await errorReporter.reportError( - error, context: "Failed to schedule upcoming spins", level: .error) - } - os_log( - "Schedule update failed: %@", log: PlayolaStationPlayer.logger, type: .error, - error.localizedDescription) + Task { + await errorReporter.reportError( + error, context: "Failed to schedule upcoming spins", level: .error) } + os_log( + "Schedule update failed: %@", log: PlayolaStationPlayer.logger, type: .error, + error.localizedDescription) } - // Wait before next poll do { try await Task.sleep(nanoseconds: schedulePollingInterval) } catch { - return // Task was cancelled during sleep + return } } } + @MainActor + private func performScheduleUpdate(stationId: String) async throws { + let updatedSchedule = try await getUpdatedSchedule(stationId: stationId) + + os_log( + "Retrieved schedule: %d total, %d current", log: PlayolaStationPlayer.logger, type: .info, + updatedSchedule.spins.count, + updatedSchedule.current(offsetTimeInterval: scheduleOffset).count) + + // Server returns only locked-in spins via lockedIn=true param. + // This filter is a client-side safety net to prevent scheduling spins too far out. + let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { + $0.airtime < .now + TimeInterval(600) + } + + os_log( + "Loading %d upcoming spins", log: PlayolaStationPlayer.logger, type: .info, + spinsToLoad.count) + + for spin in spinsToLoad { + try Task.checkCancellation() + if !isScheduled(spin: spin) { + os_log( + "Scheduling new spin: %@ at %@", log: PlayolaStationPlayer.logger, type: .info, + spin.id, ISO8601DateFormatter().string(from: spin.airtime)) + try await scheduleSpin(spin: spin) + } + } + + let scheduledSpinsCount = _spinPlayers.filter { $0.spin != nil }.count + os_log( + "Total scheduled spins: %d", log: PlayolaStationPlayer.logger, type: .info, + scheduledSpinsCount) + } + private func getUpdatedSchedule(stationId: String) async throws -> Schedule { let url = createScheduleURL(for: stationId) From d979a75758cc912b90413cc904d46a1c0c21c502 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Mon, 15 Dec 2025 13:42:12 -0600 Subject: [PATCH 08/17] update fastlane --- Gemfile.lock | 79 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 454bf9f..62eec63 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,37 +1,40 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.1043.0) - aws-sdk-core (3.217.0) + aws-eventstream (1.4.0) + aws-partitions (1.1194.0) + aws-sdk-core (3.239.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.97.0) - aws-sdk-core (~> 3, >= 3.216.0) + logger + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.178.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.206.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) + bigdecimal (3.3.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) @@ -51,14 +54,14 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -68,15 +71,18 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.226.0) + fastlane (2.229.1) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) bundler (>= 1.12.0, < 3.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -96,7 +102,9 @@ GEM jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) @@ -108,7 +116,7 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.4.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.0.0) sysrandom (~> 1.0) @@ -129,12 +137,12 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) + google-cloud-errors (1.5.0) google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -152,37 +160,40 @@ GEM highline (2.0.3) http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.9.1) - jwt (2.10.1) + json (2.18.0) + jwt (2.10.2) base64 + logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.18.0) multipart-post (2.4.1) + mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) + naturally (2.3.0) nkf (0.2.0) - optparse (0.6.0) + optparse (0.8.1) os (1.1.4) plist (3.7.2) - public_suffix (6.0.1) - rake (13.2.1) + public_suffix (7.0.0) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.4.0) + rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.19.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList @@ -206,7 +217,7 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.4.0) + xcpretty (0.4.1) rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) From db8b838aca01867ed99a126ad4d5c91622aef9db Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Sat, 20 Dec 2025 15:12:49 -0600 Subject: [PATCH 09/17] deadlock crash fix --- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 07ff570..6fc993b 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -279,22 +279,8 @@ public class SpinPlayer { } private func stopAudio() { - if !engine.isRunning { - do { - // Make sure audio session is configured before starting engine - playolaMainMixer.configureAudioSession() - try engine.start() - } catch { - Task { - await errorReporter.reportError( - error, - context: "Failed to start engine during stop operation", - level: .error - ) - } - return - } - } + // Only stop if the engine is running - no need to start it just to stop + guard engine.isRunning else { return } playerNode.stop() playerNode.reset() } @@ -673,15 +659,33 @@ public class SpinPlayer { // Require some energy to avoid preroll silence guard bufferHasEnergy(buffer) else { return } + // Only capture minimal data in the tap callback - no heavy work here! + // Calling removeTap or doing logging/parameter scheduling from within + // the tap callback can deadlock with playerNode.stop() on the main thread. didCaptureStart = true scheduledStartSample = AUEventSampleTime(time.sampleTime) + let hostTime = time.hostTime - // Remove the tap immediately (one-shot) - trackMixer.removeTap(onBus: 0) - startTapInstalled = false + // Dispatch all heavy work to main thread to avoid deadlock + DispatchQueue.main.async { [weak self] in + self?.finishStartCaptureOnMain(hostTime: hostTime, pendingFades: pendingFades) + } + } + + /// Complete the start capture on main thread where it's safe to remove taps and schedule fades. + @MainActor + private func finishStartCaptureOnMain( + hostTime: UInt64, + pendingFades: [(offset: Double, to: Float)] + ) { + // Remove the tap (safe to do on main thread, not inside the tap callback) + if startTapInstalled { + trackMixer.removeTap(onBus: 0) + startTapInstalled = false + } - // Ensure a known baseline on the exact host time of first render (user-space only) - setInitialVolume(at: time.hostTime) + // Ensure a known baseline on the exact host time of first render + setInitialVolume(at: hostTime) // Schedule any fades that were waiting for start capture scheduleFadesAtStartIfNeeded(pendingFades) @@ -1134,7 +1138,7 @@ public class SpinPlayer { // Clear the timer reference since it's now invalid self.clearTimer = nil - self.stopAudio() + // clear() handles stopAudio() internally, no need to call it twice self.clear() } } From d1aca07add19a4010f06ef60cab850ce7c24d887 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 30 Dec 2025 13:32:02 -0600 Subject: [PATCH 10/17] fix Carplay --- Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 4f180b2..a388cdc 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -536,6 +536,12 @@ final public class PlayolaStationPlayer: ObservableObject { /// - Missing audio content in the schedule /// - File download failures public func play(stationId: String, atDate: Date? = nil) async throws { + // Reset any stale interruption state when explicitly starting playback. + // This ensures CarPlay and other external callers always get a clean start. + isSuspended = false + wasPlayingBeforeInterruption = false + interruptedStationId = nil + // Cancel any existing play task playTask?.cancel() From 1b7e97166b75ad10367ed27402d54995a484e62d Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 30 Dec 2025 15:29:42 -0600 Subject: [PATCH 11/17] stop grabbing the audio back from interruptors --- Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index a388cdc..8610f64 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -692,6 +692,10 @@ final public class PlayolaStationPlayer: ObservableObject { wasPlayingBeforeInterruption = isPlaying interruptedStationId = stationId + // Cancel scheduling to prevent grabbing audio back from other apps + schedulingTask?.cancel() + schedulingTask = nil + case .ended: os_log("Audio session interruption ended", log: PlayolaStationPlayer.logger, type: .info) isSuspended = false From a14bab431f5e0d5ac48ab4b1d32d37a4b143be25 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 7 Jan 2026 17:41:53 -0600 Subject: [PATCH 12/17] add new show structure to models --- Sources/PlayolaPlayer/Models/Airing.swift | 72 +++++++++++++++++++ Sources/PlayolaPlayer/Models/Episode.swift | 72 +++++++++++++++++++ .../PlayolaPlayer/Models/ScheduledShow.swift | 72 +++++++++++++++++++ Sources/PlayolaPlayer/Models/Show.swift | 72 +++++++++++++++++++ Sources/PlayolaPlayer/Models/Spin.swift | 17 ++++- 5 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 Sources/PlayolaPlayer/Models/Airing.swift create mode 100644 Sources/PlayolaPlayer/Models/Episode.swift create mode 100644 Sources/PlayolaPlayer/Models/ScheduledShow.swift create mode 100644 Sources/PlayolaPlayer/Models/Show.swift diff --git a/Sources/PlayolaPlayer/Models/Airing.swift b/Sources/PlayolaPlayer/Models/Airing.swift new file mode 100644 index 0000000..bfe2d32 --- /dev/null +++ b/Sources/PlayolaPlayer/Models/Airing.swift @@ -0,0 +1,72 @@ +// +// Airing.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 1/7/26. +// + +import Foundation + +/// Represents a scheduled airing of an episode +public struct Airing: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let episodeId: String + public let stationId: String + public let airtime: Date + public let createdAt: Date + public let updatedAt: Date + public let episode: Episode? + + public init( + id: String, + episodeId: String, + stationId: String, + airtime: Date, + createdAt: Date, + updatedAt: Date, + episode: Episode? = nil + ) { + self.id = id + self.episodeId = episodeId + self.stationId = stationId + self.airtime = airtime + self.createdAt = createdAt + self.updatedAt = updatedAt + self.episode = episode + } +} + +extension Airing { + public static var mock: Airing { + Airing( + id: "mock-airing-id", + episodeId: "mock-episode-id", + stationId: "mock-station-id", + airtime: Date(timeIntervalSince1970: 1_800_000_000), + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + episode: .mock + ) + } + + public static func mockWith( + id: String? = nil, + episodeId: String? = nil, + stationId: String? = nil, + airtime: Date? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + episode: Episode?? = nil + ) -> Airing { + let mock = Self.mock + return Airing( + id: id ?? mock.id, + episodeId: episodeId ?? mock.episodeId, + stationId: stationId ?? mock.stationId, + airtime: airtime ?? mock.airtime, + createdAt: createdAt ?? mock.createdAt, + updatedAt: updatedAt ?? mock.updatedAt, + episode: episode ?? mock.episode + ) + } +} diff --git a/Sources/PlayolaPlayer/Models/Episode.swift b/Sources/PlayolaPlayer/Models/Episode.swift new file mode 100644 index 0000000..c98f827 --- /dev/null +++ b/Sources/PlayolaPlayer/Models/Episode.swift @@ -0,0 +1,72 @@ +// +// Episode.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 1/7/26. +// + +import Foundation + +/// Represents an episode of a show +public struct Episode: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let showId: String + public let title: String + public let durationMS: Int? + public let createdAt: Date + public let updatedAt: Date + public let show: Show? + + public init( + id: String, + showId: String, + title: String, + durationMS: Int? = nil, + createdAt: Date, + updatedAt: Date, + show: Show? = nil + ) { + self.id = id + self.showId = showId + self.title = title + self.durationMS = durationMS + self.createdAt = createdAt + self.updatedAt = updatedAt + self.show = show + } +} + +extension Episode { + public static var mock: Episode { + Episode( + id: "mock-episode-id", + showId: "mock-show-id", + title: "Mock Episode Title", + durationMS: 1000 * 60 * 30, + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + show: .mock + ) + } + + public static func mockWith( + id: String? = nil, + showId: String? = nil, + title: String? = nil, + durationMS: Int?? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + show: Show?? = nil + ) -> Episode { + let mock = Self.mock + return Episode( + id: id ?? mock.id, + showId: showId ?? mock.showId, + title: title ?? mock.title, + durationMS: durationMS ?? mock.durationMS, + createdAt: createdAt ?? mock.createdAt, + updatedAt: updatedAt ?? mock.updatedAt, + show: show ?? mock.show + ) + } +} diff --git a/Sources/PlayolaPlayer/Models/ScheduledShow.swift b/Sources/PlayolaPlayer/Models/ScheduledShow.swift new file mode 100644 index 0000000..108e12d --- /dev/null +++ b/Sources/PlayolaPlayer/Models/ScheduledShow.swift @@ -0,0 +1,72 @@ +// +// ScheduledShow.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 1/6/26. +// + +import Foundation + +/// Represents a scheduled instance of a show +public struct ScheduledShow: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let showId: String + public let stationId: String + public let airtime: Date + public let createdAt: Date + public let updatedAt: Date + public let show: Show? + + public init( + id: String, + showId: String, + stationId: String, + airtime: Date, + createdAt: Date, + updatedAt: Date, + show: Show? + ) { + self.id = id + self.showId = showId + self.stationId = stationId + self.airtime = airtime + self.createdAt = createdAt + self.updatedAt = updatedAt + self.show = show + } +} + +extension ScheduledShow { + public static var mock: ScheduledShow { + ScheduledShow( + id: "mock-scheduled-show-id", + showId: "mock-show-id", + stationId: "mock-station-id", + airtime: Date(timeIntervalSince1970: 1_800_000_000), + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + show: .mock + ) + } + + public static func mockWith( + id: String? = nil, + showId: String? = nil, + stationId: String? = nil, + airtime: Date? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil, + show: Show? = nil + ) -> ScheduledShow { + let mock = Self.mock + return ScheduledShow( + id: id ?? mock.id, + showId: showId ?? mock.showId, + stationId: stationId ?? mock.stationId, + airtime: airtime ?? mock.airtime, + createdAt: createdAt ?? mock.createdAt, + updatedAt: updatedAt ?? mock.updatedAt, + show: show ?? mock.show + ) + } +} diff --git a/Sources/PlayolaPlayer/Models/Show.swift b/Sources/PlayolaPlayer/Models/Show.swift new file mode 100644 index 0000000..de8e1fb --- /dev/null +++ b/Sources/PlayolaPlayer/Models/Show.swift @@ -0,0 +1,72 @@ +// +// Show.swift +// PlayolaPlayer +// +// Created by Brian D Keane on 1/6/26. +// + +import Foundation + +/// Represents a radio show +public struct Show: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let stationId: String + public let title: String + public let rrule: String? + public let durationMS: Int? + public let createdAt: Date + public let updatedAt: Date + + public init( + id: String, + stationId: String, + title: String, + rrule: String? = nil, + durationMS: Int? = nil, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.stationId = stationId + self.title = title + self.rrule = rrule + self.durationMS = durationMS + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension Show { + public static var mock: Show { + Show( + id: "mock-show-id", + stationId: "mock-station-id", + title: "Mock Show Title", + rrule: nil, + durationMS: 1000 * 60 * 30, + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + } + + public static func mockWith( + id: String? = nil, + stationId: String? = nil, + title: String? = nil, + rrule: String?? = nil, + durationMS: Int?? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) -> Show { + let mock = Self.mock + return Show( + id: id ?? mock.id, + stationId: stationId ?? mock.stationId, + title: title ?? mock.title, + rrule: rrule ?? mock.rrule, + durationMS: durationMS ?? mock.durationMS, + createdAt: createdAt ?? mock.createdAt, + updatedAt: updatedAt ?? mock.updatedAt + ) + } +} diff --git a/Sources/PlayolaPlayer/Models/Spin.swift b/Sources/PlayolaPlayer/Models/Spin.swift index 5a6bf4e..afc4b62 100644 --- a/Sources/PlayolaPlayer/Models/Spin.swift +++ b/Sources/PlayolaPlayer/Models/Spin.swift @@ -56,6 +56,9 @@ public struct Spin: Codable, Sendable { /// Related texts associated with this spin (e.g., DJ notes, song explanations) public let relatedTexts: [RelatedText]? + /// The airing this spin belongs to (if any) + public let airing: Airing? + /// Date provider for testing time-dependent behavior public var dateProvider: DateProviderProtocol! = DateProvider() @@ -70,6 +73,7 @@ public struct Spin: Codable, Sendable { fades: [Fade], spinGroupId: String? = nil, relatedTexts: [RelatedText]? = nil, + airing: Airing? = nil, dateProvider: DateProviderProtocol? = nil ) { self.id = id @@ -82,6 +86,7 @@ public struct Spin: Codable, Sendable { self.fades = fades.sorted { $0.atMS < $1.atMS } self.spinGroupId = spinGroupId self.relatedTexts = relatedTexts + self.airing = airing self.dateProvider = dateProvider ?? DateProvider() } @@ -211,7 +216,8 @@ public struct Spin: Codable, Sendable { audioBlock: audioBlock, fades: fades, spinGroupId: spinGroupId, - relatedTexts: relatedTexts + relatedTexts: relatedTexts, + airing: airing ) // Preserve the dateProvider @@ -222,7 +228,7 @@ public struct Spin: Codable, Sendable { private enum CodingKeys: String, CodingKey { case id, stationId, airtime, createdAt, updatedAt, audioBlock, fades, spinGroupId, - startingVolume, relatedTexts + startingVolume, relatedTexts, airing } // Custom decoder to handle dateProvider @@ -240,6 +246,7 @@ public struct Spin: Codable, Sendable { fades = decodedFades.sorted { $0.atMS < $1.atMS } spinGroupId = try container.decodeIfPresent(String.self, forKey: .spinGroupId) relatedTexts = try container.decodeIfPresent([RelatedText].self, forKey: .relatedTexts) + airing = try container.decodeIfPresent(Airing.self, forKey: .airing) // Initialize dateProvider with default value dateProvider = DateProvider() @@ -259,6 +266,7 @@ public struct Spin: Codable, Sendable { try container.encode(fades, forKey: .fades) try container.encodeIfPresent(spinGroupId, forKey: .spinGroupId) try container.encodeIfPresent(relatedTexts, forKey: .relatedTexts) + try container.encodeIfPresent(airing, forKey: .airing) } } @@ -282,6 +290,7 @@ extension Spin { /// - createdAt: Optional override for created date /// - updatedAt: Optional override for updated date /// - relatedTexts: Optional override for related texts + /// - airing: Optional override for airing /// - dateProvider: Optional override for date provider /// - Returns: A mock Spin with specified overrides public static func mockWith( @@ -295,6 +304,7 @@ extension Spin { createdAt: Date? = nil, updatedAt: Date? = nil, relatedTexts: [RelatedText]? = nil, + airing: Airing?? = nil, dateProvider: DateProviderProtocol? = nil ) -> Spin { // Start with the default mock @@ -311,7 +321,8 @@ extension Spin { audioBlock: audioBlock ?? mockSpin.audioBlock, fades: fades ?? mockSpin.fades, spinGroupId: spinGroupId ?? mockSpin.spinGroupId, - relatedTexts: relatedTexts ?? mockSpin.relatedTexts + relatedTexts: relatedTexts ?? mockSpin.relatedTexts, + airing: airing ?? mockSpin.airing ) // Set date provider if specified From 0b1250ccbe52dddadfde80f0c0ed050fc9c00254 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 7 Jan 2026 17:49:52 -0600 Subject: [PATCH 13/17] remove scheduled show --- .../PlayolaPlayer/Models/ScheduledShow.swift | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 Sources/PlayolaPlayer/Models/ScheduledShow.swift diff --git a/Sources/PlayolaPlayer/Models/ScheduledShow.swift b/Sources/PlayolaPlayer/Models/ScheduledShow.swift deleted file mode 100644 index 108e12d..0000000 --- a/Sources/PlayolaPlayer/Models/ScheduledShow.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// ScheduledShow.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 1/6/26. -// - -import Foundation - -/// Represents a scheduled instance of a show -public struct ScheduledShow: Codable, Sendable, Equatable, Hashable, Identifiable { - public let id: String - public let showId: String - public let stationId: String - public let airtime: Date - public let createdAt: Date - public let updatedAt: Date - public let show: Show? - - public init( - id: String, - showId: String, - stationId: String, - airtime: Date, - createdAt: Date, - updatedAt: Date, - show: Show? - ) { - self.id = id - self.showId = showId - self.stationId = stationId - self.airtime = airtime - self.createdAt = createdAt - self.updatedAt = updatedAt - self.show = show - } -} - -extension ScheduledShow { - public static var mock: ScheduledShow { - ScheduledShow( - id: "mock-scheduled-show-id", - showId: "mock-show-id", - stationId: "mock-station-id", - airtime: Date(timeIntervalSince1970: 1_800_000_000), - createdAt: Date(timeIntervalSince1970: 1_800_000_000), - updatedAt: Date(timeIntervalSince1970: 1_800_000_000), - show: .mock - ) - } - - public static func mockWith( - id: String? = nil, - showId: String? = nil, - stationId: String? = nil, - airtime: Date? = nil, - createdAt: Date? = nil, - updatedAt: Date? = nil, - show: Show? = nil - ) -> ScheduledShow { - let mock = Self.mock - return ScheduledShow( - id: id ?? mock.id, - showId: showId ?? mock.showId, - stationId: stationId ?? mock.stationId, - airtime: airtime ?? mock.airtime, - createdAt: createdAt ?? mock.createdAt, - updatedAt: updatedAt ?? mock.updatedAt, - show: show ?? mock.show - ) - } -} From 55d99c438d68702cd26b7574f4b9515828a431e3 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Thu, 8 Jan 2026 17:36:07 -0600 Subject: [PATCH 14/17] add station to airing model --- Sources/PlayolaPlayer/Models/Airing.swift | 14 ++++++++++---- Sources/PlayolaPlayer/Models/Station.swift | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/PlayolaPlayer/Models/Airing.swift b/Sources/PlayolaPlayer/Models/Airing.swift index bfe2d32..c003019 100644 --- a/Sources/PlayolaPlayer/Models/Airing.swift +++ b/Sources/PlayolaPlayer/Models/Airing.swift @@ -16,6 +16,7 @@ public struct Airing: Codable, Sendable, Equatable, Hashable, Identifiable { public let createdAt: Date public let updatedAt: Date public let episode: Episode? + public let station: Station? public init( id: String, @@ -24,7 +25,8 @@ public struct Airing: Codable, Sendable, Equatable, Hashable, Identifiable { airtime: Date, createdAt: Date, updatedAt: Date, - episode: Episode? = nil + episode: Episode? = nil, + station: Station? = nil ) { self.id = id self.episodeId = episodeId @@ -33,6 +35,7 @@ public struct Airing: Codable, Sendable, Equatable, Hashable, Identifiable { self.createdAt = createdAt self.updatedAt = updatedAt self.episode = episode + self.station = station } } @@ -45,7 +48,8 @@ extension Airing { airtime: Date(timeIntervalSince1970: 1_800_000_000), createdAt: Date(timeIntervalSince1970: 1_800_000_000), updatedAt: Date(timeIntervalSince1970: 1_800_000_000), - episode: .mock + episode: .mock, + station: .mock ) } @@ -56,7 +60,8 @@ extension Airing { airtime: Date? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, - episode: Episode?? = nil + episode: Episode?? = nil, + station: Station?? = nil ) -> Airing { let mock = Self.mock return Airing( @@ -66,7 +71,8 @@ extension Airing { airtime: airtime ?? mock.airtime, createdAt: createdAt ?? mock.createdAt, updatedAt: updatedAt ?? mock.updatedAt, - episode: episode ?? mock.episode + episode: episode ?? mock.episode, + station: station ?? mock.station ) } } diff --git a/Sources/PlayolaPlayer/Models/Station.swift b/Sources/PlayolaPlayer/Models/Station.swift index ff12e6f..f508d66 100644 --- a/Sources/PlayolaPlayer/Models/Station.swift +++ b/Sources/PlayolaPlayer/Models/Station.swift @@ -124,6 +124,20 @@ extension Station: Hashable, Equatable { } extension Station { + public static var mock: Station { + Station( + id: "mock-station-id", + name: "Mock Station", + curatorName: "Mock Curator", + imageUrl: nil as URL?, + description: "A mock station for testing", + active: true, + releaseDate: nil, + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + } + public static func mockWith( id: String = "mock-station-id", name: String = "Mock Station", From bec61de3c3fe0c5ea9cd44b8bd0c02746dc03daa Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 21 Jan 2026 20:27:24 -0600 Subject: [PATCH 15/17] remove strong reference cycle to ListeningSessionReporter --- Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index 4d572cd..b28cabd 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -74,7 +74,8 @@ public class ListeningSessionReporter { self.urlSession = urlSession self.baseURL = baseURL - stationPlayer.$stationId.sink { stationId in + stationPlayer.$stationId.sink { [weak self] stationId in + guard let self else { return } if let stationId { Task { do { From 90a1c0edb40d6a33aa892090302c8e3be5bec587 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 21 Jan 2026 20:29:23 -0600 Subject: [PATCH 16/17] update circleci --- .circleci/config.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f3e031b..ff2c0d2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,8 @@ orbs: jobs: swiftlint_check: macos: - xcode: 16.2.0 + xcode: 16.4 + resource_class: m4pro.medium steps: - checkout - run: @@ -27,7 +28,8 @@ jobs: test: macos: - xcode: 16.2.0 + xcode: 16.4 + resource_class: m4pro.medium environment: FASTLANE_SKIP_UPDATE_CHECK: true FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 10 From 572c4295515a1b0c36e8a7298d8338e14b31af1c Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Fri, 13 Feb 2026 20:50:31 -0600 Subject: [PATCH 17/17] Extract models to PlayolaCore --- Package.swift | 23 +++++++++++++++---- .../PlayolaAuthenticationProvider.swift | 0 .../ErrorHandling/PlayolaErrorReporter.swift | 0 .../DateFormatter+iso8601Full.swift | 0 .../JSONDecoderWithIsoFull.swift | 0 .../MockData/MockSchedule.json | 0 .../Models/Airing.swift | 0 .../Models/AudioBlock.swift | 0 .../Models/Episode.swift | 0 .../Models/RelatedText.swift | 0 .../Models/Schedule.swift | 0 .../Models/Show.swift | 0 .../Models/Spin.swift | 0 .../Models/Station.swift | 0 .../Protocols/DateProvider.swift | 0 .../Protocols/TimerProvider.swift | 0 .../Protocols/URLSessionProtocol.swift | 2 +- .../Player/AudioSessionManager.swift | 5 ++-- .../Player/DeviceInfoProvider.swift | 8 +++---- .../Player/FileDownloadManagerAsync.swift | 3 ++- .../Player/FileDownloaderAsync.swift | 1 + .../Player/ListeningSessionReporter.swift | 3 ++- .../Player/PlayolaMainMixer.swift | 3 ++- .../Player/PlayolaStationPlayer.swift | 5 ++-- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 1 + .../DateFormatterTests.swift | 2 +- .../ScheduleTests.swift | 2 +- .../SpinTests.swift | 2 +- .../Test Utilities/DateProviderMock.swift | 2 +- .../Test Utilities/Schedule+WithMock.swift | 2 +- 30 files changed, 42 insertions(+), 22 deletions(-) rename Sources/{PlayolaPlayer => PlayolaCore}/Authentication/PlayolaAuthenticationProvider.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/ErrorHandling/PlayolaErrorReporter.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Extensions/DateFormatter+iso8601Full.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/JSONDecoderWithIsoFull.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/MockData/MockSchedule.json (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Airing.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/AudioBlock.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Episode.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/RelatedText.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Schedule.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Show.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Spin.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Models/Station.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Protocols/DateProvider.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Protocols/TimerProvider.swift (100%) rename Sources/{PlayolaPlayer => PlayolaCore}/Protocols/URLSessionProtocol.swift (88%) rename Tests/{PlayolaPlayerTests => PlayolaCoreTests}/DateFormatterTests.swift (99%) rename Tests/{PlayolaPlayerTests => PlayolaCoreTests}/ScheduleTests.swift (99%) rename Tests/{PlayolaPlayerTests => PlayolaCoreTests}/SpinTests.swift (99%) rename Tests/{PlayolaPlayerTests => PlayolaCoreTests}/Test Utilities/DateProviderMock.swift (94%) rename Tests/{PlayolaPlayerTests => PlayolaCoreTests}/Test Utilities/Schedule+WithMock.swift (98%) diff --git a/Package.swift b/Package.swift index 5fcc98b..1cd98a3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,21 +1,34 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.0 import PackageDescription let package = Package( name: "PlayolaPlayer", - platforms: [.iOS(.v17), .macOS(.v14)], + platforms: [.iOS(.v18), .macOS(.v14), .tvOS(.v18)], products: [ - .library(name: "PlayolaPlayer", targets: ["PlayolaPlayer"]) + .library(name: "PlayolaPlayer", targets: ["PlayolaPlayer"]), + .library(name: "PlayolaCore", targets: ["PlayolaCore"]), ], dependencies: [], targets: [ + .target( + name: "PlayolaCore", + resources: [.copy("MockData")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target( name: "PlayolaPlayer", - resources: [.copy("MockData")] + dependencies: ["PlayolaCore"], + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget( + name: "PlayolaCoreTests", + dependencies: ["PlayolaCore"], + swiftSettings: [.swiftLanguageMode(.v5)] ), .testTarget( name: "PlayolaPlayerTests", - dependencies: ["PlayolaPlayer"] + dependencies: ["PlayolaPlayer"], + swiftSettings: [.swiftLanguageMode(.v5)] ), ] ) diff --git a/Sources/PlayolaPlayer/Authentication/PlayolaAuthenticationProvider.swift b/Sources/PlayolaCore/Authentication/PlayolaAuthenticationProvider.swift similarity index 100% rename from Sources/PlayolaPlayer/Authentication/PlayolaAuthenticationProvider.swift rename to Sources/PlayolaCore/Authentication/PlayolaAuthenticationProvider.swift diff --git a/Sources/PlayolaPlayer/ErrorHandling/PlayolaErrorReporter.swift b/Sources/PlayolaCore/ErrorHandling/PlayolaErrorReporter.swift similarity index 100% rename from Sources/PlayolaPlayer/ErrorHandling/PlayolaErrorReporter.swift rename to Sources/PlayolaCore/ErrorHandling/PlayolaErrorReporter.swift diff --git a/Sources/PlayolaPlayer/Extensions/DateFormatter+iso8601Full.swift b/Sources/PlayolaCore/Extensions/DateFormatter+iso8601Full.swift similarity index 100% rename from Sources/PlayolaPlayer/Extensions/DateFormatter+iso8601Full.swift rename to Sources/PlayolaCore/Extensions/DateFormatter+iso8601Full.swift diff --git a/Sources/PlayolaPlayer/JSONDecoderWithIsoFull.swift b/Sources/PlayolaCore/JSONDecoderWithIsoFull.swift similarity index 100% rename from Sources/PlayolaPlayer/JSONDecoderWithIsoFull.swift rename to Sources/PlayolaCore/JSONDecoderWithIsoFull.swift diff --git a/Sources/PlayolaPlayer/MockData/MockSchedule.json b/Sources/PlayolaCore/MockData/MockSchedule.json similarity index 100% rename from Sources/PlayolaPlayer/MockData/MockSchedule.json rename to Sources/PlayolaCore/MockData/MockSchedule.json diff --git a/Sources/PlayolaPlayer/Models/Airing.swift b/Sources/PlayolaCore/Models/Airing.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Airing.swift rename to Sources/PlayolaCore/Models/Airing.swift diff --git a/Sources/PlayolaPlayer/Models/AudioBlock.swift b/Sources/PlayolaCore/Models/AudioBlock.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/AudioBlock.swift rename to Sources/PlayolaCore/Models/AudioBlock.swift diff --git a/Sources/PlayolaPlayer/Models/Episode.swift b/Sources/PlayolaCore/Models/Episode.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Episode.swift rename to Sources/PlayolaCore/Models/Episode.swift diff --git a/Sources/PlayolaPlayer/Models/RelatedText.swift b/Sources/PlayolaCore/Models/RelatedText.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/RelatedText.swift rename to Sources/PlayolaCore/Models/RelatedText.swift diff --git a/Sources/PlayolaPlayer/Models/Schedule.swift b/Sources/PlayolaCore/Models/Schedule.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Schedule.swift rename to Sources/PlayolaCore/Models/Schedule.swift diff --git a/Sources/PlayolaPlayer/Models/Show.swift b/Sources/PlayolaCore/Models/Show.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Show.swift rename to Sources/PlayolaCore/Models/Show.swift diff --git a/Sources/PlayolaPlayer/Models/Spin.swift b/Sources/PlayolaCore/Models/Spin.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Spin.swift rename to Sources/PlayolaCore/Models/Spin.swift diff --git a/Sources/PlayolaPlayer/Models/Station.swift b/Sources/PlayolaCore/Models/Station.swift similarity index 100% rename from Sources/PlayolaPlayer/Models/Station.swift rename to Sources/PlayolaCore/Models/Station.swift diff --git a/Sources/PlayolaPlayer/Protocols/DateProvider.swift b/Sources/PlayolaCore/Protocols/DateProvider.swift similarity index 100% rename from Sources/PlayolaPlayer/Protocols/DateProvider.swift rename to Sources/PlayolaCore/Protocols/DateProvider.swift diff --git a/Sources/PlayolaPlayer/Protocols/TimerProvider.swift b/Sources/PlayolaCore/Protocols/TimerProvider.swift similarity index 100% rename from Sources/PlayolaPlayer/Protocols/TimerProvider.swift rename to Sources/PlayolaCore/Protocols/TimerProvider.swift diff --git a/Sources/PlayolaPlayer/Protocols/URLSessionProtocol.swift b/Sources/PlayolaCore/Protocols/URLSessionProtocol.swift similarity index 88% rename from Sources/PlayolaPlayer/Protocols/URLSessionProtocol.swift rename to Sources/PlayolaCore/Protocols/URLSessionProtocol.swift index 7278d0e..76bc5b0 100644 --- a/Sources/PlayolaPlayer/Protocols/URLSessionProtocol.swift +++ b/Sources/PlayolaCore/Protocols/URLSessionProtocol.swift @@ -7,7 +7,7 @@ import Foundation // Protocol for URLSession dependency injection -protocol URLSessionProtocol { +public protocol URLSessionProtocol { func data(for request: URLRequest) async throws -> (Data, URLResponse) } diff --git a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift b/Sources/PlayolaPlayer/Player/AudioSessionManager.swift index ee97f83..48a384f 100644 --- a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift +++ b/Sources/PlayolaPlayer/Player/AudioSessionManager.swift @@ -7,6 +7,7 @@ import AVFoundation import Foundation +import PlayolaCore /// Protocol for managing audio session configuration across platforms protocol AudioSessionManaging { @@ -16,8 +17,8 @@ protocol AudioSessionManaging { var isConfigured: Bool { get } } -#if os(iOS) - /// iOS implementation using AVAudioSession +#if os(iOS) || os(tvOS) + /// iOS/tvOS implementation using AVAudioSession class AudioSessionManager: AudioSessionManaging { private let errorReporter: PlayolaErrorReporter diff --git a/Sources/PlayolaPlayer/Player/DeviceInfoProvider.swift b/Sources/PlayolaPlayer/Player/DeviceInfoProvider.swift index 811c670..5da5ffe 100644 --- a/Sources/PlayolaPlayer/Player/DeviceInfoProvider.swift +++ b/Sources/PlayolaPlayer/Player/DeviceInfoProvider.swift @@ -7,7 +7,7 @@ import Foundation -#if os(iOS) +#if os(iOS) || os(tvOS) import UIKit #endif @@ -15,7 +15,7 @@ import Foundation public struct DeviceInfoProvider { public static var deviceName: String { - #if os(iOS) + #if os(iOS) || os(tvOS) return UIDevice.current.name #elseif os(macOS) return Host.current().localizedName ?? "Mac" @@ -23,7 +23,7 @@ public struct DeviceInfoProvider { } public static var systemVersion: String { - #if os(iOS) + #if os(iOS) || os(tvOS) return UIDevice.current.systemVersion #elseif os(macOS) let version = ProcessInfo.processInfo.operatingSystemVersion @@ -32,7 +32,7 @@ public struct DeviceInfoProvider { } public static var identifierForVendor: UUID? { - #if os(iOS) + #if os(iOS) || os(tvOS) return UIDevice.current.identifierForVendor #elseif os(macOS) return getOrCreateVendorIdentifier() diff --git a/Sources/PlayolaPlayer/Player/FileDownloadManagerAsync.swift b/Sources/PlayolaPlayer/Player/FileDownloadManagerAsync.swift index 57d302f..8837a02 100644 --- a/Sources/PlayolaPlayer/Player/FileDownloadManagerAsync.swift +++ b/Sources/PlayolaPlayer/Player/FileDownloadManagerAsync.swift @@ -6,6 +6,7 @@ // import Foundation +import PlayolaCore import os.log private struct FileInfo { @@ -143,7 +144,7 @@ public class FileDownloadManagerAsync: FileDownloadManaging { /// File manager for cache operations private let fileManager = FileManager.default - #if os(iOS) + #if os(iOS) || os(tvOS) /// Cache directory URL private var cacheDirectoryURL: URL { let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! diff --git a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift index c364de1..8f9c24b 100644 --- a/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift +++ b/Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift @@ -6,6 +6,7 @@ // import Foundation +import PlayolaCore import os.log /// An async/await-based file downloader that eliminates deadlock issues diff --git a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift index b28cabd..4574d7d 100644 --- a/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift +++ b/Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift @@ -8,8 +8,9 @@ import AVFoundation import Combine import Foundation +import PlayolaCore -#if os(iOS) +#if os(iOS) || os(tvOS) import UIKit #endif diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 10173ba..b87c5c2 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -7,9 +7,10 @@ import AVFoundation import Foundation +import PlayolaCore import os.log -#if os(iOS) +#if os(iOS) || os(tvOS) import UIKit #endif diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 8610f64..437ec14 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -8,6 +8,7 @@ import AVFAudio import Combine import Foundation +@_exported import PlayolaCore import os.log /// Errors specific to the station player @@ -140,7 +141,7 @@ final public class PlayolaStationPlayer: ObservableObject { self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) - #if os(iOS) + #if os(iOS) || os(tvOS) NotificationCenter.default.addObserver( self, selector: #selector(handleAudioSessionInterruption(_:)), @@ -636,7 +637,7 @@ final public class PlayolaStationPlayer: ObservableObject { os_log("✅ STOP completed", log: PlayolaStationPlayer.logger, type: .info) } - #if os(iOS) + #if os(iOS) || os(tvOS) @objc public func handleAudioRouteChange(_ notification: Notification) { guard let userInfo = notification.userInfo, let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 6fc993b..1ea61f1 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -8,6 +8,7 @@ import AVFoundation import AudioToolbox import Foundation +import PlayolaCore import os.log #if os(iOS) diff --git a/Tests/PlayolaPlayerTests/DateFormatterTests.swift b/Tests/PlayolaCoreTests/DateFormatterTests.swift similarity index 99% rename from Tests/PlayolaPlayerTests/DateFormatterTests.swift rename to Tests/PlayolaCoreTests/DateFormatterTests.swift index 4a355a6..b50d7c5 100644 --- a/Tests/PlayolaPlayerTests/DateFormatterTests.swift +++ b/Tests/PlayolaCoreTests/DateFormatterTests.swift @@ -8,7 +8,7 @@ import Testing import XCTest -@testable import PlayolaPlayer +@testable import PlayolaCore struct DateFormatterTests { // Test that the iso8601Full formatter correctly formats dates diff --git a/Tests/PlayolaPlayerTests/ScheduleTests.swift b/Tests/PlayolaCoreTests/ScheduleTests.swift similarity index 99% rename from Tests/PlayolaPlayerTests/ScheduleTests.swift rename to Tests/PlayolaCoreTests/ScheduleTests.swift index 5a17426..f0f765e 100644 --- a/Tests/PlayolaPlayerTests/ScheduleTests.swift +++ b/Tests/PlayolaCoreTests/ScheduleTests.swift @@ -7,7 +7,7 @@ import Foundation import Testing -@testable import PlayolaPlayer +@testable import PlayolaCore private struct TestSpins { let pastSpin: Spin diff --git a/Tests/PlayolaPlayerTests/SpinTests.swift b/Tests/PlayolaCoreTests/SpinTests.swift similarity index 99% rename from Tests/PlayolaPlayerTests/SpinTests.swift rename to Tests/PlayolaCoreTests/SpinTests.swift index fc81370..c2c02de 100644 --- a/Tests/PlayolaPlayerTests/SpinTests.swift +++ b/Tests/PlayolaCoreTests/SpinTests.swift @@ -9,7 +9,7 @@ import Foundation import Testing -@testable import PlayolaPlayer +@testable import PlayolaCore struct SpinTests { var spin: Spin = .mock diff --git a/Tests/PlayolaPlayerTests/Test Utilities/DateProviderMock.swift b/Tests/PlayolaCoreTests/Test Utilities/DateProviderMock.swift similarity index 94% rename from Tests/PlayolaPlayerTests/Test Utilities/DateProviderMock.swift rename to Tests/PlayolaCoreTests/Test Utilities/DateProviderMock.swift index de04f97..66cee51 100644 --- a/Tests/PlayolaPlayerTests/Test Utilities/DateProviderMock.swift +++ b/Tests/PlayolaCoreTests/Test Utilities/DateProviderMock.swift @@ -8,7 +8,7 @@ import Foundation import os -@testable import PlayolaPlayer +@testable import PlayolaCore final class DateProviderMock: DateProviderProtocol, @unchecked Sendable { private let lock = OSAllocatedUnfairLock() diff --git a/Tests/PlayolaPlayerTests/Test Utilities/Schedule+WithMock.swift b/Tests/PlayolaCoreTests/Test Utilities/Schedule+WithMock.swift similarity index 98% rename from Tests/PlayolaPlayerTests/Test Utilities/Schedule+WithMock.swift rename to Tests/PlayolaCoreTests/Test Utilities/Schedule+WithMock.swift index b591ed7..263ab84 100644 --- a/Tests/PlayolaPlayerTests/Test Utilities/Schedule+WithMock.swift +++ b/Tests/PlayolaCoreTests/Test Utilities/Schedule+WithMock.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PlayolaPlayer +@testable import PlayolaCore // Extension to provide additional mock utilities for testing extension Schedule {