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,185 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
@testable import Playback
|
||||
import Testing
|
||||
|
||||
actor RecordingEngine: OutputEngine {
|
||||
nonisolated let events: AsyncStream<OutputEvent>
|
||||
nonisolated let continuation: AsyncStream<OutputEvent>.Continuation
|
||||
private(set) var loaded: [QueueItem.ID] = []
|
||||
private(set) var prepared: [QueueItem.ID?] = []
|
||||
|
||||
init() {
|
||||
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||
}
|
||||
|
||||
func load(_ item: QueueItem, at _: TimeInterval) {
|
||||
loaded.append(item.id)
|
||||
}
|
||||
|
||||
func prepare(_ item: QueueItem?) {
|
||||
prepared.append(item?.id)
|
||||
}
|
||||
|
||||
func play() {}
|
||||
func pause() {}
|
||||
func seek(to _: TimeInterval) {}
|
||||
func stop() {}
|
||||
}
|
||||
|
||||
final class FakeClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var instant = ContinuousClock.now
|
||||
|
||||
var now: ContinuousClock.Instant {
|
||||
lock.withLock { instant }
|
||||
}
|
||||
|
||||
func advance(_ seconds: Double) {
|
||||
lock.withLock { instant += .seconds(seconds) }
|
||||
}
|
||||
}
|
||||
|
||||
private func tracks(_ count: Int) -> [Track] {
|
||||
let source = SourceID()
|
||||
return (0 ..< count).map {
|
||||
Track(
|
||||
id: EntityID(source: source, remote: "\($0)"),
|
||||
title: "Track \($0)",
|
||||
albumID: nil,
|
||||
albumTitle: "",
|
||||
artistID: nil,
|
||||
artistName: "",
|
||||
duration: 200
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func queueStartsAtIndexAndAnchorsPosition() async {
|
||||
let clock = FakeClock()
|
||||
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||
await session.send(.setQueue(tracks: tracks(5), startIndex: 2, shuffled: false))
|
||||
clock.advance(10)
|
||||
let state = await session.snapshot
|
||||
#expect(state.current?.track.title == "Track 2")
|
||||
#expect(state.history.count == 2 && state.upcoming.count == 2)
|
||||
#expect(state.status == .playing)
|
||||
#expect(abs(state.anchor.position(at: clock.now) - 10) < 0.001)
|
||||
}
|
||||
|
||||
@Test func pauseFreezesAnchor() async {
|
||||
let clock = FakeClock()
|
||||
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
clock.advance(30)
|
||||
await session.send(.pause)
|
||||
clock.advance(30)
|
||||
let state = await session.snapshot
|
||||
#expect(abs(state.anchor.position(at: clock.now) - 30) < 0.001)
|
||||
}
|
||||
|
||||
@Test func previousRestartsAfterThresholdAndGoesBackBefore() async {
|
||||
let clock = FakeClock()
|
||||
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||
await session.send(.setQueue(tracks: tracks(3), startIndex: 1, shuffled: false))
|
||||
clock.advance(10)
|
||||
await session.send(.previous)
|
||||
#expect(await session.snapshot.current?.track.title == "Track 1")
|
||||
await session.send(.previous)
|
||||
#expect(await session.snapshot.current?.track.title == "Track 0")
|
||||
}
|
||||
|
||||
@Test func queueEditsAddressItemsByID() async {
|
||||
let session = PlaybackSession(engine: RecordingEngine())
|
||||
await session.send(.setQueue(tracks: tracks(4), startIndex: 0, shuffled: false))
|
||||
let upcoming = await session.snapshot.upcoming
|
||||
await session.send(.move(item: upcoming[2].id, after: nil))
|
||||
await session.send(.remove(upcoming[0].id))
|
||||
#expect(await session.snapshot.upcoming.map(\.track.title) == ["Track 3", "Track 2"])
|
||||
await session.send(.jump(to: upcoming[1].id))
|
||||
let state = await session.snapshot
|
||||
#expect(state.current?.track.title == "Track 2" && state.upcoming.isEmpty)
|
||||
}
|
||||
|
||||
@Test func finishedTrackAdvancesAndStopsAtEndWithoutRepeat() async throws {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
let states = await session.states()
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
for expected in [("Track 1", PlaybackStatus.playing), ("Track 0", .paused)] {
|
||||
let current = try #require(await session.snapshot.current)
|
||||
engine.continuation.yield(.finished(current.id))
|
||||
for await state in states where state.current?.id != current.id {
|
||||
#expect(state.current?.track.title == expected.0 && state.status == expected.1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unshuffleRestoresOriginalOrder() async {
|
||||
let session = PlaybackSession(engine: RecordingEngine())
|
||||
await session.send(.setQueue(tracks: tracks(8), startIndex: 0, shuffled: false))
|
||||
await session.send(.setShuffle(true))
|
||||
await session.send(.setShuffle(false))
|
||||
#expect(await session.snapshot.upcoming.map(\.track.title) == (1 ..< 8).map { "Track \($0)" })
|
||||
}
|
||||
|
||||
@Test func prepareFollowsHeadOfUpcoming() async {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
await session.send(.setQueue(tracks: tracks(3), startIndex: 0, shuffled: false))
|
||||
var state = await session.snapshot
|
||||
#expect(await engine.prepared == [state.upcoming[0].id])
|
||||
await session.send(.next)
|
||||
state = await session.snapshot
|
||||
#expect(await engine.prepared.last == state.upcoming[0].id)
|
||||
await session.send(.next)
|
||||
#expect(await engine.prepared.last == .some(nil))
|
||||
}
|
||||
|
||||
@Test func anchorEventReanchorsCurrentAndIgnoresStaleID() async throws {
|
||||
let clock = FakeClock()
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine) { clock.now }
|
||||
let states = await session.states()
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
let current = try #require(await session.snapshot.current)
|
||||
engine.continuation.yield(.anchor(UUID(), position: 50, rate: 1))
|
||||
engine.continuation.yield(.anchor(current.id, position: 70, rate: 0))
|
||||
for await state in states where state.anchor.rate == 0 {
|
||||
#expect(state.status == .playing && state.current?.id == current.id)
|
||||
#expect(abs(state.anchor.position(at: clock.now) - 70) < 0.001)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@Test func failedTrackAdvancesToNext() async throws {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
let states = await session.states()
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
let current = try #require(await session.snapshot.current)
|
||||
engine.continuation.yield(.failed(current.id))
|
||||
for await state in states where state.current?.id != current.id {
|
||||
#expect(state.current?.track.title == "Track 1" && state.status == .playing)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@Test func everyTrackFailingStopsInsteadOfLooping() async throws {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
let states = await session.states()
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
await session.send(.setRepeat(.all))
|
||||
for _ in 0 ..< 3 {
|
||||
let current = try #require(await session.snapshot.current)
|
||||
engine.continuation.yield(.failed(current.id))
|
||||
for await state in states where state.current?.id != current.id || state.status == .paused {
|
||||
break
|
||||
}
|
||||
}
|
||||
let state = await session.snapshot
|
||||
#expect(state.status == .paused && state.anchor.rate == 0)
|
||||
#expect(await engine.loaded.count == 2)
|
||||
}
|
||||
Reference in new issue
Block a user