feat(*): subsonic client with avfoundation playback and apple music ui

This commit is contained in:
hh committed 2026-09-22 06:44:44 +02:00
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