#if canImport(AVFoundation) import AVFoundation import Domain import Foundation public actor AVFoundationEngine: OutputEngine { public typealias Resolve = @Sendable (Track) async throws -> StreamRequest private struct Slot { let item: QueueItem let playerItem: AVPlayerItem let statusObservation: NSKeyValueObservation } public nonisolated let events: AsyncStream private let continuation: AsyncStream.Continuation private let resolve: Resolve private let player = AVQueuePlayer() private let bufferAhead: TimeInterval = 60 private let anchorInterval: TimeInterval = 5 private var current: Slot? private var prepared: Slot? private var adoptable = false private var wantsPlaying = false private var sessionActive = false private var loadGeneration = 0 private var prepareGeneration = 0 private var observations: [NSKeyValueObservation] = [] private var tokens: [any NSObjectProtocol] = [] private var timeObserver: Any? public init(resolve: @escaping Resolve) { self.resolve = resolve (events, continuation) = AsyncStream.makeStream() player.automaticallyWaitsToMinimizeStalling = true player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible } isolated deinit { tokens.forEach { NotificationCenter.default.removeObserver($0) } if let timeObserver { player.removeTimeObserver(timeObserver) } } public var outputLatency: TimeInterval { AudioSession.outputLatency } public func load(_ item: QueueItem, at position: TimeInterval) async { startObserving() loadGeneration += 1 let generation = loadGeneration if adoptable, current?.item.id == item.id { adoptable = false } else if let prepared, prepared.item.id == item.id { player.advanceToNextItem() current = prepared self.prepared = nil } else { guard let slot = await makeSlot(item), generation == loadGeneration else { return } player.removeAllItems() prepared = nil player.insert(slot.playerItem, after: nil) current = slot } adoptable = false if position > 0 { await seek(to: position) } } public func prepare(_ item: QueueItem?) async { startObserving() guard prepared?.item.id != item?.id else { return } prepareGeneration += 1 let generation = prepareGeneration if let prepared { player.remove(prepared.playerItem) self.prepared = nil } guard let item, current != nil else { return } guard let slot = await makeSlot(item), generation == prepareGeneration, current != nil else { return } player.insert(slot.playerItem, after: nil) prepared = slot } public func play() async { wantsPlaying = true if !sessionActive { sessionActive = await (try? AudioSession.activate()) != nil } player.play() } public func pause() { wantsPlaying = false player.pause() } public func seek(to position: TimeInterval) async { let time = CMTime(seconds: position, preferredTimescale: 600) _ = await player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) reportAnchor() } public func stop() { wantsPlaying = false player.pause() player.removeAllItems() current = nil prepared = nil adoptable = false } private func makeSlot(_ item: QueueItem) async -> Slot? { do { let request = try await resolve(item.track) let playerItem = makePlayerItem(request) let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in guard playerItem.status == .failed else { return } let id = ObjectIdentifier(playerItem) Task { await self?.itemFailed(id) } } return Slot(item: item, playerItem: playerItem, statusObservation: observation) } catch { continuation.yield(.failed(item.id)) return nil } } private func makePlayerItem(_ request: StreamRequest) -> AVPlayerItem { var options: [String: any Sendable] = [AVURLAssetPreferPreciseDurationAndTimingKey: true] if !request.resource.headers.isEmpty { options["AVURLAssetHTTPHeaderFieldsKey"] = request.resource.headers } let asset = AVURLAsset(url: request.resource.url, options: options) let playerItem = AVPlayerItem(asset: asset) playerItem.preferredForwardBufferDuration = bufferAhead return playerItem } private func startObserving() { guard timeObserver == nil else { return } let interval = CMTime(seconds: anchorInterval, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in Task { await self?.reportAnchor() } } observations.append(player.observe(\.timeControlStatus, options: [.new]) { @Sendable [weak self] _, _ in Task { await self?.reportAnchor() } }) tokens.append(observeItems(.AVPlayerItemDidPlayToEndTime) { [weak self] id in await self?.itemEnded(id) }) tokens.append(observeItems(.AVPlayerItemFailedToPlayToEndTime) { [weak self] id in await self?.itemFailed(id) }) tokens.append(contentsOf: AudioSession.observe( interrupted: { [weak self] began in Task { await self?.interruption(began: began) } }, routeLost: { [weak self] in Task { await self?.routeLost() } } )) } private func observeItems( _ name: Notification.Name, handler: @escaping @Sendable (ObjectIdentifier) async -> Void ) -> any NSObjectProtocol { NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { notification in guard let playerItem = notification.object as? AVPlayerItem else { return } let id = ObjectIdentifier(playerItem) Task { await handler(id) } } } private func itemEnded(_ id: ObjectIdentifier) { guard let current, ObjectIdentifier(current.playerItem) == id else { return } continuation.yield(.finished(current.item.id)) self.current = prepared prepared = nil adoptable = self.current != nil } private func itemFailed(_ id: ObjectIdentifier) { if let current, ObjectIdentifier(current.playerItem) == id { continuation.yield(.failed(current.item.id)) } else if let prepared, ObjectIdentifier(prepared.playerItem) == id { player.remove(prepared.playerItem) self.prepared = nil } } private func reportAnchor() { guard let playing = player.currentItem, let slot = [current, prepared].compactMap(\.self).first(where: { $0.playerItem === playing }) else { return } let seconds = player.currentTime().seconds guard seconds.isFinite else { return } let rate = player.timeControlStatus == .playing ? Double(player.rate) : 0 continuation.yield(.anchor(slot.item.id, position: seconds, rate: rate)) } private func interruption(began: Bool) { if began { player.pause() reportAnchor() } else if wantsPlaying { player.play() } } private func routeLost() { wantsPlaying = false player.pause() reportAnchor() } } #endif