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,270 @@
import Domain
import Foundation
public actor PlaybackSession {
public typealias Now = @Sendable () -> ContinuousClock.Instant
private let engine: any OutputEngine
private let now: Now
private var state = PlaybackState()
private var originalOrder: [QueueItem.ID] = []
private var observers: [UUID: AsyncStream<PlaybackState>.Continuation] = [:]
private var eventTask: Task<Void, Never>?
private var preparedKey: [QueueItem.ID?] = []
private var failureStreak = 0
private let restartThreshold: TimeInterval = 3
public init(engine: any OutputEngine, now: @escaping Now = { .now }) {
self.engine = engine
self.now = now
}
public func states() -> AsyncStream<PlaybackState> {
listenToEngine()
let key = UUID()
let (stream, continuation) = AsyncStream<PlaybackState>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[key] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(key) }
}
continuation.yield(state)
return stream
}
public var snapshot: PlaybackState {
state
}
public func send(_ command: PlaybackCommand) async {
listenToEngine()
failureStreak = 0
switch command {
case .play: await resume()
case .pause: await pause()
case .togglePlayPause: state.status == .playing ? await pause() : await resume()
case let .seek(position): await seek(to: position)
case .next: await advance(auto: false)
case .previous: await goBack()
case let .setQueue(tracks, startIndex, shuffled): await setQueue(tracks, startIndex: startIndex, shuffled: shuffled)
case let .insertNext(tracks): insert(tracks, atEnd: false)
case let .insertLast(tracks): insert(tracks, atEnd: true)
case let .jump(id): await jump(to: id)
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 let .setShuffle(isOn): setShuffle(isOn)
case let .setRepeat(mode): state.repeatMode = mode
}
publish()
await syncPrepared()
}
private func listenToEngine() {
guard eventTask == nil else { return }
let events = engine.events
eventTask = Task { [weak self] in
for await event in events {
await self?.handle(event)
}
}
}
private func handle(_ event: OutputEvent) async {
switch event {
case let .finished(id) where id == state.current?.id:
failureStreak = 0
await finishCurrent()
case let .failed(id) where id == state.current?.id:
await failCurrent()
case let .anchor(id, position, rate) where id == state.current?.id:
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: state.status == .playing ? rate : 0)
default:
return
}
publish()
await syncPrepared()
}
private func finishCurrent() async {
if state.repeatMode == .one {
await start(at: 0, playing: true)
} else {
await advance(auto: true)
}
}
private func failCurrent() async {
failureStreak += 1
guard failureStreak <= state.history.count + state.upcoming.count else {
anchor(0, playing: false)
await engine.stop()
return
}
await advance(auto: true)
}
private var nextToPrepare: QueueItem? {
if let next = state.upcoming.first {
return next
}
switch state.repeatMode {
case .off: return nil
case .one: return state.current
case .all: return state.history.first ?? state.current
}
}
private func syncPrepared() async {
let key = [state.current?.id, nextToPrepare?.id]
guard key != preparedKey else { return }
preparedKey = key
await engine.prepare(state.current == nil ? nil : nextToPrepare)
}
private func removeObserver(_ key: UUID) {
observers[key] = nil
}
private func publish() {
observers.values.forEach { $0.yield(state) }
}
private func anchor(_ position: TimeInterval, playing: Bool) {
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: playing ? 1 : 0)
state.status = playing ? .playing : .paused
}
private var position: TimeInterval {
let raw = state.anchor.position(at: now())
return min(max(raw, 0), state.current?.track.duration ?? 0)
}
private func resume() async {
guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return }
anchor(position, playing: true)
await engine.play()
}
private func pause() async {
guard state.status == .playing else { return }
anchor(position, playing: false)
await engine.pause()
}
private func seek(to target: TimeInterval) async {
guard let current = state.current else { return }
let clamped = min(max(target, 0), current.track.duration)
anchor(clamped, playing: state.status == .playing)
await engine.seek(to: clamped)
}
private func start(at position: TimeInterval, playing: Bool) async {
guard let current = state.current else {
state.status = .stopped
state.anchor = PlaybackAnchor(position: 0, instant: now(), rate: 0)
await engine.stop()
return
}
await engine.load(current, at: position)
anchor(position, playing: playing)
if playing {
await engine.play()
}
}
private func setQueue(_ tracks: [Track], startIndex: Int, shuffled: Bool) async {
let items = tracks.map { QueueItem(track: $0) }
originalOrder = items.map(\.id)
state.history = []
state.isShuffled = shuffled
guard !items.isEmpty else {
state.current = nil
state.upcoming = []
await start(at: 0, playing: false)
return
}
if shuffled {
var rest = items
let first = rest.remove(at: Int.random(in: rest.indices))
state.current = first
state.upcoming = rest.shuffled()
} else {
let index = min(max(startIndex, 0), items.count - 1)
state.history = Array(items[..<index])
state.current = items[index]
state.upcoming = Array(items[(index + 1)...])
}
await start(at: 0, playing: true)
}
private func insert(_ tracks: [Track], atEnd: Bool) {
let items = tracks.map { QueueItem(track: $0) }
originalOrder.append(contentsOf: items.map(\.id))
if atEnd {
state.upcoming.append(contentsOf: items)
} else {
state.upcoming.insert(contentsOf: items, at: 0)
}
}
private func advance(auto: Bool) async {
guard let current = state.current else { return }
var keepsPlaying = auto || state.status == .playing
if state.upcoming.isEmpty {
let all = state.history + [current]
state.history = []
state.current = all.first
state.upcoming = Array(all.dropFirst())
keepsPlaying = keepsPlaying && state.repeatMode != .off
} else {
state.history.append(current)
state.current = state.upcoming.removeFirst()
}
await start(at: 0, playing: keepsPlaying)
}
private func goBack() async {
guard let current = state.current else { return }
guard position <= restartThreshold, let previous = state.history.popLast() else {
await seek(to: 0)
return
}
state.upcoming.insert(current, at: 0)
state.current = previous
await start(at: 0, playing: state.status == .playing)
}
private func jump(to id: QueueItem.ID) async {
guard let current = state.current else { return }
if let index = state.upcoming.firstIndex(where: { $0.id == id }) {
state.history.append(current)
state.history.append(contentsOf: state.upcoming[..<index])
state.current = state.upcoming[index]
state.upcoming.removeSubrange(...index)
} else if let index = state.history.firstIndex(where: { $0.id == id }) {
state.upcoming.insert(contentsOf: Array(state.history[(index + 1)...]) + [current], at: 0)
state.current = state.history[index]
state.history.removeSubrange(index...)
} else {
return
}
await start(at: 0, playing: true)
}
private func move(_ id: QueueItem.ID, after target: QueueItem.ID?) {
guard let from = state.upcoming.firstIndex(where: { $0.id == id }) else { return }
let item = state.upcoming.remove(at: from)
let index = target.flatMap { anchorID in state.upcoming.firstIndex { $0.id == anchorID } }.map { $0 + 1 } ?? 0
state.upcoming.insert(item, at: index)
}
private func setShuffle(_ isOn: Bool) {
state.isShuffled = isOn
if isOn {
state.upcoming.shuffle()
} else {
let rank = Dictionary(uniqueKeysWithValues: originalOrder.enumerated().map { ($1, $0) })
state.upcoming.sort { (rank[$0.id] ?? .max) < (rank[$1.id] ?? .max) }
}
}
}