feat(mac,ios,playback,store): initial macos implementation

This commit is contained in:
hh committed 2026-09-23 04:56:48 +02:00
1 parent e5bb559d7f
commit d2d7166cf0
72 files changed
+4452 -318

No files matched your search

@@ -3,7 +3,7 @@
import Domain
import Foundation
public actor AVFoundationEngine: OutputEngine {
public actor AVFoundationEngine: OutputEngine, PlaybackTuning {
public typealias Resolve = @Sendable (Track) async throws -> StreamRequest
private struct Slot {
@@ -22,6 +22,7 @@
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
@@ -58,7 +59,10 @@
current = prepared
self.prepared = nil
} else {
guard let slot = await makeSlot(item), generation == loadGeneration else { return }
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)
@@ -80,11 +84,20 @@
self.prepared = nil
}
guard let item, current != nil else { return }
guard let slot = await makeSlot(item), generation == prepareGeneration, 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 {
@@ -104,6 +117,10 @@
reportAnchor()
}
public func setVolume(_ volume: Double) {
player.volume = Float(min(max(volume, 0), 1))
}
public func stop() {
wantsPlaying = false
player.pause()
@@ -113,22 +130,44 @@
adoptable = false
}
private func makeSlot(_ item: QueueItem) async -> Slot? {
private func resolveStream(_ item: QueueItem) async -> StreamRequest? {
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)
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 {