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
|
||||
@@ -0,0 +1,69 @@
|
||||
#if canImport(AVFoundation)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum AudioSession {
|
||||
typealias Token = any NSObjectProtocol
|
||||
|
||||
#if os(iOS) || os(watchOS)
|
||||
static var outputLatency: TimeInterval {
|
||||
AVAudioSession.sharedInstance().outputLatency
|
||||
}
|
||||
|
||||
static func activate() async throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
#if os(watchOS)
|
||||
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
_ = try await session.activate()
|
||||
#else
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func observe(
|
||||
interrupted: @escaping @Sendable (Bool) -> Void,
|
||||
routeLost: @escaping @Sendable () -> Void
|
||||
) -> [Token] {
|
||||
let center = NotificationCenter.default
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let interruption = center.addObserver(forName: AVAudioSession.interruptionNotification, object: session, queue: nil) {
|
||||
guard let raw = $0.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||
else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
interrupted(true)
|
||||
case .ended:
|
||||
let options = $0.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
if AVAudioSession.InterruptionOptions(rawValue: options).contains(.shouldResume) {
|
||||
interrupted(false)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let route = center.addObserver(forName: AVAudioSession.routeChangeNotification, object: session, queue: nil) {
|
||||
let raw = $0.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
if raw.flatMap(AVAudioSession.RouteChangeReason.init) == .oldDeviceUnavailable {
|
||||
routeLost()
|
||||
}
|
||||
}
|
||||
return [interruption, route]
|
||||
}
|
||||
#else
|
||||
static var outputLatency: TimeInterval {
|
||||
0
|
||||
}
|
||||
|
||||
static func activate() async throws {}
|
||||
|
||||
static func observe(
|
||||
interrupted _: @escaping @Sendable (Bool) -> Void,
|
||||
routeLost _: @escaping @Sendable () -> Void
|
||||
) -> [Token] {
|
||||
[]
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,88 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public struct QueueItem: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: UUID
|
||||
public let track: Track
|
||||
|
||||
public init(id: UUID = UUID(), track: Track) {
|
||||
self.id = id
|
||||
self.track = track
|
||||
}
|
||||
}
|
||||
|
||||
public enum RepeatMode: String, Codable, Sendable, CaseIterable {
|
||||
case off, all, one
|
||||
}
|
||||
|
||||
public enum PlaybackCommand: Codable, Sendable {
|
||||
case play
|
||||
case pause
|
||||
case togglePlayPause
|
||||
case seek(to: TimeInterval)
|
||||
case next
|
||||
case previous
|
||||
case setQueue(tracks: [Track], startIndex: Int, shuffled: Bool)
|
||||
case insertNext([Track])
|
||||
case insertLast([Track])
|
||||
case jump(to: QueueItem.ID)
|
||||
case move(item: QueueItem.ID, after: QueueItem.ID?)
|
||||
case remove(QueueItem.ID)
|
||||
case clearUpcoming
|
||||
case setShuffle(Bool)
|
||||
case setRepeat(RepeatMode)
|
||||
}
|
||||
|
||||
public struct PlaybackAnchor: Hashable, Sendable {
|
||||
public var position: TimeInterval
|
||||
public var instant: ContinuousClock.Instant
|
||||
public var rate: Double
|
||||
|
||||
public init(position: TimeInterval, instant: ContinuousClock.Instant, rate: Double) {
|
||||
self.position = position
|
||||
self.instant = instant
|
||||
self.rate = rate
|
||||
}
|
||||
|
||||
public func position(at now: ContinuousClock.Instant) -> TimeInterval {
|
||||
let elapsed = now - instant
|
||||
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
return position + seconds * rate
|
||||
}
|
||||
}
|
||||
|
||||
public enum PlaybackStatus: String, Sendable {
|
||||
case stopped, paused, playing
|
||||
}
|
||||
|
||||
public struct PlaybackState: Hashable, Sendable {
|
||||
public var history: [QueueItem] = []
|
||||
public var current: QueueItem?
|
||||
public var upcoming: [QueueItem] = []
|
||||
public var status: PlaybackStatus = .stopped
|
||||
public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0)
|
||||
public var isShuffled = false
|
||||
public var repeatMode: RepeatMode = .off
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum OutputEvent: Sendable {
|
||||
case finished(QueueItem.ID)
|
||||
case failed(QueueItem.ID)
|
||||
case anchor(QueueItem.ID, position: TimeInterval, rate: Double)
|
||||
}
|
||||
|
||||
public protocol OutputEngine: Sendable {
|
||||
var events: AsyncStream<OutputEvent> { get }
|
||||
func load(_ item: QueueItem, at position: TimeInterval) async
|
||||
func prepare(_ item: QueueItem?) async
|
||||
func play() async
|
||||
func pause() async
|
||||
func seek(to position: TimeInterval) async
|
||||
func stop() async
|
||||
}
|
||||
|
||||
public extension OutputEngine {
|
||||
func prepare(_: QueueItem?) async {}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
public actor SilentEngine: OutputEngine {
|
||||
public nonisolated let events: AsyncStream<OutputEvent>
|
||||
private let continuation: AsyncStream<OutputEvent>.Continuation
|
||||
private var item: QueueItem?
|
||||
private var position: TimeInterval = 0
|
||||
private var startedAt: ContinuousClock.Instant?
|
||||
private var timer: Task<Void, Never>?
|
||||
|
||||
public init() {
|
||||
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||
}
|
||||
|
||||
public func load(_ item: QueueItem, at position: TimeInterval) {
|
||||
timer?.cancel()
|
||||
self.item = item
|
||||
self.position = position
|
||||
startedAt = nil
|
||||
}
|
||||
|
||||
public func play() {
|
||||
guard let item, startedAt == nil else { return }
|
||||
startedAt = .now
|
||||
let remaining = max(item.track.duration - position, 0)
|
||||
timer = Task { [continuation] in
|
||||
try? await Task.sleep(for: .seconds(remaining))
|
||||
guard !Task.isCancelled else { return }
|
||||
continuation.yield(.finished(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
timer?.cancel()
|
||||
if let startedAt {
|
||||
let elapsed = ContinuousClock.now - startedAt
|
||||
position += Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
}
|
||||
startedAt = nil
|
||||
}
|
||||
|
||||
public func seek(to position: TimeInterval) {
|
||||
let wasPlaying = startedAt != nil
|
||||
pause()
|
||||
self.position = position
|
||||
if wasPlaying {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
timer?.cancel()
|
||||
item = nil
|
||||
startedAt = nil
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user