Files

253 lines
10 KiB
Swift

#if canImport(AVFoundation)
import AVFoundation
import Domain
import Foundation
public actor AVFoundationEngine: OutputEngine, PlaybackTuning {
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<OutputEvent>
private let continuation: AsyncStream<OutputEvent>.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
public private(set) var matchesSampleRate = 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<OutputEvent>.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 request = await resolveStream(item), generation == loadGeneration else { return }
await switchRate(for: item, request: request)
guard generation == loadGeneration else { return }
let slot = makeSlot(item, request)
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 request = await resolveStream(item), generation == prepareGeneration, current != nil else { return }
guard rateSwitch(for: item, request: request) == nil else { return }
let slot = makeSlot(item, request)
player.insert(slot.playerItem, after: nil)
prepared = slot
}
public func setMatchesSampleRate(_ isOn: Bool) {
matchesSampleRate = isOn
guard isOn, let prepared, rateSwitch(for: prepared.item, request: nil) != nil else { return }
player.remove(prepared.playerItem)
self.prepared = nil
}
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 setVolume(_ volume: Double) {
player.volume = Float(min(max(volume, 0), 1))
}
public func stop() {
wantsPlaying = false
player.pause()
player.removeAllItems()
current = nil
prepared = nil
adoptable = false
}
private func resolveStream(_ item: QueueItem) async -> StreamRequest? {
do {
return try await resolve(item.track)
} catch {
continuation.yield(.failed(item.id))
return nil
}
}
private func makeSlot(_ item: QueueItem, _ request: StreamRequest) -> Slot {
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)
}
private func rateSwitch(for item: QueueItem, request: StreamRequest?) -> Double? {
#if os(macOS)
let format = request?.format ?? item.track.formats.first
return SampleRateMatcher(isEnabled: matchesSampleRate).targetRate(for: format, on: OutputDevice.current())
#else
return nil
#endif
}
private func switchRate(for item: QueueItem, request: StreamRequest) async {
#if os(macOS)
guard let rate = rateSwitch(for: item, request: request) else { return }
player.pause()
player.removeAllItems()
prepared = nil
try? await OutputDevice.setNominalRate(rate)
#endif
}
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