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 {
@@ -0,0 +1,127 @@
#if os(macOS)
import CoreAudio
import Foundation
public enum OutputDeviceError: Error {
case noDevice, unsupportedRate, writeFailed, timedOut
}
public enum OutputDevice {
private static let pollStep: Duration = .milliseconds(20)
private static let pollLimit = 50
private static let settle: Duration = .milliseconds(100)
public static func current() -> OutputDeviceInfo? {
guard let id = defaultDevice() else { return nil }
return OutputDeviceInfo(
id: id,
name: read(kAudioObjectPropertyName, from: id, as: CFString.self).map { $0 as String } ?? "",
nominalRate: read(kAudioDevicePropertyNominalSampleRate, from: id, as: Float64.self) ?? 0,
availableRates: availableRates(of: id)
)
}
public static func setNominalRate(_ rate: Double) async throws {
guard let device = current() else { throw OutputDeviceError.noDevice }
guard device.nominalRate != rate else { return }
guard device.availableRates.contains(rate) else { throw OutputDeviceError.unsupportedRate }
var address = address(kAudioDevicePropertyNominalSampleRate)
var value = rate
let size = UInt32(MemoryLayout<Float64>.size)
guard AudioObjectSetPropertyData(device.id, &address, 0, nil, size, &value) == noErr else {
throw OutputDeviceError.writeFailed
}
for _ in 0 ..< pollLimit {
if read(kAudioDevicePropertyNominalSampleRate, from: device.id, as: Float64.self) == rate {
try await Task.sleep(for: settle)
return
}
try await Task.sleep(for: pollStep)
}
throw OutputDeviceError.timedOut
}
public static func changes() -> AsyncStream<OutputDeviceInfo?> {
AsyncStream { continuation in
continuation.yield(current())
let listener = Listener { continuation.yield(current()) }
continuation.onTermination = { _ in listener.stop() }
}
}
fileprivate static func defaultDevice() -> AudioDeviceID? {
let system = AudioObjectID(kAudioObjectSystemObject)
let id = read(kAudioHardwarePropertyDefaultOutputDevice, from: system, as: AudioDeviceID.self)
return id.flatMap { $0 == kAudioObjectUnknown ? nil : $0 }
}
fileprivate static func address(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}
private static func read<T>(_ selector: AudioObjectPropertySelector, from object: AudioObjectID, as _: T.Type) -> T? {
var address = address(selector)
var size = UInt32(MemoryLayout<T>.size)
let buffer = UnsafeMutablePointer<T>.allocate(capacity: 1)
defer { buffer.deallocate() }
guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, buffer) == noErr else { return nil }
return buffer.move()
}
private static func availableRates(of device: AudioDeviceID) -> [Double] {
var address = address(kAudioDevicePropertyAvailableNominalSampleRates)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr else { return [] }
let count = Int(size) / MemoryLayout<AudioValueRange>.stride
var ranges = [AudioValueRange](repeating: AudioValueRange(), count: count)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &ranges) == noErr else { return [] }
return Array(Set(ranges.flatMap { [$0.mMinimum, $0.mMaximum] })).sorted()
}
}
private final class Listener: @unchecked Sendable {
private let queue = DispatchQueue(label: "Amphlify.OutputDevice")
private let system = AudioObjectID(kAudioObjectSystemObject)
private var device: AudioDeviceID = kAudioObjectUnknown
private var systemAddress = OutputDevice.address(kAudioHardwarePropertyDefaultOutputDevice)
private var rateAddress = OutputDevice.address(kAudioDevicePropertyNominalSampleRate)
private var onSystemChange: AudioObjectPropertyListenerBlock = { _, _ in }
private var onRateChange: AudioObjectPropertyListenerBlock = { _, _ in }
init(onChange: @escaping @Sendable () -> Void) {
onRateChange = { _, _ in onChange() }
onSystemChange = { [weak self] _, _ in
self?.follow()
onChange()
}
queue.sync {
AudioObjectAddPropertyListenerBlock(system, &systemAddress, queue, onSystemChange)
follow()
}
}
func stop() {
queue.sync {
AudioObjectRemovePropertyListenerBlock(system, &systemAddress, queue, onSystemChange)
unfollow()
}
}
private func follow() {
unfollow()
device = OutputDevice.defaultDevice() ?? kAudioObjectUnknown
guard device != kAudioObjectUnknown else { return }
AudioObjectAddPropertyListenerBlock(device, &rateAddress, queue, onRateChange)
}
private func unfollow() {
guard device != kAudioObjectUnknown else { return }
AudioObjectRemovePropertyListenerBlock(device, &rateAddress, queue, onRateChange)
device = kAudioObjectUnknown
}
}
#endif
@@ -29,8 +29,10 @@ public enum PlaybackCommand: Codable, Sendable {
case move(item: QueueItem.ID, after: QueueItem.ID?)
case remove(QueueItem.ID)
case clearUpcoming
case clearHistory
case setShuffle(Bool)
case setRepeat(RepeatMode)
case setVolume(Double)
}
public struct PlaybackAnchor: Hashable, Sendable {
@@ -63,6 +65,7 @@ public struct PlaybackState: Hashable, Sendable {
public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0)
public var isShuffled = false
public var repeatMode: RepeatMode = .off
public var volume: Double = 1
public init() {}
}
@@ -81,8 +84,10 @@ public protocol OutputEngine: Sendable {
func pause() async
func seek(to position: TimeInterval) async
func stop() async
func setVolume(_ volume: Double) async
}
public extension OutputEngine {
func prepare(_: QueueItem?) async {}
func setVolume(_: Double) async {}
}
@@ -52,8 +52,13 @@ public actor PlaybackSession {
case let .move(item, after): move(item, after: after)
case let .remove(id): state.upcoming.removeAll { $0.id == id }
case .clearUpcoming: state.upcoming.removeAll()
case .clearHistory: state.history.removeAll()
case let .setShuffle(isOn): setShuffle(isOn)
case let .setRepeat(mode): state.repeatMode = mode
case let .setVolume(volume):
guard volume.isFinite else { return }
state.volume = min(max(volume, 0), 1)
await engine.setVolume(state.volume)
}
publish()
await syncPrepared()
@@ -142,12 +147,14 @@ public actor PlaybackSession {
private func resume() async {
guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return }
anchor(position, playing: true)
publish()
await engine.play()
}
private func pause() async {
guard state.status == .playing else { return }
anchor(position, playing: false)
publish()
await engine.pause()
}
@@ -155,6 +162,7 @@ public actor PlaybackSession {
guard let current = state.current else { return }
let clamped = min(max(target, 0), current.track.duration)
anchor(clamped, playing: state.status == .playing)
publish()
await engine.seek(to: clamped)
}
@@ -165,10 +173,15 @@ public actor PlaybackSession {
await engine.stop()
return
}
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: 0)
state.status = playing ? .playing : .paused
publish()
await engine.load(current, at: position)
anchor(position, playing: playing)
if playing {
await engine.play()
} else {
await engine.pause()
}
}
@@ -0,0 +1,34 @@
import Domain
import Foundation
public struct OutputDeviceInfo: Hashable, Sendable {
public var id: UInt32
public var name: String
public var nominalRate: Double
public var availableRates: [Double]
public init(id: UInt32, name: String, nominalRate: Double, availableRates: [Double]) {
self.id = id
self.name = name
self.nominalRate = nominalRate
self.availableRates = availableRates
}
}
public struct SampleRateMatcher: Sendable {
public var isEnabled: Bool
public init(isEnabled: Bool) {
self.isEnabled = isEnabled
}
public func targetRate(for format: AudioFormat?, on device: OutputDeviceInfo?) -> Double? {
guard isEnabled, let device, let rate = format?.sampleRate.map(Double.init) else { return nil }
guard rate != device.nominalRate, device.availableRates.contains(rate) else { return nil }
return rate
}
}
public protocol PlaybackTuning: Sendable {
func setMatchesSampleRate(_ isOn: Bool) async
}