feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
commit
e5bb559d7f
93 files changed
+7517
No files matched your search
@@ -0,0 +1,213 @@
|
||||
#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<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
|
||||
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 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
|
||||
Reference in new issue
Block a user