feat(mac,ios,playback,store): initial macos implementation
This commit is contained in:
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
|
||||
}
|
||||
@@ -5,7 +5,24 @@ public enum AlbumSort: String, CaseIterable, Sendable {
|
||||
case recentlyAdded, title, artist, year
|
||||
}
|
||||
|
||||
public enum SearchHit: Identifiable, Hashable, Codable, Sendable {
|
||||
case artist(Artist)
|
||||
case album(Album)
|
||||
case track(Track)
|
||||
case playlist(Playlist)
|
||||
|
||||
public var id: EntityID {
|
||||
switch self {
|
||||
case let .artist(artist): artist.id
|
||||
case let .album(album): album.id
|
||||
case let .track(track): track.id
|
||||
case let .playlist(playlist): playlist.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchResults: Sendable {
|
||||
public var top: [SearchHit] = []
|
||||
public var artists: [Artist] = []
|
||||
public var albums: [Album] = []
|
||||
public var tracks: [Track] = []
|
||||
|
||||
@@ -115,7 +115,7 @@ public actor MemoryLibrary: Library {
|
||||
|
||||
public func tracks(byArtist id: EntityID) -> [Track] {
|
||||
tracksByID.values.filter { $0.artistID == id }
|
||||
.sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) }
|
||||
.sorted { (plays($0), $1.title) > (plays($1), $0.title) }
|
||||
}
|
||||
|
||||
public func tracks(inGenre genre: String) -> [Track] {
|
||||
@@ -181,14 +181,59 @@ public actor MemoryLibrary: Library {
|
||||
public func search(_ query: String) -> SearchResults {
|
||||
let needle = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !needle.isEmpty else { return SearchResults() }
|
||||
func matches(_ text: String) -> Bool {
|
||||
text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||
func named(_ name: String) -> Int? {
|
||||
relevance(of: needle, in: name)
|
||||
}
|
||||
func credited(_ title: String, _ artist: String) -> Int? {
|
||||
[named(title), named(artist).map { $0 + 1 }].compactMap(\.self).min()
|
||||
}
|
||||
let popular = tracks().sorted { (plays($0), $1.title) > (plays($1), $0.title) }
|
||||
let artists = ranked(artists()) { named($0.name) }
|
||||
let tracks = Array(ranked(popular) { credited($0.title, $0.artistName) }.prefix(50))
|
||||
let albums = ranked(albums(sortedBy: .title)) { credited($0.title, $0.artistName) }
|
||||
let playlists = ranked(playlists()) { named($0.name) }
|
||||
let candidates = artists.prefix(3).map { $0.map(SearchHit.artist) }
|
||||
+ tracks.prefix(12).map { $0.map(SearchHit.track) }
|
||||
+ albums.prefix(4).map { $0.map(SearchHit.album) }
|
||||
+ playlists.prefix(3).map { $0.map(SearchHit.playlist) }
|
||||
var results = SearchResults()
|
||||
results.artists = artists().filter { matches($0.name) }
|
||||
results.albums = albums(sortedBy: .title).filter { matches($0.title) }
|
||||
results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50))
|
||||
results.playlists = playlists().filter { matches($0.name) }
|
||||
results.top = ranked(candidates) { $0.score }.map(\.item.item)
|
||||
results.artists = artists.map(\.item)
|
||||
results.albums = albums.map(\.item)
|
||||
results.tracks = tracks.map(\.item)
|
||||
results.playlists = playlists.map(\.item)
|
||||
return results
|
||||
}
|
||||
|
||||
private func plays(_ track: Track) -> Int {
|
||||
annotations[track.id]?.playCount ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private struct Ranked<Item> {
|
||||
let item: Item
|
||||
let score: Int
|
||||
|
||||
func map<Other>(_ transform: (Item) -> Other) -> Ranked<Other> {
|
||||
Ranked<Other>(item: transform(item), score: score)
|
||||
}
|
||||
}
|
||||
|
||||
private func ranked<Item>(_ items: [Item], by score: (Item) -> Int?) -> [Ranked<Item>] {
|
||||
items.enumerated()
|
||||
.compactMap { offset, item in score(item).map { (Ranked(item: item, score: $0), offset) } }
|
||||
.sorted { ($0.0.score, $0.1) < ($1.0.score, $1.1) }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func relevance(of needle: String, in text: String) -> Int? {
|
||||
let options: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
|
||||
guard let range = text.range(of: needle, options: options) else { return nil }
|
||||
if text.compare(needle, options: options) == .orderedSame {
|
||||
return 0
|
||||
}
|
||||
if range.lowerBound == text.startIndex {
|
||||
return 1
|
||||
}
|
||||
return text[text.index(before: range.lowerBound)].isLetter ? 3 : 2
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import Testing
|
||||
actor RecordingEngine: OutputEngine {
|
||||
nonisolated let events: AsyncStream<OutputEvent>
|
||||
nonisolated let continuation: AsyncStream<OutputEvent>.Continuation
|
||||
private(set) var volumes: [Double] = []
|
||||
private(set) var loaded: [QueueItem.ID] = []
|
||||
private(set) var prepared: [QueueItem.ID?] = []
|
||||
private(set) var isPlaying = false
|
||||
|
||||
init() {
|
||||
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||
@@ -21,8 +23,18 @@ actor RecordingEngine: OutputEngine {
|
||||
prepared.append(item?.id)
|
||||
}
|
||||
|
||||
func play() {}
|
||||
func pause() {}
|
||||
func setVolume(_ volume: Double) {
|
||||
volumes.append(volume)
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func seek(to _: TimeInterval) {}
|
||||
func stop() {}
|
||||
}
|
||||
@@ -183,3 +195,37 @@ private func tracks(_ count: Int) -> [Track] {
|
||||
#expect(state.status == .paused && state.anchor.rate == 0)
|
||||
#expect(await engine.loaded.count == 2)
|
||||
}
|
||||
|
||||
@Test func volumeReachesEngineAndSurvivesQueueChanges() async {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
await session.send(.setVolume(0.35))
|
||||
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||
await session.send(.next)
|
||||
#expect(await session.snapshot.volume == 0.35)
|
||||
#expect(await engine.volumes == [0.35])
|
||||
await session.send(.setVolume(-1))
|
||||
await session.send(.setVolume(2))
|
||||
await session.send(.setVolume(.nan))
|
||||
#expect(await engine.volumes == [0.35, 0, 1])
|
||||
#expect(await session.snapshot.volume == 1)
|
||||
}
|
||||
|
||||
@Test func skippingPastTheEndStopsTheEngine() async {
|
||||
let engine = RecordingEngine()
|
||||
let session = PlaybackSession(engine: engine)
|
||||
await session.send(.setQueue(tracks: tracks(1), startIndex: 0, shuffled: false))
|
||||
#expect(await engine.isPlaying)
|
||||
await session.send(.next)
|
||||
#expect(await session.snapshot.status == .paused)
|
||||
#expect(await !engine.isPlaying)
|
||||
}
|
||||
|
||||
@Test func clearingHistoryKeepsCurrentAndUpcoming() async {
|
||||
let session = PlaybackSession(engine: RecordingEngine())
|
||||
await session.send(.setQueue(tracks: tracks(4), startIndex: 2, shuffled: false))
|
||||
await session.send(.clearHistory)
|
||||
let state = await session.snapshot
|
||||
#expect(state.history.isEmpty)
|
||||
#expect(state.current?.track.title == "Track 2" && state.upcoming.count == 1)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
@testable import Playback
|
||||
import Testing
|
||||
|
||||
struct SampleRateMatcherTests {
|
||||
private let device = OutputDeviceInfo(id: 1, name: "Fake", nominalRate: 48000, availableRates: [44100, 48000, 96000])
|
||||
private let flac = AudioFormat(codec: .flac, sampleRate: 44100, bitDepth: 16)
|
||||
|
||||
@Test func settingOffLeavesDeviceAlone() {
|
||||
#expect(SampleRateMatcher(isEnabled: false).targetRate(for: flac, on: device) == nil)
|
||||
}
|
||||
|
||||
@Test func sameRateNeedsNoSwitch() {
|
||||
let format = AudioFormat(codec: .flac, sampleRate: 48000)
|
||||
#expect(SampleRateMatcher(isEnabled: true).targetRate(for: format, on: device) == nil)
|
||||
}
|
||||
|
||||
@Test func unavailableRateIsIgnored() {
|
||||
let format = AudioFormat(codec: .flac, sampleRate: 192_000)
|
||||
#expect(SampleRateMatcher(isEnabled: true).targetRate(for: format, on: device) == nil)
|
||||
}
|
||||
|
||||
@Test func unknownRateOrDeviceIsIgnored() {
|
||||
let matcher = SampleRateMatcher(isEnabled: true)
|
||||
#expect(matcher.targetRate(for: AudioFormat(codec: .mp3), on: device) == nil)
|
||||
#expect(matcher.targetRate(for: nil, on: device) == nil)
|
||||
#expect(matcher.targetRate(for: flac, on: nil) == nil)
|
||||
}
|
||||
|
||||
@Test func availableRateIsChosenExactly() {
|
||||
#expect(SampleRateMatcher(isEnabled: true).targetRate(for: flac, on: device) == 44100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Domain
|
||||
import Store
|
||||
import Testing
|
||||
|
||||
@Test func searchRanksExactArtistFirstAndFindsTheirSongs() async {
|
||||
let source = SourceID()
|
||||
func id(_ remote: String) -> EntityID {
|
||||
EntityID(source: source, remote: remote)
|
||||
}
|
||||
func track(_ remote: String, _ title: String, by artist: String, _ artistID: EntityID?) -> Change {
|
||||
.upsert(.track(Track(
|
||||
id: id(remote),
|
||||
title: title,
|
||||
albumID: nil,
|
||||
albumTitle: "",
|
||||
artistID: artistID,
|
||||
artistName: artist,
|
||||
duration: 1
|
||||
)))
|
||||
}
|
||||
let library = MemoryLibrary()
|
||||
await library.apply([
|
||||
.upsert(.artist(Artist(id: id("ar-1"), name: "Kai Angel"))),
|
||||
.upsert(.artist(Artist(id: id("ar-2"), name: "Angel Olsen"))),
|
||||
.upsert(.album(Album(id: id("al-1"), title: "Shh...", artistID: id("ar-1"), artistName: "Kai Angel"))),
|
||||
track("t-1", "andy warhol", by: "Kai Angel", id("ar-1")),
|
||||
track("t-2", "Fallen Angel", by: "Other", nil),
|
||||
])
|
||||
|
||||
let exact = await library.search("kai angel")
|
||||
#expect(exact.top.map(\.id) == [id("ar-1"), id("t-1"), id("al-1")])
|
||||
#expect(exact.tracks.map(\.id) == [id("t-1")])
|
||||
|
||||
let broad = await library.search("angel")
|
||||
#expect(broad.artists.map(\.id) == [id("ar-2"), id("ar-1")])
|
||||
#expect(broad.tracks.map(\.id) == [id("t-2"), id("t-1")])
|
||||
}
|
||||
Reference in new issue
Block a user