Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// Hand-wired singletons, shared by the phone UI, CarPlay and Siri.
|
||||
@MainActor
|
||||
final class AppGraph {
|
||||
static let shared = AppGraph()
|
||||
|
||||
let settings: Settings
|
||||
let net: Connectivity
|
||||
/// Library calls: kept on disk and served from there when the server is unreachable.
|
||||
let apiHttp: Http
|
||||
/// Same disk cache, but away from home it reads only from disk instead of waiting on LAN timeouts.
|
||||
let carHttp: Http
|
||||
/// Media ranges; MediaCache is the cache. Long timeout covers server-side Opus transcodes.
|
||||
let mediaSession: URLSession
|
||||
let plex: PlexClient
|
||||
let reach: HomeReach
|
||||
let carPlex: PlexClient
|
||||
let stems: StemServer
|
||||
let cache: MediaCache
|
||||
let eq: EqController
|
||||
let playback: PlaybackManager
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
let car: CarLibrary
|
||||
|
||||
private init() {
|
||||
let fm = FileManager.default
|
||||
let support = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
try? fm.createDirectory(at: support, withIntermediateDirectories: true)
|
||||
|
||||
settings = Settings()
|
||||
net = Connectivity()
|
||||
apiHttp = Http(connectTimeout: 6, readTimeout: 30, offlineCache: caches.appendingPathComponent("http"))
|
||||
carHttp = Http(connectTimeout: 6, readTimeout: 30, offlineCache: caches.appendingPathComponent("http"))
|
||||
let m = URLSessionConfiguration.default
|
||||
m.timeoutIntervalForRequest = 90
|
||||
m.timeoutIntervalForResource = 24 * 3600
|
||||
m.urlCache = nil
|
||||
m.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
mediaSession = URLSession(configuration: m)
|
||||
|
||||
plex = PlexClient(http: apiHttp, settings: settings)
|
||||
reach = HomeReach(settings: settings, net: net)
|
||||
let reach = reach
|
||||
carHttp.forceCache = { !reach.lastKnown }
|
||||
carPlex = PlexClient(http: carHttp, settings: settings)
|
||||
stems = StemServer(http: apiHttp, settings: settings, dir: support)
|
||||
|
||||
// Media lives outside Caches' purge-anytime rules and out of iCloud backups.
|
||||
var media = support.appendingPathComponent("media", isDirectory: true)
|
||||
try? fm.createDirectory(at: media, withIntermediateDirectories: true)
|
||||
var rv = URLResourceValues()
|
||||
rv.isExcludedFromBackup = true
|
||||
try? media.setResourceValues(rv)
|
||||
let settings = settings
|
||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||
|
||||
eq = EqController(dir: support)
|
||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex)
|
||||
car = CarLibrary(plex: carPlex, reach: reach, downloads: downloads, stems: stems, pm: playback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Intents
|
||||
|
||||
/// "Hey Siri, play … on Stemify". Handled in the app process, so it can start playback directly.
|
||||
final class PlayMediaHandler: NSObject, INPlayMediaIntentHandling {
|
||||
func resolveMediaItems(for intent: INPlayMediaIntent) async -> [INPlayMediaMediaItemResolutionResult] {
|
||||
let q = intent.mediaSearch?.mediaName ?? intent.mediaSearch?.artistName ?? intent.mediaSearch?.albumName ?? ""
|
||||
let item = INMediaItem(identifier: "search:\(q)", title: q.isEmpty ? "Downloads" : q, type: .music, artwork: nil)
|
||||
return INPlayMediaMediaItemResolutionResult.successes(with: [item])
|
||||
}
|
||||
|
||||
func handle(intent: INPlayMediaIntent) async -> INPlayMediaIntentResponse {
|
||||
let s = intent.mediaSearch
|
||||
let q = s?.mediaName ?? s?.artistName ?? s?.albumName ?? intent.mediaItems?.first?.title ?? ""
|
||||
let shuffle = intent.playShuffled == true
|
||||
let ok = await Self.play(q, shuffle)
|
||||
return INPlayMediaIntentResponse(code: ok ? .success : .failure, userActivity: nil)
|
||||
}
|
||||
|
||||
@MainActor static func play(_ query: String, _ shuffle: Bool) async -> Bool {
|
||||
let g = AppGraph.shared
|
||||
do {
|
||||
guard let r = try await g.car.resolveSearch(query) else {
|
||||
g.playback.report(query.isEmpty ? "Nothing downloaded yet" : "Nothing found for “\(query)”")
|
||||
return false
|
||||
}
|
||||
g.playback.playTracks(r.tracks, start: r.start, shuffle: r.shuffle || shuffle)
|
||||
return true
|
||||
} catch {
|
||||
g.playback.report(error is NotSignedIn ? "Sign in to Plex on your phone" : "Couldn't load that: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import AVFoundation
|
||||
import Intents
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
MainActor.assumeIsolated { _ = AppGraph.shared }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Phone windows go to SwiftUI; CarPlay gets its own scene delegate.
|
||||
func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
if session.role == .carTemplateApplication {
|
||||
let c = UISceneConfiguration(name: "CarPlay", sessionRole: session.role)
|
||||
c.delegateClass = CarPlayScene.self
|
||||
return c
|
||||
}
|
||||
return UISceneConfiguration(name: "Phone", sessionRole: session.role)
|
||||
}
|
||||
|
||||
/// Siri: "Play <album> on Stemify".
|
||||
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
|
||||
intent is INPlayMediaIntent ? PlayMediaHandler() : nil
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct StemifyApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.onChange(of: phase) { _, p in
|
||||
if p == .active { AppGraph.shared.eq.refreshRoute() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Album art: a memory cache over a big disk URLCache, so downloaded albums keep their covers
|
||||
/// offline. Stands in for Coil on Android.
|
||||
final class ArtLoader: @unchecked Sendable {
|
||||
static let shared = ArtLoader()
|
||||
|
||||
private let memory = NSCache<NSURL, UIImage>()
|
||||
private let session: URLSession
|
||||
private let lock = NSLock()
|
||||
private var inflight: [URL: Task<UIImage?, Never>] = [:]
|
||||
|
||||
init() {
|
||||
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("art", isDirectory: true)
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.urlCache = URLCache(memoryCapacity: 16 << 20, diskCapacity: 512 << 20, directory: dir)
|
||||
cfg.requestCachePolicy = .returnCacheDataElseLoad
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
session = URLSession(configuration: cfg)
|
||||
memory.totalCostLimit = 64 << 20
|
||||
}
|
||||
|
||||
func cached(_ url: URL) -> UIImage? { memory.object(forKey: url as NSURL) }
|
||||
|
||||
func image(_ url: URL) async -> UIImage? {
|
||||
if let img = cached(url) { return img }
|
||||
if let t = lock.withLock({ inflight[url] }) { return await t.value }
|
||||
let t = Task<UIImage?, Never> { [session] in
|
||||
guard let (data, resp) = try? await session.data(from: url),
|
||||
(resp as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
||||
let img = UIImage(data: data) else { return nil }
|
||||
let decoded = await img.byPreparingForDisplay() ?? img
|
||||
return decoded
|
||||
}
|
||||
lock.withLock { inflight[url] = t }
|
||||
let img = await t.value
|
||||
lock.withLock { inflight[url] = nil }
|
||||
if let img {
|
||||
memory.setObject(img, forKey: url as NSURL, cost: Int(img.size.width * img.size.height * img.scale * img.scale * 4))
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func prefetch(_ url: URL) {
|
||||
Task.detached(priority: .utility) { _ = await self.image(url) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(UIKit) && !SWIFT_PACKAGE
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct DownloadRecord: Codable, Equatable {
|
||||
var track: Track
|
||||
var keys: [String] = []
|
||||
var stems = false
|
||||
var done = false
|
||||
var error: String?
|
||||
var added = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
init(track: Track) { self.track = track }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
track = try c.decode(Track.self, forKey: AnyKey("track"))
|
||||
keys = (try? c.decode([String].self, forKey: AnyKey("keys"))) ?? []
|
||||
stems = c.bool("stems") ?? false
|
||||
done = c.bool("done") ?? false
|
||||
error = c.str("error")
|
||||
added = c.int64("added") ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline copies: the track's stems (or the original file) fully fetched into
|
||||
/// [MediaCache] and pinned, plus the metadata needed to browse them with no server.
|
||||
@Observable
|
||||
final class Downloads: @unchecked Sendable {
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let cache: MediaCache
|
||||
@ObservationIgnored private let net: Connectivity
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let lock = NSCondition()
|
||||
@ObservationIgnored private var state: [String: DownloadRecord] = [:]
|
||||
@ObservationIgnored private var prog: [String: Float] = [:]
|
||||
@ObservationIgnored private var queue: [String] = []
|
||||
@ObservationIgnored private var cancelled = Set<String>()
|
||||
@ObservationIgnored private let writeQueue = DispatchQueue(label: "downloads-save", qos: .utility)
|
||||
#if canImport(UIKit) && !SWIFT_PACKAGE
|
||||
@ObservationIgnored private var bgTask = UIBackgroundTaskIdentifier.invalid
|
||||
#endif
|
||||
static let waveBuckets = 256
|
||||
|
||||
private(set) var records: [String: DownloadRecord] = [:]
|
||||
/// ratingKey -> 0..1 for queued and running downloads.
|
||||
private(set) var progress: [String: Float] = [:]
|
||||
|
||||
init(settings: Settings, plex: PlexClient, stems: StemServer, cache: MediaCache, net: Connectivity, dir: URL) {
|
||||
self.settings = settings
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.cache = cache
|
||||
self.net = net
|
||||
file = dir.appendingPathComponent("downloads.json")
|
||||
if let data = try? Data(contentsOf: file), let list = try? JSONDecoder().decode([Lenient<DownloadRecord>].self, from: data) {
|
||||
for r in list.compactMap(\.value) { state[r.track.ratingKey] = r }
|
||||
}
|
||||
records = state
|
||||
for _ in 0..<2 {
|
||||
let t = Thread { [weak self] in self?.work() }
|
||||
t.qualityOfService = .utility
|
||||
t.start()
|
||||
}
|
||||
// Resume anything interrupted last time.
|
||||
state.values.filter { !$0.done && $0.error == nil }.forEach { enqueue($0.track.ratingKey) }
|
||||
}
|
||||
|
||||
func isDownloaded(_ ratingKey: String) -> Bool { records[ratingKey]?.done == true }
|
||||
|
||||
func download(_ tracks: [Track]) {
|
||||
lock.lock()
|
||||
let add = tracks.filter { t in
|
||||
guard let r = state[t.ratingKey] else { return true }
|
||||
return !r.done && r.error != nil
|
||||
}
|
||||
lock.unlock()
|
||||
if add.isEmpty { return }
|
||||
update { m in for t in add { m[t.ratingKey] = DownloadRecord(track: t) } }
|
||||
add.forEach { enqueue($0.ratingKey) }
|
||||
}
|
||||
|
||||
func remove(_ tracks: [Track]) {
|
||||
let keys = Set(tracks.map(\.ratingKey))
|
||||
lock.lock()
|
||||
cancelled.formUnion(keys)
|
||||
let gone = state.filter { keys.contains($0.key) }.values
|
||||
queue.removeAll { keys.contains($0) }
|
||||
for k in keys { prog[k] = nil }
|
||||
lock.unlock()
|
||||
update { m in for k in keys { m[k] = nil } }
|
||||
publish()
|
||||
DispatchQueue.global(qos: .utility).async { [cache] in gone.forEach { $0.keys.forEach(cache.unpin) } }
|
||||
}
|
||||
|
||||
func retryFailed() {
|
||||
lock.lock()
|
||||
let failed = state.values.filter { $0.error != nil }
|
||||
lock.unlock()
|
||||
update { m in for r in failed { m[r.track.ratingKey]?.error = nil } }
|
||||
failed.forEach { enqueue($0.track.ratingKey) }
|
||||
}
|
||||
|
||||
private func enqueue(_ key: String) {
|
||||
lock.lock()
|
||||
cancelled.remove(key)
|
||||
prog[key] = 0
|
||||
if !queue.contains(key) { queue.append(key) }
|
||||
lock.broadcast()
|
||||
lock.unlock()
|
||||
publish()
|
||||
}
|
||||
|
||||
private func isCancelled(_ key: String) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return cancelled.contains(key)
|
||||
}
|
||||
|
||||
private func work() {
|
||||
while true {
|
||||
lock.lock()
|
||||
while queue.isEmpty { lock.wait() }
|
||||
let key = queue.removeFirst()
|
||||
lock.unlock()
|
||||
run(key)
|
||||
}
|
||||
}
|
||||
|
||||
private func setProgress(_ key: String, _ f: Float?) {
|
||||
lock.lock()
|
||||
if f == nil || prog[key] != nil { prog[key] = f }
|
||||
lock.unlock()
|
||||
publish()
|
||||
}
|
||||
|
||||
private func run(_ key: String) {
|
||||
lock.lock()
|
||||
let rec = state[key]
|
||||
lock.unlock()
|
||||
guard let rec, !rec.done else { setProgress(key, nil); return }
|
||||
defer { setProgress(key, nil) }
|
||||
do {
|
||||
while settings.downloadsWifiOnly && !net.unmetered {
|
||||
if isCancelled(key) { return }
|
||||
Thread.sleep(forTimeInterval: 2)
|
||||
}
|
||||
let t = rec.track
|
||||
let entry = stems.find(t)
|
||||
let sources: [(String, URL?)]
|
||||
if let entry {
|
||||
let q = stems.effectiveQuality(settings.stemQuality)
|
||||
stems.prepare(entry, q)
|
||||
sources = entry.stems.map { (PlaybackManager.stemKey(entry, $0.file, $0.size, q), stems.fileUrl(entry, $0, q)) }
|
||||
} else {
|
||||
guard let part = t.partKey else { throw PlainError("No file on the server") }
|
||||
sources = [(PlaybackManager.plexKey(t), plex.partUrl(part))]
|
||||
}
|
||||
update { m in
|
||||
m[key]?.keys = sources.map(\.0)
|
||||
m[key]?.stems = entry != nil
|
||||
}
|
||||
for (i, (cacheKey, url)) in sources.enumerated() {
|
||||
let res = cache.acquire(cacheKey) { url }
|
||||
defer { cache.release(res) }
|
||||
res.pinned = true
|
||||
var lastPublish = Date.distantPast
|
||||
try res.downloadAll(progress: { got, len in
|
||||
let f: Float = len > 0 ? Float(got) / Float(len) : 0
|
||||
if Date().timeIntervalSince(lastPublish) > 0.25 {
|
||||
lastPublish = Date()
|
||||
self.setProgress(key, (Float(i) + f) / Float(sources.count))
|
||||
}
|
||||
}, cancelled: { self.isCancelled(key) })
|
||||
}
|
||||
if isCancelled(key) { return }
|
||||
if let entry { Task { _ = await stems.peaks(entry, buckets: Self.waveBuckets) } } // warms the HTTP cache for offline
|
||||
if let art = plex.artUrl(t.thumb, px: 600) { ArtLoader.shared.prefetch(art) }
|
||||
update { m in
|
||||
m[key]?.done = true
|
||||
m[key]?.error = nil
|
||||
}
|
||||
} catch {
|
||||
if isCancelled(key) { return }
|
||||
update { m in m[key]?.error = error.localizedDescription.isEmpty ? "Download failed" : error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ f: (inout [String: DownloadRecord]) -> Void) {
|
||||
lock.lock()
|
||||
f(&state)
|
||||
let snapshot = state
|
||||
lock.unlock()
|
||||
DispatchQueue.main.async { self.records = snapshot }
|
||||
writeQueue.async { [file] in
|
||||
let list = snapshot.values.sorted { $0.added < $1.added }
|
||||
if let data = try? JSONEncoder().encode(list) { try? data.write(to: file, options: .atomic) }
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
lock.lock()
|
||||
let p = prog
|
||||
lock.unlock()
|
||||
DispatchQueue.main.async {
|
||||
if self.progress != p { self.progress = p }
|
||||
self.holdBackground(!p.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS has no foreground service; ask for background time while downloads run. Playback
|
||||
/// keeps the app alive anyway, and anything cut off resumes on next launch.
|
||||
private func holdBackground(_ hold: Bool) {
|
||||
#if canImport(UIKit) && !SWIFT_PACKAGE
|
||||
if hold && bgTask == .invalid {
|
||||
bgTask = UIApplication.shared.beginBackgroundTask(withName: "downloads") { [weak self] in
|
||||
guard let self else { return }
|
||||
UIApplication.shared.endBackgroundTask(self.bgTask)
|
||||
self.bgTask = .invalid
|
||||
}
|
||||
} else if !hold && bgTask != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(bgTask)
|
||||
bgTask = .invalid
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct CacheMeta: Codable {
|
||||
var length: Int64 = -1
|
||||
var bits = ""
|
||||
var pinned = false
|
||||
var access: Int64 = 0
|
||||
}
|
||||
|
||||
struct MediaError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// A growable bit set, persisted as base64 of its little-endian bytes.
|
||||
struct Bits {
|
||||
private(set) var words: [UInt64] = []
|
||||
private(set) var count = 0
|
||||
|
||||
init() {}
|
||||
init(base64: String) {
|
||||
guard let d = Data(base64Encoded: base64) else { return }
|
||||
words = [UInt64](repeating: 0, count: (d.count + 7) / 8)
|
||||
for (i, byte) in d.enumerated() { words[i / 8] |= UInt64(byte) << UInt64((i % 8) * 8) }
|
||||
count = words.reduce(0) { $0 + $1.nonzeroBitCount }
|
||||
}
|
||||
|
||||
subscript(i: Int) -> Bool {
|
||||
let w = i >> 6
|
||||
return w < words.count && words[w] & (1 << UInt64(i & 63)) != 0
|
||||
}
|
||||
|
||||
mutating func set(_ i: Int) {
|
||||
let w = i >> 6
|
||||
if w >= words.count { words += [UInt64](repeating: 0, count: w - words.count + 1) }
|
||||
let m: UInt64 = 1 << UInt64(i & 63)
|
||||
if words[w] & m == 0 { words[w] |= m; count += 1 }
|
||||
}
|
||||
|
||||
mutating func clear(_ i: Int) {
|
||||
let w = i >> 6
|
||||
guard w < words.count else { return }
|
||||
let m: UInt64 = 1 << UInt64(i & 63)
|
||||
if words[w] & m != 0 { words[w] &= ~m; count -= 1 }
|
||||
}
|
||||
|
||||
/// Clears every bit at or after [from].
|
||||
mutating func truncate(_ from: Int) {
|
||||
var i = from
|
||||
while i < words.count * 64 { clear(i); i += 1 }
|
||||
}
|
||||
|
||||
func nextClear(_ from: Int) -> Int {
|
||||
var i = max(0, from)
|
||||
while self[i] { i += 1 }
|
||||
return i
|
||||
}
|
||||
|
||||
var base64: String {
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(words.count * 8)
|
||||
for w in words { for b in 0..<8 { bytes.append(UInt8((w >> UInt64(b * 8)) & 0xFF)) } }
|
||||
while bytes.last == 0 { bytes.removeLast() }
|
||||
return Data(bytes).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk cache for stems and original tracks. Each resource is a sparse file filled in
|
||||
/// BLOCK-sized pieces with HTTP range requests: reads block only on the bytes they need,
|
||||
/// a background prefetcher fills the rest in order, and seeks (FLAC without a seek table
|
||||
/// bisects the file) fetch out of order. Pinned resources are downloads and never evicted.
|
||||
@Observable
|
||||
final class MediaCache: @unchecked Sendable {
|
||||
static let block = 64 * 1024
|
||||
static let onDemandRun = 8
|
||||
static let prefetchRun = 32
|
||||
|
||||
struct Usage: Equatable { var cachedBytes: Int64; var pinnedBytes: Int64 }
|
||||
private final class CatalogEntry { var bytes: Int64; var access: Int64; var pinned: Bool
|
||||
init(_ b: Int64, _ a: Int64, _ p: Bool) { bytes = b; access = a; pinned = p } }
|
||||
|
||||
@ObservationIgnored private let dir: URL
|
||||
@ObservationIgnored let session: URLSession
|
||||
@ObservationIgnored private let limitBytes: () -> Int64
|
||||
@ObservationIgnored private let lock = NSRecursiveLock()
|
||||
@ObservationIgnored private var open: [String: CachedResource] = [:]
|
||||
@ObservationIgnored private var refs: [String: Int] = [:]
|
||||
@ObservationIgnored private var catalog: [String: CatalogEntry] = [:]
|
||||
@ObservationIgnored private let prefetchQueue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.maxConcurrentOperationCount = 6
|
||||
q.qualityOfService = .utility
|
||||
return q
|
||||
}()
|
||||
@ObservationIgnored private let trimQueue = DispatchQueue(label: "media-trim", qos: .utility)
|
||||
@ObservationIgnored private var trimQueued = false
|
||||
|
||||
private(set) var usage = Usage(cachedBytes: 0, pinnedBytes: 0)
|
||||
|
||||
init(dir: URL, session: URLSession, limitBytes: @escaping () -> Int64) {
|
||||
self.dir = dir
|
||||
self.session = session
|
||||
self.limitBytes = limitBytes
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
trimQueue.async { [self] in
|
||||
let files = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
|
||||
for name in files where name.hasSuffix(".meta") {
|
||||
guard let m = readMeta(dir.appendingPathComponent(name)) else { continue }
|
||||
let id = String(name.dropLast(5))
|
||||
lock.lock()
|
||||
if catalog[id] == nil {
|
||||
catalog[id] = CatalogEntry(Int64(Bits(base64: m.bits).count) * Int64(Self.block), m.access, m.pinned)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
publishUsage()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens (or reuses) a resource; pair every call with [release].
|
||||
func acquire(_ key: String, url: @escaping () -> URL?) -> CachedResource {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
refs[id, default: 0] += 1
|
||||
if let r = open[id] { return r }
|
||||
let r = CachedResource(id: id, dataFile: dir.appendingPathComponent("\(id).data"),
|
||||
metaFile: dir.appendingPathComponent("\(id).meta"), url: url, cache: self)
|
||||
open[id] = r
|
||||
return r
|
||||
}
|
||||
|
||||
func release(_ res: CachedResource) {
|
||||
lock.lock()
|
||||
let n = (refs[res.id] ?? 1) - 1
|
||||
if n > 0 {
|
||||
refs[res.id] = n
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
refs[res.id] = nil
|
||||
open[res.id] = nil
|
||||
lock.unlock()
|
||||
res.close()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
func isComplete(_ key: String) -> Bool {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
if let r = open[id] { lock.unlock(); return r.isComplete }
|
||||
lock.unlock()
|
||||
guard let m = readMeta(dir.appendingPathComponent("\(id).meta")) else { return false }
|
||||
return m.length >= 0 && Bits(base64: m.bits).count >= Self.blocksFor(m.length)
|
||||
&& FileManager.default.fileExists(atPath: dir.appendingPathComponent("\(id).data").path)
|
||||
}
|
||||
|
||||
func isPinned(_ key: String) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return catalog[Self.idFor(key)]?.pinned == true
|
||||
}
|
||||
|
||||
/// Unpins a download so normal eviction can reclaim it.
|
||||
func unpin(_ key: String) {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
let r = open[id]
|
||||
lock.unlock()
|
||||
if let r { r.pinned = false; return }
|
||||
let f = dir.appendingPathComponent("\(id).meta")
|
||||
guard var m = readMeta(f) else { return }
|
||||
m.pinned = false
|
||||
writeMeta(f, m)
|
||||
lock.lock()
|
||||
catalog[id]?.pinned = false
|
||||
lock.unlock()
|
||||
publishUsage()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
/// Deletes every resource that isn't pinned or in use.
|
||||
func clearUnpinned() {
|
||||
trimQueue.async { self.evict(keepBytes: 0) }
|
||||
}
|
||||
|
||||
func submitPrefetch(_ task: @escaping () -> Void) { prefetchQueue.addOperation(task) }
|
||||
|
||||
func update(_ id: String, bytes: Int64, access: Int64, pinned: Bool) {
|
||||
lock.lock()
|
||||
let e = catalog[id] ?? CatalogEntry(0, 0, false)
|
||||
catalog[id] = e
|
||||
e.bytes = bytes; e.access = access; e.pinned = pinned
|
||||
lock.unlock()
|
||||
publishUsage()
|
||||
}
|
||||
|
||||
private func publishUsage() {
|
||||
lock.lock()
|
||||
var all: Int64 = 0
|
||||
var pinned: Int64 = 0
|
||||
for e in catalog.values {
|
||||
all += e.bytes
|
||||
if e.pinned { pinned += e.bytes }
|
||||
}
|
||||
lock.unlock()
|
||||
let u = Usage(cachedBytes: all, pinnedBytes: pinned)
|
||||
DispatchQueue.main.async { if self.usage != u { self.usage = u } }
|
||||
}
|
||||
|
||||
func scheduleTrim() {
|
||||
lock.lock()
|
||||
if trimQueued { lock.unlock(); return }
|
||||
trimQueued = true
|
||||
lock.unlock()
|
||||
trimQueue.async { [self] in
|
||||
lock.lock(); trimQueued = false; lock.unlock()
|
||||
evict(keepBytes: limitBytes())
|
||||
}
|
||||
}
|
||||
|
||||
private func evict(keepBytes: Int64) {
|
||||
lock.lock()
|
||||
var total = catalog.values.reduce(Int64(0)) { $0 + $1.bytes }
|
||||
var victims: [String] = []
|
||||
for (id, e) in catalog.sorted(by: { $0.value.access < $1.value.access }) {
|
||||
if total <= keepBytes { break }
|
||||
if e.pinned || open[id] != nil { continue }
|
||||
victims.append(id)
|
||||
total -= e.bytes
|
||||
}
|
||||
victims.forEach { catalog[$0] = nil }
|
||||
lock.unlock()
|
||||
let fm = FileManager.default
|
||||
for id in victims {
|
||||
try? fm.removeItem(at: dir.appendingPathComponent("\(id).data"))
|
||||
try? fm.removeItem(at: dir.appendingPathComponent("\(id).meta"))
|
||||
}
|
||||
if !victims.isEmpty { publishUsage() }
|
||||
}
|
||||
|
||||
func readMeta(_ f: URL) -> CacheMeta? {
|
||||
(try? Data(contentsOf: f)).flatMap { try? JSONDecoder().decode(CacheMeta.self, from: $0) }
|
||||
}
|
||||
|
||||
func writeMeta(_ f: URL, _ m: CacheMeta) {
|
||||
if let d = try? JSONEncoder().encode(m) { try? d.write(to: f, options: .atomic) }
|
||||
}
|
||||
|
||||
static func idFor(_ key: String) -> String {
|
||||
Insecure.SHA1.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func blocksFor(_ length: Int64) -> Int { Int((length + Int64(block) - 1) / Int64(block)) }
|
||||
}
|
||||
|
||||
/// One cached remote file. Thread-safe; readers block until their bytes have arrived.
|
||||
final class CachedResource: @unchecked Sendable {
|
||||
let id: String
|
||||
private let dataFile: URL
|
||||
private let metaFile: URL
|
||||
private let url: () -> URL?
|
||||
private unowned let cache: MediaCache
|
||||
private let cond = NSCondition()
|
||||
private var bits: Bits
|
||||
private var claimed = Bits()
|
||||
private var handle: FileHandle?
|
||||
private let ioLock = NSLock()
|
||||
private var failure: Error?
|
||||
private var unsaved = 0
|
||||
private var prefetching = false
|
||||
private var lastRead = 0
|
||||
private var closed = false
|
||||
private(set) var length: Int64 = -1
|
||||
private var pinnedFlag = false
|
||||
private static let block = MediaCache.block
|
||||
|
||||
var pinned: Bool {
|
||||
get { cond.lock(); defer { cond.unlock() }; return pinnedFlag }
|
||||
set { cond.lock(); pinnedFlag = newValue; cond.unlock(); saveMeta() }
|
||||
}
|
||||
|
||||
init(id: String, dataFile: URL, metaFile: URL, url: @escaping () -> URL?, cache: MediaCache) {
|
||||
self.id = id
|
||||
self.dataFile = dataFile
|
||||
self.metaFile = metaFile
|
||||
self.url = url
|
||||
self.cache = cache
|
||||
if let m = cache.readMeta(metaFile), FileManager.default.fileExists(atPath: dataFile.path) {
|
||||
length = m.length
|
||||
bits = Bits(base64: m.bits)
|
||||
pinnedFlag = m.pinned
|
||||
} else {
|
||||
bits = Bits()
|
||||
try? FileManager.default.removeItem(at: dataFile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller holds cond.
|
||||
private var blockCount: Int { length < 0 ? Int.max : MediaCache.blocksFor(length) }
|
||||
|
||||
var isComplete: Bool {
|
||||
cond.lock(); defer { cond.unlock() }
|
||||
return length >= 0 && bits.count >= MediaCache.blocksFor(length)
|
||||
}
|
||||
|
||||
var cachedBytes: Int64 {
|
||||
cond.lock(); defer { cond.unlock() }
|
||||
return min(Int64(bits.count) * Int64(Self.block), length < 0 ? Int64.max : length)
|
||||
}
|
||||
|
||||
private func file() throws -> FileHandle {
|
||||
ioLock.lock(); defer { ioLock.unlock() }
|
||||
if closed { throw MediaError("closed") }
|
||||
if let h = handle { return h }
|
||||
if !FileManager.default.fileExists(atPath: dataFile.path) {
|
||||
FileManager.default.createFile(atPath: dataFile.path, contents: nil)
|
||||
}
|
||||
let h = try FileHandle(forUpdating: dataFile)
|
||||
handle = h
|
||||
return h
|
||||
}
|
||||
|
||||
private func pread(_ at: Int64, _ buf: UnsafeMutableRawPointer, _ n: Int) throws -> Int {
|
||||
let fd = try file().fileDescriptor
|
||||
let r = Darwin.pread(fd, buf, n, off_t(at))
|
||||
if r < 0 { throw MediaError("read failed") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func pwrite(_ at: Int64, _ buf: UnsafeRawPointer, _ n: Int) throws {
|
||||
let fd = try file().fileDescriptor
|
||||
var done = 0
|
||||
while done < n {
|
||||
let w = Darwin.pwrite(fd, buf + done, n - done, off_t(at) + off_t(done))
|
||||
if w <= 0 { throw MediaError("write failed") }
|
||||
done += w
|
||||
}
|
||||
}
|
||||
|
||||
/// Total size, fetching the first run if it isn't known yet.
|
||||
func size() throws -> Int64 {
|
||||
cond.lock()
|
||||
let known = length >= 0
|
||||
cond.unlock()
|
||||
if !known { try awaitBlock(0) }
|
||||
return length
|
||||
}
|
||||
|
||||
/// Reads up to [size] bytes at [pos] into [buf]; -1 at end of file. Blocks on the network if needed.
|
||||
func readAt(_ pos: Int64, _ buf: UnsafeMutableRawPointer, _ size: Int) throws -> Int {
|
||||
let total = try self.size()
|
||||
if pos >= total { return -1 }
|
||||
let n = Int(min(Int64(size), total - pos))
|
||||
if n <= 0 { return 0 }
|
||||
let first = Int(pos / Int64(Self.block))
|
||||
let last = Int((pos + Int64(n) - 1) / Int64(Self.block))
|
||||
for b in first...last { try awaitBlock(b) }
|
||||
cond.lock(); lastRead = last; cond.unlock()
|
||||
var got = 0
|
||||
while got < n {
|
||||
let r = try pread(pos + Int64(got), buf + got, n - got)
|
||||
if r <= 0 { break }
|
||||
got += r
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func readAt(_ pos: Int64, _ buf: inout [UInt8], _ off: Int, _ size: Int) throws -> Int {
|
||||
try buf.withUnsafeMutableBytes { try readAt(pos, $0.baseAddress! + off, size) }
|
||||
}
|
||||
|
||||
/// Starts filling the rest of the file in the background, from wherever reading is.
|
||||
func prefetch() {
|
||||
cond.lock()
|
||||
if prefetching || closed { cond.unlock(); return }
|
||||
prefetching = true
|
||||
cond.unlock()
|
||||
cache.submitPrefetch { [self] in
|
||||
// A reader that needs these bytes will retry and surface the error.
|
||||
try? fillAll(fromBlock: { self.lastRead }, cancelled: { self.closed })
|
||||
cond.lock(); prefetching = false; cond.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads every missing block on the calling thread.
|
||||
func downloadAll(progress: (Int64, Int64) -> Void, cancelled: () -> Bool) throws {
|
||||
try fillAll(fromBlock: { 0 }, cancelled: cancelled, progress: progress)
|
||||
}
|
||||
|
||||
private func fillAll(fromBlock: () -> Int, cancelled: () -> Bool, progress: (Int64, Int64) -> Void = { _, _ in }) throws {
|
||||
_ = try size()
|
||||
while !cancelled() {
|
||||
var run: ClosedRange<Int>?
|
||||
var waitFor = -1
|
||||
cond.lock()
|
||||
let count = blockCount
|
||||
if bits.count >= count { cond.unlock(); return }
|
||||
let start = min(max(fromBlock(), 0), count - 1)
|
||||
var b = bits.nextClear(start)
|
||||
if b >= count { b = bits.nextClear(0) }
|
||||
if b >= count { cond.unlock(); return }
|
||||
if claimed[b] {
|
||||
// Someone else is fetching here; look for a free block after it.
|
||||
var c = b
|
||||
while c < count && (bits[c] || claimed[c]) { c += 1 }
|
||||
if c >= count { waitFor = b } else { run = claimRun(c, MediaCache.prefetchRun) }
|
||||
} else {
|
||||
run = claimRun(b, MediaCache.prefetchRun)
|
||||
}
|
||||
cond.unlock()
|
||||
if let run { try fetch(run) }
|
||||
if waitFor >= 0 { try awaitBlock(waitFor) }
|
||||
progress(cachedBytes, length)
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitBlock(_ b: Int) throws {
|
||||
var waited: TimeInterval = 0
|
||||
while true {
|
||||
var run: ClosedRange<Int>?
|
||||
cond.lock()
|
||||
if closed { cond.unlock(); throw MediaError("closed") }
|
||||
if length >= 0 && b >= blockCount { cond.unlock(); return }
|
||||
if bits[b] { cond.unlock(); return }
|
||||
if !claimed[b] {
|
||||
run = claimRun(b, MediaCache.onDemandRun)
|
||||
} else {
|
||||
let before = Date()
|
||||
_ = cond.wait(until: Date().addingTimeInterval(1))
|
||||
waited += Date().timeIntervalSince(before)
|
||||
if waited > 60 {
|
||||
let f = failure
|
||||
cond.unlock()
|
||||
throw f ?? MediaError("timed out waiting for data")
|
||||
}
|
||||
}
|
||||
cond.unlock()
|
||||
if let run { try fetch(run) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims up to [max] consecutive missing, unclaimed blocks from [b]. Caller holds the lock.
|
||||
private func claimRun(_ b: Int, _ max: Int) -> ClosedRange<Int> {
|
||||
var end = b
|
||||
let count = blockCount
|
||||
while end + 1 < count && end + 1 - b < max && !bits[end + 1] && !claimed[end + 1] { end += 1 }
|
||||
for i in b...end { claimed.set(i) }
|
||||
return b...end
|
||||
}
|
||||
|
||||
/// Synchronous range GET; the body of one run is at most a couple of MB.
|
||||
private func get(_ req: URLRequest) throws -> (Data, HTTPURLResponse) {
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var out: (Data?, URLResponse?, Error?)
|
||||
let task = cache.session.dataTask(with: req) { d, r, e in out = (d, r, e); sem.signal() }
|
||||
task.resume()
|
||||
sem.wait()
|
||||
if let e = out.2 { throw e }
|
||||
guard let resp = out.1 as? HTTPURLResponse else { throw MediaError("no response") }
|
||||
return (out.0 ?? Data(), resp)
|
||||
}
|
||||
|
||||
private func fetch(_ run: ClosedRange<Int>) throws {
|
||||
var block = run.lowerBound
|
||||
let B = Int64(Self.block)
|
||||
defer {
|
||||
cond.lock()
|
||||
// Release whatever this run didn't deliver, including blocks past EOF.
|
||||
if block <= run.upperBound { for b in block...run.upperBound { claimed.clear(b) } }
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
}
|
||||
do {
|
||||
guard let u = url() else { throw MediaError("no URL") }
|
||||
let start = Int64(run.lowerBound) * B
|
||||
cond.lock()
|
||||
let len = length
|
||||
cond.unlock()
|
||||
let endExclusive = len >= 0 ? min(Int64(run.upperBound + 1) * B, len) : Int64(run.upperBound + 1) * B
|
||||
var req = URLRequest(url: u)
|
||||
req.setValue("bytes=\(start)-\(endExclusive - 1)", forHTTPHeaderField: "Range")
|
||||
let (body, resp) = try get(req)
|
||||
var skip: Int64 = 0
|
||||
var lastBlock = run.upperBound
|
||||
switch resp.statusCode {
|
||||
case 206:
|
||||
if let cr = resp.value(forHTTPHeaderField: "Content-Range"),
|
||||
let slash = cr.lastIndex(of: "/"), let total = Int64(cr[cr.index(after: slash)...]) {
|
||||
learnLength(total)
|
||||
}
|
||||
case 200:
|
||||
// No range support: the body is the whole file, so keep all of it.
|
||||
learnLength(Int64(body.count))
|
||||
skip = 0
|
||||
block = 0
|
||||
lastBlock = MediaCache.blocksFor(Int64(body.count)) - 1
|
||||
default:
|
||||
throw MediaError("HTTP \(resp.statusCode) fetching media")
|
||||
}
|
||||
cond.lock()
|
||||
let length = self.length
|
||||
let count = blockCount
|
||||
cond.unlock()
|
||||
if length < 0 { throw MediaError("server sent no length") }
|
||||
let bodyOffset = resp.statusCode == 200 ? Int64(0) : start
|
||||
lastBlock = min(lastBlock, count - 1)
|
||||
try body.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||
while block <= lastBlock {
|
||||
let blockStart = Int64(block) * B
|
||||
let want = Int(min(blockStart + B, length) - blockStart)
|
||||
let from = Int(blockStart - bodyOffset + skip)
|
||||
if from < 0 || from + want > raw.count { throw MediaError("connection ended early") }
|
||||
cond.lock()
|
||||
let have = bits[block]
|
||||
cond.unlock()
|
||||
if !have { try pwrite(blockStart, raw.baseAddress! + from, want) }
|
||||
cond.lock()
|
||||
bits.set(block)
|
||||
claimed.clear(block)
|
||||
failure = nil
|
||||
cond.broadcast()
|
||||
unsaved += 1
|
||||
let save = unsaved >= 32
|
||||
cond.unlock()
|
||||
block += 1
|
||||
if save || (block > lastBlock && isComplete) { saveMeta() }
|
||||
}
|
||||
}
|
||||
if resp.statusCode == 200 { block = run.upperBound + 1 }
|
||||
} catch {
|
||||
cond.lock()
|
||||
failure = error
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func learnLength(_ total: Int64) {
|
||||
cond.lock()
|
||||
if length != total {
|
||||
length = total
|
||||
bits.truncate(MediaCache.blocksFor(total))
|
||||
}
|
||||
cond.unlock()
|
||||
}
|
||||
|
||||
private func saveMeta() {
|
||||
cond.lock()
|
||||
unsaved = 0
|
||||
let m = CacheMeta(length: length, bits: bits.base64, pinned: pinnedFlag, access: Int64(Date().timeIntervalSince1970 * 1000))
|
||||
let bytes = min(Int64(bits.count) * Int64(Self.block), length < 0 ? 0 : length)
|
||||
cond.unlock()
|
||||
cache.writeMeta(metaFile, m)
|
||||
cache.update(id, bytes: bytes, access: m.access, pinned: m.pinned)
|
||||
}
|
||||
|
||||
func close() {
|
||||
cond.lock()
|
||||
closed = true
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
saveMeta()
|
||||
ioLock.lock()
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
ioLock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import Foundation
|
||||
|
||||
/// What a pick or a voice request turns into: a queue, where to start, and whether to shuffle it.
|
||||
struct Resolved {
|
||||
var tracks: [Track]
|
||||
var start = 0
|
||||
var shuffle = false
|
||||
}
|
||||
|
||||
/// A row in the car's lists; CarPlayScene turns these into CPListItems.
|
||||
struct CarItem {
|
||||
enum Art: Equatable {
|
||||
case none
|
||||
case thumb(String)
|
||||
case letter(String)
|
||||
case shuffle
|
||||
}
|
||||
|
||||
var id: String
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var art: Art = .none
|
||||
var playable: Bool
|
||||
/// Section header the row sits under, nil for the list's first unnamed section.
|
||||
var group: String?
|
||||
var downloaded = false
|
||||
var trackKey: String?
|
||||
}
|
||||
|
||||
/// The browse tree the car draws. CarPlay renders the lists itself, so designing for the car
|
||||
/// means shaping this tree: four tabs, shallow folders, Shuffle as the first row, A–Z folders
|
||||
/// instead of long lists.
|
||||
///
|
||||
/// Away from home (Plex and stemserve unreachable) everything comes from the HTTP cache and
|
||||
/// only songs that will actually play offline are listed, with Downloads as the first tab.
|
||||
final class CarLibrary: @unchecked Sendable {
|
||||
private static let cacheNs: UInt64 = 120_000_000_000
|
||||
|
||||
private let plex: PlexClient
|
||||
private let reach: HomeReach
|
||||
private let downloads: Downloads
|
||||
private let stems: StemServer
|
||||
private let pm: PlaybackManager
|
||||
|
||||
private struct Cached { let at: UInt64; let tracks: [Track] }
|
||||
private let lock = UnfairLock()
|
||||
private var trackCache: [String: Cached] = [:]
|
||||
private var order: [String] = []
|
||||
|
||||
init(plex: PlexClient, reach: HomeReach, downloads: Downloads, stems: StemServer, pm: PlaybackManager) {
|
||||
self.plex = plex
|
||||
self.reach = reach
|
||||
self.downloads = downloads
|
||||
self.stems = stems
|
||||
self.pm = pm
|
||||
}
|
||||
|
||||
private func away() async -> Bool { !(await reach.reachable()) }
|
||||
|
||||
// ---- tree -----------------------------------------------------------------------
|
||||
|
||||
/// Tab ids and titles, Downloads first when away from home.
|
||||
func tabs() async -> [(String, String)] {
|
||||
let tabs = [(CarIds.playlists, "Playlists"), (CarIds.albums, "Albums"), (CarIds.artists, "Artists"), (CarIds.downloads, "Downloads")]
|
||||
return await away() ? [tabs[3]] + tabs.prefix(3) : tabs
|
||||
}
|
||||
|
||||
/// Children of [id], or nil if the id means nothing to us.
|
||||
func children(_ id: String) async throws -> [CarItem]? {
|
||||
let away = await away()
|
||||
switch id {
|
||||
case CarIds.downloads: return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
|
||||
case CarIds.playlists: return try await playlists(away)
|
||||
case CarIds.albums: return try await albumsTab(away)
|
||||
case CarIds.artists: return try await artistsTab(away)
|
||||
default:
|
||||
guard let (kind, key) = CarIds.split(id) else { return nil }
|
||||
switch kind {
|
||||
case "playlist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
|
||||
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
|
||||
case "artist": return try await artistPage(key, away)
|
||||
case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) }
|
||||
case "artists": return try await artistsFor(away).filter { CarTree.letterOf($0.sortTitle) == key }.map { artistItem($0) }
|
||||
case "search": return try await searchItems(key)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playlists(_ away: Bool) async throws -> [CarItem] {
|
||||
let all = try await plex.playlists()
|
||||
var shown: [Playlist] = []
|
||||
if away {
|
||||
for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) }
|
||||
} else {
|
||||
shown = all
|
||||
}
|
||||
return shown.map { playlistItem($0) }
|
||||
}
|
||||
|
||||
private func albumsTab(_ away: Bool) async throws -> [CarItem] {
|
||||
let albums = try await albumsFor(away)
|
||||
if away || albums.count <= CarTree.flatMax { return albums.map { albumItem($0) } }
|
||||
let recent = (try? await plex.recentlyAdded(count: CarTree.recentAlbums)) ?? []
|
||||
return recent.map { albumItem($0, group: "Recently added") } +
|
||||
CarTree.buckets(albums, \.title).map { letter, list in
|
||||
CarItem(id: CarIds.albumLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "album", "albums"),
|
||||
art: .letter(letter), playable: false, group: "All albums")
|
||||
}
|
||||
}
|
||||
|
||||
private func artistsTab(_ away: Bool) async throws -> [CarItem] {
|
||||
let artists = try await artistsFor(away)
|
||||
if artists.count <= CarTree.flatMax { return artists.map { artistItem($0) } }
|
||||
return CarTree.buckets(artists, \.sortTitle).map { letter, list in
|
||||
CarItem(id: CarIds.artistLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "artist", "artists"),
|
||||
art: .letter(letter), playable: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func artistPage(_ key: String, _ away: Bool) async throws -> [CarItem] {
|
||||
let id = CarIds.artist(key)
|
||||
let albums = away ? albumsOf(downloadedTracks().filter { $0.artistKey == key }) : try await plex.artistAlbums(key)
|
||||
let shuffle = ((try? await tracksOf(id))?.isEmpty == false) ? [shuffleItem(id, "Shuffle artist")] : []
|
||||
return shuffle + albums.map { albumItem($0) }
|
||||
}
|
||||
|
||||
/// Away from home, the library is what's downloaded.
|
||||
private func albumsFor(_ away: Bool) async throws -> [Album] {
|
||||
away ? albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() } : try await plex.albums()
|
||||
}
|
||||
|
||||
private func artistsFor(_ away: Bool) async throws -> [Artist] {
|
||||
guard away else { return try await plex.artists() }
|
||||
var seen = Set<String>()
|
||||
return downloadedTracks().compactMap { t -> Artist? in
|
||||
guard let k = t.artistKey, seen.insert(k).inserted else { return nil }
|
||||
return Artist(ratingKey: k, title: t.artist, sortTitle: t.artist)
|
||||
}.sorted { $0.sortTitle.lowercased() < $1.sortTitle.lowercased() }
|
||||
}
|
||||
|
||||
private func albumsOf(_ tracks: [Track]) -> [Album] {
|
||||
var seen = Set<String>()
|
||||
return tracks.compactMap { t in
|
||||
guard let k = t.albumKey, seen.insert(k).inserted else { return nil }
|
||||
return Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadedTracks() -> [Track] {
|
||||
downloads.records.values.filter(\.done).map(\.track).sorted {
|
||||
($0.artist.lowercased(), $0.album.lowercased(), $0.disc ?? 0, $0.index ?? 0) <
|
||||
($1.artist.lowercased(), $1.album.lowercased(), $1.disc ?? 0, $1.index ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A container's songs, minus those that can't play right now.
|
||||
private func tracksOf(_ container: String) async throws -> [Track] {
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let away = await away()
|
||||
// Home and away lists differ (away keeps only what plays offline), so they cache apart.
|
||||
let ck = "\(away)|\(container)"
|
||||
if let hit = lock.with({ trackCache[ck] }), now - hit.at < Self.cacheNs, !hit.tracks.isEmpty { return hit.tracks }
|
||||
let all: [Track]
|
||||
if container == CarIds.downloads {
|
||||
all = downloadedTracks()
|
||||
} else {
|
||||
guard let (kind, key) = CarIds.split(container) else { return [] }
|
||||
switch kind {
|
||||
case "playlist": all = try await plex.playlistTracks(key)
|
||||
case "album": all = try await plex.albumTracks(key)
|
||||
case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key)
|
||||
case "search": all = try await searchTracks(key, away)
|
||||
default: all = []
|
||||
}
|
||||
}
|
||||
let playable = away ? await MainActor.run { all.filter { pm.playableOffline($0) } } : all
|
||||
lock.with {
|
||||
trackCache[ck] = Cached(at: now, tracks: playable)
|
||||
order.removeAll { $0 == ck }
|
||||
order.append(ck)
|
||||
if order.count > 24 { trackCache[order.removeFirst()] = nil }
|
||||
}
|
||||
return playable
|
||||
}
|
||||
|
||||
// ---- picks -----------------------------------------------------------------------
|
||||
|
||||
/// Turns a tapped row into a queue.
|
||||
func resolve(_ id: String) async throws -> Resolved? {
|
||||
guard let pick = CarIds.parsePick(id) else { return nil }
|
||||
let tracks = try await tracksOf(pick.container)
|
||||
if tracks.isEmpty { return nil }
|
||||
switch pick {
|
||||
case .shuffle: return Resolved(tracks: tracks, shuffle: true)
|
||||
case .song(_, let key): return Resolved(tracks: tracks, start: tracks.firstIndex { $0.ratingKey == key } ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Hey Siri, play … on Stemify". Prefers a playlist, then an artist (shuffled), then an
|
||||
/// album whose name matches; otherwise plays the song hits. Away from home it searches downloads.
|
||||
func resolveSearch(_ query: String) async throws -> Resolved? {
|
||||
if query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
let dl = try await tracksOf(CarIds.downloads)
|
||||
return dl.isEmpty ? nil : Resolved(tracks: dl, shuffle: true)
|
||||
}
|
||||
if await away() {
|
||||
let hits = offlineSearch(query)
|
||||
return hits.isEmpty ? nil : Resolved(tracks: hits)
|
||||
}
|
||||
let r = try await plex.search(query)
|
||||
if let p = r.playlists.first(where: { CarTree.matches($0.title, query) }),
|
||||
let t = try? await tracksOf(CarIds.playlist(p.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
|
||||
if let a = r.artists.first(where: { CarTree.matches($0.title, query) }),
|
||||
let t = try? await tracksOf(CarIds.artist(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t, shuffle: true) }
|
||||
if let a = r.albums.first(where: { CarTree.matches($0.title, query) }),
|
||||
let t = try? await tracksOf(CarIds.album(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
|
||||
if !r.tracks.isEmpty { return Resolved(tracks: r.tracks) }
|
||||
if let p = r.playlists.first { return Resolved(tracks: try await tracksOf(CarIds.playlist(p.ratingKey))) }
|
||||
if let a = r.artists.first { return Resolved(tracks: try await tracksOf(CarIds.artist(a.ratingKey)), shuffle: true) }
|
||||
if let a = r.albums.first { return Resolved(tracks: try await tracksOf(CarIds.album(a.ratingKey))) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func searchTracks(_ query: String, _ away: Bool) async throws -> [Track] {
|
||||
away ? offlineSearch(query) : try await plex.search(query).tracks
|
||||
}
|
||||
|
||||
func offlineSearch(_ query: String) -> [Track] {
|
||||
let q = CarTree.norm(query)
|
||||
if q.isEmpty { return [] }
|
||||
let dl = downloadedTracks()
|
||||
let byTitle = dl.filter { CarTree.norm($0.title).contains(q) }
|
||||
let byOther = dl.filter { !CarTree.norm($0.title).contains(q) && (CarTree.norm($0.artist).contains(q) || CarTree.norm($0.album).contains(q)) }
|
||||
return byTitle + byOther
|
||||
}
|
||||
|
||||
/// Results for a search (from Siri, or the phone's last query).
|
||||
func searchItems(_ query: String) async throws -> [CarItem] {
|
||||
let container = CarIds.search(query)
|
||||
if await away() { return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Downloaded songs") }
|
||||
let r = try await plex.search(query)
|
||||
return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Songs") +
|
||||
r.artists.map { artistItem($0, group: "Artists") } +
|
||||
r.albums.map { albumItem($0, group: "Albums") } +
|
||||
r.playlists.map { playlistItem($0, group: "Playlists") }
|
||||
}
|
||||
|
||||
// ---- items -----------------------------------------------------------------------
|
||||
|
||||
private func songs(_ container: String, _ tracks: [Track], shuffleRow: Bool, showArtist: Bool, group: String? = nil) async -> [CarItem] {
|
||||
let rows = await MainActor.run { tracks.map { songItem(container, $0, showArtist, group) } }
|
||||
return shuffleRow && tracks.count > 1 ? [shuffleItem(container, "Shuffle all", group)] + rows : rows
|
||||
}
|
||||
|
||||
private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem {
|
||||
// Stems are the norm, so only the exceptions are marked.
|
||||
let noStems = stems.index.size > 0 && !pm.hasStems(t)
|
||||
let sub = [noStems ? "No stems" : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ")
|
||||
return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub,
|
||||
art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group,
|
||||
downloaded: pm.playableOffline(t), trackKey: t.ratingKey)
|
||||
}
|
||||
|
||||
private func shuffleItem(_ container: String, _ title: String, _ group: String? = nil) -> CarItem {
|
||||
CarItem(id: CarIds.shuffle(container), title: title, art: .shuffle, playable: true, group: group)
|
||||
}
|
||||
|
||||
private func playlistItem(_ p: Playlist, group: String? = nil) -> CarItem {
|
||||
CarItem(id: CarIds.playlist(p.ratingKey), title: p.title, subtitle: CarTree.countLabel(p.trackCount, "song", "songs"),
|
||||
art: artOr(p.thumb, p.title), playable: false, group: group)
|
||||
}
|
||||
|
||||
private func albumItem(_ a: Album, group: String? = nil) -> CarItem {
|
||||
CarItem(id: CarIds.album(a.ratingKey), title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false, group: group)
|
||||
}
|
||||
|
||||
private func artistItem(_ a: Artist, group: String? = nil) -> CarItem {
|
||||
CarItem(id: CarIds.artist(a.ratingKey), title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false, group: group)
|
||||
}
|
||||
|
||||
/// Plex art, or a letter tile so rows never sit blank.
|
||||
private func artOr(_ thumb: String?, _ title: String) -> CarItem.Art {
|
||||
thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(title))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import CarPlay
|
||||
import UIKit
|
||||
|
||||
/// The CarPlay UI: a tab bar of browse lists over [CarLibrary], and the system Now Playing
|
||||
/// screen with Vocals and Mix buttons next to shuffle and repeat.
|
||||
@MainActor
|
||||
final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNowPlayingTemplateObserver {
|
||||
private var ic: CPInterfaceController?
|
||||
private var g: AppGraph { AppGraph.shared }
|
||||
private var listenerId: UUID?
|
||||
private var messageId: UUID?
|
||||
private var tabIds: [String] = []
|
||||
private var lastHome: Bool?
|
||||
private var alertTask: Task<Void, Never>?
|
||||
|
||||
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect ic: CPInterfaceController) {
|
||||
self.ic = ic
|
||||
let np = CPNowPlayingTemplate.shared
|
||||
np.isUpNextButtonEnabled = true
|
||||
np.isAlbumArtistButtonEnabled = false
|
||||
np.add(self)
|
||||
listenerId = g.playback.addListener { [weak self] in self?.updateNowPlaying() }
|
||||
messageId = g.playback.addMessageListener { [weak self] in self?.alert($0) }
|
||||
updateNowPlaying()
|
||||
g.reach.onChange { [weak self] in self?.reloadRoot() }
|
||||
reloadRoot(force: true)
|
||||
}
|
||||
|
||||
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didDisconnectInterfaceController ic: CPInterfaceController) {
|
||||
listenerId.map(g.playback.removeListener)
|
||||
listenerId = nil
|
||||
messageId.map(g.playback.removeListener)
|
||||
messageId = nil
|
||||
CPNowPlayingTemplate.shared.remove(self)
|
||||
self.ic = nil
|
||||
}
|
||||
|
||||
// ---- browse ------------------------------------------------------------------------
|
||||
|
||||
/// Rebuilds the tabs when home reachability flips (Downloads moves to the front away from home).
|
||||
private func reloadRoot(force: Bool = false) {
|
||||
Task {
|
||||
let home = await g.reach.reachable()
|
||||
guard force || home != lastHome, let ic else { return }
|
||||
lastHome = home
|
||||
let tabs = await g.car.tabs()
|
||||
tabIds = tabs.map(\.0)
|
||||
let templates: [CPTemplate] = tabs.prefix(CPTabBarTemplate.maximumTabCount).map { id, title in
|
||||
let t = CPListTemplate(title: title, sections: [])
|
||||
t.tabTitle = title
|
||||
t.tabImage = UIImage(systemName: Self.tabIcon(id))
|
||||
t.userInfo = id
|
||||
load(t, id)
|
||||
return t
|
||||
}
|
||||
let bar = CPTabBarTemplate(templates: templates)
|
||||
ic.setRootTemplate(bar, animated: false, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func tabIcon(_ id: String) -> String {
|
||||
switch id {
|
||||
case CarIds.playlists: "music.note.list"
|
||||
case CarIds.albums: "square.stack"
|
||||
case CarIds.artists: "music.mic"
|
||||
default: "arrow.down.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func push(_ id: String, title: String) {
|
||||
let t = CPListTemplate(title: title, sections: [])
|
||||
t.userInfo = id
|
||||
load(t, id)
|
||||
ic?.pushTemplate(t, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func load(_ t: CPListTemplate, _ id: String) {
|
||||
t.emptyViewTitleVariants = ["Loading…"]
|
||||
Task {
|
||||
do {
|
||||
let items = try await g.car.children(id) ?? []
|
||||
t.emptyViewTitleVariants = [id == CarIds.downloads ? "No downloads yet" : "Nothing here"]
|
||||
t.emptyViewSubtitleVariants = id == CarIds.downloads ? ["Download music on your phone to play it anywhere"] : []
|
||||
t.updateSections(sections(items))
|
||||
} catch {
|
||||
t.emptyViewTitleVariants = [error is NotSignedIn ? "Sign in to Plex on your phone"
|
||||
: g.reach.lastKnown ? "Couldn't reach Plex" : "Not downloaded – connect to home Wi-Fi"]
|
||||
t.emptyViewSubtitleVariants = []
|
||||
t.updateSections([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sections(_ items: [CarItem]) -> [CPListSection] {
|
||||
let maxItems = CPListTemplate.maximumItemCount
|
||||
var out: [(String?, [CPListItem])] = []
|
||||
var count = 0
|
||||
for it in items where count < maxItems {
|
||||
let row = listItem(it)
|
||||
if let last = out.last, last.0 == it.group { out[out.count - 1].1.append(row) } else { out.append((it.group, [row])) }
|
||||
count += 1
|
||||
}
|
||||
return out.prefix(CPListTemplate.maximumSectionCount).map { header, rows in
|
||||
header.map { CPListSection(items: rows, header: $0, sectionIndexTitle: nil) } ?? CPListSection(items: rows)
|
||||
}
|
||||
}
|
||||
|
||||
private func listItem(_ it: CarItem) -> CPListItem {
|
||||
let row = CPListItem(text: it.title, detailText: it.subtitle, image: placeholder(it.art),
|
||||
accessoryImage: it.downloaded ? UIImage(systemName: "arrow.down.circle.fill") : nil,
|
||||
accessoryType: it.playable ? .none : .disclosureIndicator)
|
||||
if let key = it.trackKey {
|
||||
row.isPlaying = g.playback.current?.track.ratingKey == key
|
||||
row.playingIndicatorLocation = .trailing
|
||||
}
|
||||
if case .thumb(let thumb) = it.art, let url = g.carPlex.artUrl(thumb, px: 240) {
|
||||
if let img = ArtLoader.shared.cached(url) {
|
||||
row.setImage(img)
|
||||
} else {
|
||||
Task { if let img = await ArtLoader.shared.image(url) { row.setImage(img) } }
|
||||
}
|
||||
}
|
||||
row.handler = { [weak self] _, done in
|
||||
guard let self else { return done() }
|
||||
if it.playable { self.pick(it.id, done) } else { self.push(it.id, title: it.title); done() }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
private func placeholder(_ art: CarItem.Art) -> UIImage? {
|
||||
switch art {
|
||||
case .none: nil
|
||||
case .shuffle: UIImage(systemName: "shuffle")
|
||||
case .letter(let l): Self.letterTile(l)
|
||||
case .thumb: nil
|
||||
}
|
||||
}
|
||||
|
||||
private func pick(_ id: String, _ done: @escaping () -> Void) {
|
||||
Task {
|
||||
do {
|
||||
if let r = try await g.car.resolve(id) {
|
||||
g.playback.playTracks(r.tracks, start: r.start, shuffle: r.shuffle)
|
||||
showNowPlaying()
|
||||
} else {
|
||||
g.playback.report("That isn't available offline")
|
||||
}
|
||||
} catch {
|
||||
g.playback.report(error is NotSignedIn ? "Sign in to Plex on your phone" : "Couldn't load that: \(error.localizedDescription)")
|
||||
}
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
private func showNowPlaying() {
|
||||
guard let ic, ic.topTemplate !== CPNowPlayingTemplate.shared else { return }
|
||||
ic.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// ---- now playing -------------------------------------------------------------------
|
||||
|
||||
private func updateNowPlaying() {
|
||||
let np = g.nowPlaying, pm = g.playback
|
||||
var buttons: [CPNowPlayingButton] = []
|
||||
if np.vocalsAvailable {
|
||||
let silent = np.vocalsSilent
|
||||
let img = UIImage(systemName: silent ? "mic.slash.fill" : "mic.fill")!
|
||||
buttons.append(CPNowPlayingImageButton(image: img) { [weak self] _ in self?.g.nowPlaying.toggleVocals() })
|
||||
}
|
||||
if pm.source == .Stems && pm.stemNames.count > 1 {
|
||||
buttons.append(CPNowPlayingImageButton(image: UIImage(systemName: "slider.vertical.3")!) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let label = self.g.nowPlaying.nextMix.label
|
||||
self.g.nowPlaying.cycleMix()
|
||||
self.alert("Mix: \(label)")
|
||||
})
|
||||
}
|
||||
let shuffle = CPNowPlayingShuffleButton { [weak self] _ in self?.g.playback.toggleShuffle() }
|
||||
shuffle.isSelected = pm.shuffle
|
||||
let repeatButton = CPNowPlayingRepeatButton { [weak self] _ in self?.g.playback.cycleRepeat() }
|
||||
repeatButton.isSelected = pm.repeatMode != .Off
|
||||
buttons += [shuffle, repeatButton]
|
||||
CPNowPlayingTemplate.shared.updateNowPlayingButtons(buttons)
|
||||
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = pm.queue.count > 1
|
||||
}
|
||||
|
||||
nonisolated func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {
|
||||
MainActor.assumeIsolated { showQueue() }
|
||||
}
|
||||
|
||||
nonisolated func nowPlayingTemplateAlbumArtistButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {}
|
||||
|
||||
private func showQueue() {
|
||||
let pm = g.playback
|
||||
let start = max(pm.index, 0)
|
||||
let rows: [CPListItem] = pm.queue.enumerated().dropFirst(start).prefix(CPListTemplate.maximumItemCount).map { i, item in
|
||||
let t = item.track
|
||||
let row = CPListItem(text: t.title, detailText: t.artist)
|
||||
row.isPlaying = i == pm.index
|
||||
if let url = g.plex.artUrl(t.thumb, px: 240) {
|
||||
Task { if let img = await ArtLoader.shared.image(url) { row.setImage(img) } }
|
||||
}
|
||||
row.handler = { [weak self] _, done in
|
||||
self?.g.playback.skipTo(i)
|
||||
self?.ic?.popTemplate(animated: true, completion: nil)
|
||||
done()
|
||||
}
|
||||
return row
|
||||
}
|
||||
let t = CPListTemplate(title: "Up Next", sections: [CPListSection(items: rows)])
|
||||
ic?.pushTemplate(t, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// ---- messages ----------------------------------------------------------------------
|
||||
|
||||
/// Toasts on the phone become a short alert in the car.
|
||||
private func alert(_ text: String) {
|
||||
guard let ic else { return }
|
||||
alertTask?.cancel()
|
||||
// Weak all the way down: the scene can disconnect while the alert is up.
|
||||
let show = { [weak self] in
|
||||
guard let self, let ic = self.ic else { return }
|
||||
let a = CPAlertTemplate(titleVariants: [text], actions: [CPAlertAction(title: "OK", style: .cancel) { [weak self] _ in
|
||||
self?.ic?.dismissTemplate(animated: true, completion: nil)
|
||||
}])
|
||||
ic.presentTemplate(a, animated: true, completion: nil)
|
||||
self.alertTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if !Task.isCancelled, let ic = self?.ic, ic.presentedTemplate === a { ic.dismissTemplate(animated: true, completion: nil) }
|
||||
}
|
||||
}
|
||||
if ic.presentedTemplate != nil {
|
||||
ic.dismissTemplate(animated: false) { _, _ in show() }
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- art ---------------------------------------------------------------------------
|
||||
|
||||
private static var tiles: [String: UIImage] = [:]
|
||||
|
||||
/// A big white letter on a flat dark tile, readable at a glance on the car screen.
|
||||
static func letterTile(_ letter: String) -> UIImage {
|
||||
let l = letter.prefix(1).uppercased().isEmpty ? "#" : letter.prefix(1).uppercased()
|
||||
if let img = tiles[l] { return img }
|
||||
let px: CGFloat = 120
|
||||
let img = UIGraphicsImageRenderer(size: CGSize(width: px, height: px)).image { ctx in
|
||||
UIColor(red: 0x2C / 255, green: 0x2C / 255, blue: 0x2E / 255, alpha: 1).setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: px, height: px))
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: px * 0.55, weight: .bold), .foregroundColor: UIColor.white]
|
||||
let s = NSString(string: l)
|
||||
let size = s.size(withAttributes: attrs)
|
||||
s.draw(at: CGPoint(x: (px - size.width) / 2, y: (px - size.height) / 2), withAttributes: attrs)
|
||||
}
|
||||
tiles[l] = img
|
||||
return img
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// Item ids for the CarPlay tree. Containers are `kind:key`; playable rows carry the
|
||||
/// container they were picked from, so choosing a song queues the rest of its list after it.
|
||||
/// The container goes last because search queries can contain anything.
|
||||
enum CarIds {
|
||||
static let downloads = "tab:downloads"
|
||||
static let playlists = "tab:playlists"
|
||||
static let albums = "tab:albums"
|
||||
static let artists = "tab:artists"
|
||||
static let tabs = [downloads, playlists, albums, artists]
|
||||
|
||||
static func playlist(_ key: String) -> String { "playlist:\(key)" }
|
||||
static func album(_ key: String) -> String { "album:\(key)" }
|
||||
static func artist(_ key: String) -> String { "artist:\(key)" }
|
||||
static func albumLetter(_ l: String) -> String { "albums:\(l)" }
|
||||
static func artistLetter(_ l: String) -> String { "artists:\(l)" }
|
||||
static func search(_ q: String) -> String { "search:\(q)" }
|
||||
|
||||
static func play(_ container: String, _ trackKey: String) -> String { "play|\(trackKey)|\(container)" }
|
||||
static func shuffle(_ container: String) -> String { "shuffle|\(container)" }
|
||||
|
||||
enum Pick: Equatable {
|
||||
case song(container: String, trackKey: String)
|
||||
case shuffle(container: String)
|
||||
var container: String {
|
||||
switch self { case .song(let c, _), .shuffle(let c): c }
|
||||
}
|
||||
}
|
||||
|
||||
static func parsePick(_ id: String) -> Pick? {
|
||||
if id.hasPrefix("play|") {
|
||||
let rest = id.dropFirst(5)
|
||||
guard let bar = rest.firstIndex(of: "|"), bar != rest.startIndex, rest.index(after: bar) != rest.endIndex else { return nil }
|
||||
return .song(container: String(rest[rest.index(after: bar)...]), trackKey: String(rest[..<bar]))
|
||||
}
|
||||
if id.hasPrefix("shuffle|") {
|
||||
let c = String(id.dropFirst(8))
|
||||
return c.isEmpty ? nil : .shuffle(container: c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// (kind, key) of a container id such as `album:123`.
|
||||
static func split(_ id: String) -> (String, String)? {
|
||||
guard let c = id.firstIndex(of: ":"), c != id.startIndex else { return nil }
|
||||
return (String(id[..<c]), String(id[id.index(after: c)...]))
|
||||
}
|
||||
}
|
||||
|
||||
enum CarTree {
|
||||
/// Lists longer than this split into A–Z folders: scrolling a few dozen rows on the car
|
||||
/// screen is fine, hundreds isn't (and CarPlay caps list length while driving).
|
||||
static let flatMax = 40
|
||||
|
||||
/// Recently added albums shown above the A–Z folders.
|
||||
static let recentAlbums = 12
|
||||
|
||||
/// "#" for anything that doesn't start with a letter; accents fold to their base letter.
|
||||
static func letterOf(_ sortTitle: String) -> String {
|
||||
let folded = sortTitle.trimmingCharacters(in: .whitespaces).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)
|
||||
guard let c = folded.unicodeScalars.first(where: { CharacterSet.alphanumerics.contains($0) }) else { return "#" }
|
||||
let u = String(c).uppercased()
|
||||
return u.count == 1 && ("A"..."Z").contains(u) ? u : "#"
|
||||
}
|
||||
|
||||
/// Items by first letter, "#" first then A–Z, each keeping the incoming order.
|
||||
static func buckets<T>(_ items: [T], _ sortTitle: (T) -> String) -> [(String, [T])] {
|
||||
var groups: [String: [T]] = [:]
|
||||
for it in items { groups[letterOf(sortTitle(it)), default: []].append(it) }
|
||||
return groups.sorted { ($0.key == "#" ? "" : $0.key) < ($1.key == "#" ? "" : $1.key) }.map { ($0.key, $0.value) }
|
||||
}
|
||||
|
||||
static func countLabel(_ n: Int, _ one: String, _ many: String) -> String { "\(n) \(n == 1 ? one : many)" }
|
||||
|
||||
/// Loose title match for voice requests: case, accents and punctuation don't matter.
|
||||
static func norm(_ s: String) -> String {
|
||||
let folded = s.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil).lowercased()
|
||||
let kept = folded.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) || $0 == " " }
|
||||
return String(String.UnicodeScalarView(kept)).split(separator: " ").joined(separator: " ")
|
||||
}
|
||||
|
||||
static func matches(_ title: String, _ query: String) -> Bool {
|
||||
let t = norm(title), q = norm(query)
|
||||
func noThe(_ s: String) -> String { s.hasPrefix("the ") ? String(s.dropFirst(4)) : s }
|
||||
return !q.isEmpty && (t == q || noThe(t) == noThe(q))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Whether Plex or stemserve answers right now. In the car the phone is usually on cellular
|
||||
/// and the LAN addresses just time out, so the car UI asks this first (a quick probe,
|
||||
/// remembered briefly) and goes straight to cached data when home is out of reach.
|
||||
@Observable
|
||||
final class HomeReach: @unchecked Sendable {
|
||||
private static let ttl: UInt64 = 20_000_000_000
|
||||
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let session: URLSession
|
||||
@ObservationIgnored private let lock = AsyncLock()
|
||||
@ObservationIgnored private let stamp = UnfairLock()
|
||||
@ObservationIgnored private var checkedAt: UInt64 = 0
|
||||
@ObservationIgnored private var _last = true
|
||||
@ObservationIgnored private var listeners: [() -> Void] = []
|
||||
|
||||
/// The last answer; true until a probe says otherwise. Changes as the phone joins or leaves home Wi-Fi.
|
||||
private(set) var home = true
|
||||
|
||||
/// Readable from any thread (the car's Plex client asks it on every request).
|
||||
var lastKnown: Bool { stamp.with { _last } }
|
||||
|
||||
init(settings: Settings, net: Connectivity) {
|
||||
self.settings = settings
|
||||
let c = URLSessionConfiguration.ephemeral
|
||||
c.timeoutIntervalForRequest = 2
|
||||
c.timeoutIntervalForResource = 2.5
|
||||
c.waitsForConnectivity = false
|
||||
session = URLSession(configuration: c)
|
||||
// A new network (home Wi-Fi in range, or lost) invalidates the answer; ask again once it
|
||||
// settles, so the car's lists can switch over without anyone browsing first.
|
||||
net.onChange { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stamp.with { self.checkedAt = 0 }
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
_ = await self.reachable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main-thread callback when [home] changes.
|
||||
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
|
||||
|
||||
func reachable() async -> Bool {
|
||||
await lock.run {
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let (at, last) = stamp.with { (checkedAt, _last) }
|
||||
if at != 0 && now - at < Self.ttl { return last }
|
||||
let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity"
|
||||
let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"
|
||||
async let a = answers(plex)
|
||||
async let b = answers(stems)
|
||||
let (ra, rb) = await (a, b)
|
||||
let ok = ra || rb
|
||||
stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds }
|
||||
await MainActor.run {
|
||||
if home != ok {
|
||||
home = ok
|
||||
listeners.forEach { $0() }
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
private func answers(_ s: String) async -> Bool {
|
||||
guard let url = URL(string: s), let (_, r) = try? await session.data(from: url) else { return false }
|
||||
return ((r as? HTTPURLResponse)?.statusCode ?? 500) < 500
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
struct HttpError: LocalizedError {
|
||||
let code: Int
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// A thin URLSession wrapper. With [offlineCache] set, successful GETs are kept on disk and
|
||||
/// answered from there when the network fails, so the library still browses with the server away.
|
||||
final class Http: @unchecked Sendable {
|
||||
let session: URLSession
|
||||
private let cacheDir: URL?
|
||||
/// When true, GETs never touch the network: cached body or HTTP 504.
|
||||
var forceCache: (() -> Bool)?
|
||||
|
||||
init(connectTimeout: TimeInterval, readTimeout: TimeInterval, offlineCache: URL? = nil, memoryMb: Int = 0) {
|
||||
let c = URLSessionConfiguration.default
|
||||
c.timeoutIntervalForRequest = max(connectTimeout, readTimeout)
|
||||
c.timeoutIntervalForResource = 3600
|
||||
c.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
c.urlCache = memoryMb > 0 ? URLCache(memoryCapacity: memoryMb << 20, diskCapacity: 0) : nil
|
||||
c.waitsForConnectivity = false
|
||||
c.httpMaximumConnectionsPerHost = 8
|
||||
session = URLSession(configuration: c)
|
||||
cacheDir = offlineCache
|
||||
if let d = offlineCache { try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) }
|
||||
}
|
||||
|
||||
private func cacheFile(_ req: URLRequest) -> URL? {
|
||||
guard let dir = cacheDir, req.httpMethod ?? "GET" == "GET", let url = req.url?.absoluteString else { return nil }
|
||||
var key = "GET " + url
|
||||
for h in ["X-Plex-Container-Start", "X-Plex-Container-Size"] {
|
||||
if let v = req.value(forHTTPHeaderField: h) { key += "|\(h)=\(v)" }
|
||||
}
|
||||
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return dir.appendingPathComponent(hash)
|
||||
}
|
||||
|
||||
/// Returns the body of a 2xx response, or throws [HttpError] / the URL error.
|
||||
func data(_ req: URLRequest) async throws -> Data {
|
||||
let file = cacheFile(req)
|
||||
if let file, forceCache?() == true {
|
||||
if let d = try? Data(contentsOf: file) { return d }
|
||||
throw HttpError(code: 504, message: "Offline, and not cached")
|
||||
}
|
||||
do {
|
||||
let (data, resp) = try await session.data(for: req)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(code) else {
|
||||
throw HttpError(code: code, message: "HTTP \(code) from \(req.url?.host ?? "server")")
|
||||
}
|
||||
if let file { try? data.write(to: file, options: .atomic) }
|
||||
return data
|
||||
} catch let e as URLError {
|
||||
if e.code != .cancelled, let file, let d = try? Data(contentsOf: file) { return d }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
func string(_ req: URLRequest) async throws -> String {
|
||||
String(decoding: try await data(req), as: UTF8.self)
|
||||
}
|
||||
|
||||
/// Status code only; network errors become false.
|
||||
func ok(_ req: URLRequest) async -> Bool {
|
||||
guard let (_, resp) = try? await session.data(for: req) else { return false }
|
||||
return ((resp as? HTTPURLResponse)?.statusCode).map { (200..<300).contains($0) } ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
/// Appends query items, skipping nil values.
|
||||
func with(_ params: [(String, Any?)]) -> URL {
|
||||
guard var c = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self }
|
||||
var items = c.queryItems ?? []
|
||||
for (k, v) in params { if let v { items.append(URLQueryItem(name: k, value: "\(v)")) } }
|
||||
c.queryItems = items.isEmpty ? nil : items
|
||||
// URLComponents leaves '+' alone, which servers read as a space.
|
||||
c.percentEncodedQuery = c.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
|
||||
return c.url ?? self
|
||||
}
|
||||
}
|
||||
|
||||
/// Form-style percent-encoding for a single query value.
|
||||
func urlEncode(_ s: String) -> String {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s
|
||||
}
|
||||
|
||||
/// Online / Wi-Fi state, for offline fallbacks and Wi-Fi-only downloads.
|
||||
@Observable
|
||||
final class Connectivity: @unchecked Sendable {
|
||||
private(set) var online = true
|
||||
private(set) var unmetered = true
|
||||
@ObservationIgnored private let monitor = NWPathMonitor()
|
||||
@ObservationIgnored private var listeners: [() -> Void] = []
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
let on = path.status == .satisfied
|
||||
let un = on && !path.isExpensive && !path.isConstrained
|
||||
if on != self.online || un != self.unmetered {
|
||||
self.online = on
|
||||
self.unmetered = un
|
||||
self.listeners.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "connectivity"))
|
||||
}
|
||||
|
||||
/// Main-thread callback whenever online or unmetered changes.
|
||||
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum ThemeMode: String, CaseIterable, Identifiable {
|
||||
case Black, Dark, White
|
||||
var id: String { rawValue }
|
||||
var label: String { rawValue }
|
||||
}
|
||||
|
||||
/// iOS system tints, light then dark variant.
|
||||
enum Accent: String, CaseIterable, Identifiable {
|
||||
case Music, Pink, Orange, Yellow, Green, Teal, Blue, Indigo, Purple
|
||||
var id: String { rawValue }
|
||||
var label: String { rawValue }
|
||||
|
||||
var light: UInt32 {
|
||||
switch self {
|
||||
case .Music: 0xFA2D48
|
||||
case .Pink: 0xFF2D55
|
||||
case .Orange: 0xFF9500
|
||||
case .Yellow: 0xFFCC00
|
||||
case .Green: 0x34C759
|
||||
case .Teal: 0x30B0C7
|
||||
case .Blue: 0x007AFF
|
||||
case .Indigo: 0x5856D6
|
||||
case .Purple: 0xAF52DE
|
||||
}
|
||||
}
|
||||
|
||||
var dark: UInt32 {
|
||||
switch self {
|
||||
case .Music: 0xFC3C44
|
||||
case .Pink: 0xFF375F
|
||||
case .Orange: 0xFF9F0A
|
||||
case .Yellow: 0xFFD60A
|
||||
case .Green: 0x30D158
|
||||
case .Teal: 0x40C8E0
|
||||
case .Blue: 0x0A84FF
|
||||
case .Indigo: 0x5E5CE6
|
||||
case .Purple: 0xBF5AF2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How stems travel from stemserve; Opus is transcoded (and cached) server-side.
|
||||
enum StemQuality: String, CaseIterable, Identifiable {
|
||||
case Lossless, High, Standard, Saver
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .Lossless: "Lossless"
|
||||
case .High: "High"
|
||||
case .Standard: "Standard"
|
||||
case .Saver: "Data Saver"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .Lossless: "Original 24-bit FLAC, ~30 MB per stem-minute"
|
||||
case .High: "Opus 256 kbps per stem"
|
||||
case .Standard: "Opus 160 kbps per stem"
|
||||
case .Saver: "Opus 96 kbps per stem"
|
||||
}
|
||||
}
|
||||
|
||||
var fmt: String { self == .Lossless ? "flac" : "opus" }
|
||||
|
||||
var kbps: Int {
|
||||
switch self {
|
||||
case .Lossless: 0
|
||||
case .High: 256
|
||||
case .Standard: 160
|
||||
case .Saver: 96
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UserDefaults-backed settings. Safe to read from any thread (the engine and network code do);
|
||||
/// SwiftUI views observe them, and [onChange] lets the rest of the app react to writes.
|
||||
@Observable
|
||||
final class Settings: @unchecked Sendable {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let lock = NSLock()
|
||||
@ObservationIgnored private var store: [String: Any] = [:]
|
||||
@ObservationIgnored private var listeners: [String: [() -> Void]] = [:]
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
for key in Self.keys {
|
||||
if let v = defaults.object(forKey: key) { store[key] = v }
|
||||
}
|
||||
if let id = defaults.string(forKey: "clientId") {
|
||||
clientId = id
|
||||
} else {
|
||||
clientId = UUID().uuidString.lowercased()
|
||||
defaults.set(clientId, forKey: "clientId")
|
||||
}
|
||||
}
|
||||
|
||||
private static let keys = [
|
||||
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
||||
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume",
|
||||
]
|
||||
|
||||
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
|
||||
access(keyPath: kp)
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return store[key] as? T ?? def
|
||||
}
|
||||
|
||||
private func write<T: Equatable>(_ kp: KeyPath<Settings, T>, _ key: String, _ v: T, _ def: T) {
|
||||
lock.lock()
|
||||
let old = store[key] as? T ?? def
|
||||
lock.unlock()
|
||||
if old == v { return }
|
||||
withMutation(keyPath: kp) {
|
||||
lock.lock()
|
||||
store[key] = v
|
||||
lock.unlock()
|
||||
}
|
||||
defaults.set(v, forKey: key)
|
||||
notify(key)
|
||||
}
|
||||
|
||||
private func readEnum<E: RawRepresentable>(_ kp: KeyPath<Settings, E>, _ key: String, _ def: E) -> E where E.RawValue == String {
|
||||
access(keyPath: kp)
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (store[key] as? String).flatMap(E.init(rawValue:)) ?? def
|
||||
}
|
||||
|
||||
private func writeEnum<E: RawRepresentable & Equatable>(_ kp: KeyPath<Settings, E>, _ key: String, _ v: E, _ def: E) where E.RawValue == String {
|
||||
lock.lock()
|
||||
let old = (store[key] as? String).flatMap(E.init(rawValue:)) ?? def
|
||||
lock.unlock()
|
||||
if old == v { return }
|
||||
withMutation(keyPath: kp) {
|
||||
lock.lock()
|
||||
store[key] = v.rawValue
|
||||
lock.unlock()
|
||||
}
|
||||
defaults.set(v.rawValue, forKey: key)
|
||||
notify(key)
|
||||
}
|
||||
|
||||
private func notify(_ key: String) {
|
||||
let run = {
|
||||
self.lock.lock()
|
||||
let fs = self.listeners[key] ?? []
|
||||
self.lock.unlock()
|
||||
fs.forEach { $0() }
|
||||
}
|
||||
if Thread.isMainThread { run() } else { DispatchQueue.main.async(execute: run) }
|
||||
}
|
||||
|
||||
/// Calls [f] on the main thread after any of [keys] changes.
|
||||
func onChange(_ keys: String..., f: @escaping () -> Void) {
|
||||
lock.lock()
|
||||
for k in keys { listeners[k, default: []].append(f) }
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
var plexUrl: String { get { read(\.plexUrl, "plexUrl", "http://192.168.86.2:32400") } set { write(\.plexUrl, "plexUrl", newValue, "http://192.168.86.2:32400") } }
|
||||
var plexToken: String { get { read(\.plexToken, "plexToken", "") } set { write(\.plexToken, "plexToken", newValue, "") } }
|
||||
var plexUser: String { get { read(\.plexUser, "plexUser", "") } set { write(\.plexUser, "plexUser", newValue, "") } }
|
||||
var plexMachineId: String { get { read(\.plexMachineId, "plexMachineId", "") } set { write(\.plexMachineId, "plexMachineId", newValue, "") } }
|
||||
var musicSection: String { get { read(\.musicSection, "musicSection", "") } set { write(\.musicSection, "musicSection", newValue, "") } }
|
||||
var musicSectionTitle: String { get { read(\.musicSectionTitle, "musicSectionTitle", "") } set { write(\.musicSectionTitle, "musicSectionTitle", newValue, "") } }
|
||||
var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } }
|
||||
var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } }
|
||||
/// Plex path prefixes that matched the stems index, newline-separated.
|
||||
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
|
||||
|
||||
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
|
||||
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
|
||||
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
||||
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
|
||||
|
||||
var stemQuality: StemQuality { get { readEnum(\.stemQuality, "stemQuality", .High) } set { writeEnum(\.stemQuality, "stemQuality", newValue, .High) } }
|
||||
var cacheLimitGb: Int { get { read(\.cacheLimitGb, "cacheLimitGb", 10) } set { write(\.cacheLimitGb, "cacheLimitGb", newValue, 10) } }
|
||||
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
|
||||
/// Downloads page order: 0 download order, 1 artist, 2 release date.
|
||||
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var masterVolume: Float {
|
||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||
set { write(\.masterVolume, "masterVolume", newValue, 0.85) }
|
||||
}
|
||||
|
||||
@ObservationIgnored let clientId: String
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// Equalizer APO's config text, the format AutoEQ publishes headphone corrections in
|
||||
/// (ParametricEQ.txt):
|
||||
///
|
||||
/// Preamp: -6.2 dB
|
||||
/// Filter 1: ON LSC Fc 105 Hz Gain 6.0 dB Q 0.70
|
||||
/// Filter 2: ON PK Fc 2300 Hz Gain -3.1 dB Q 1.41
|
||||
///
|
||||
/// Also understood: "Channel: L" / "R" / "all" lines, BW Oct instead of Q, and the older
|
||||
/// type names (LS, HS, LP, HP, PEQ ...). Other commands (GraphicEQ, Delay, Copy) are skipped.
|
||||
enum ApoConfig {
|
||||
struct Result { var filters: [EqFilter]; var preamp: Float?; var skipped: Int }
|
||||
|
||||
private static let types: [String: FilterType] = [
|
||||
"PK": .Peak, "PEQ": .Peak, "MODAL": .Peak,
|
||||
"LS": .LowShelf, "LSC": .LowShelf, "LSQ": .LowShelf,
|
||||
"HS": .HighShelf, "HSC": .HighShelf, "HSQ": .HighShelf,
|
||||
"LP": .LowPass, "LPQ": .LowPass,
|
||||
"HP": .HighPass, "HPQ": .HighPass,
|
||||
"BP": .BandPass, "NO": .Notch, "AP": .AllPass,
|
||||
]
|
||||
|
||||
/// Nil when the text has no preamp or filter lines at all.
|
||||
static func parse(_ text: String) -> Result? {
|
||||
var filters: [EqFilter] = []
|
||||
var preamp: Float?
|
||||
var channel = EqChannel.Both
|
||||
var skipped = 0
|
||||
for raw in text.components(separatedBy: .newlines) {
|
||||
let line = (raw.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty { continue }
|
||||
guard let colon = line.firstIndex(of: ":") else { continue }
|
||||
let cmd = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let args = line[line.index(after: colon)...].split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
if cmd == "preamp" {
|
||||
guard let v = args.first.flatMap(Float.init) else { continue }
|
||||
preamp = (preamp ?? 0) + v
|
||||
} else if cmd == "channel" {
|
||||
let set = Set(args.map { $0.uppercased() })
|
||||
channel = set == ["L"] ? .Left : set == ["R"] ? .Right : .Both
|
||||
} else if cmd.hasPrefix("filter") {
|
||||
if let f = parseFilter(args, channel) { filters.append(f) } else { skipped += 1 }
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
if filters.isEmpty && preamp == nil { return nil }
|
||||
return Result(filters: filters, preamp: preamp, skipped: skipped)
|
||||
}
|
||||
|
||||
private static func parseFilter(_ args: [String], _ channel: EqChannel) -> EqFilter? {
|
||||
func at(_ i: Int) -> String? { i < args.count ? args[i] : nil }
|
||||
var i = 0
|
||||
var on = true
|
||||
switch at(0)?.uppercased() {
|
||||
case "ON": i += 1
|
||||
case "OFF": on = false; i += 1
|
||||
default: break
|
||||
}
|
||||
guard let t = at(i)?.uppercased(), let type = types[t] else { return nil }
|
||||
i += 1
|
||||
var freq: Float?
|
||||
var gain: Float = 0
|
||||
var q: Float?
|
||||
var slope = 12
|
||||
while i < args.count {
|
||||
let key = args[i].lowercased()
|
||||
let num = at(i + 1).flatMap(Float.init)
|
||||
if key == "fc", let num { freq = num; i += 2 }
|
||||
else if key == "gain", let num { gain = num; i += 2 }
|
||||
else if key == "q", let num { q = num; i += 2 }
|
||||
else if key == "bw" && at(i + 1)?.lowercased() == "oct" {
|
||||
if let bw = at(i + 2).flatMap(Double.init) { q = Float(pow(2, bw).squareRoot() / (pow(2, bw) - 1)) }
|
||||
i += 3
|
||||
} else if key.hasSuffix("db"), let s = Float(key.dropLast(2)) {
|
||||
// "LSC 12dB Fc ...": a shelf or pass slope in dB per octave.
|
||||
slope = Int(s); i += 1
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
guard let fc = freq else { return nil }
|
||||
let defaultQ: Float = type == .Peak ? EQ_Q : 0.707
|
||||
return EqFilter(id: EqController.newFilterId(), type: type, freq: fc, gain: type.hasGain ? gain : 0, q: q ?? defaultQ,
|
||||
slope: type.hasSlope ? min(max(slope, 6), 48) : 12, channel: channel, on: on)
|
||||
}
|
||||
|
||||
/// The profile as an Equalizer APO config: preamp, graphic bands as peaks, then the filters.
|
||||
static func format(_ p: EqProfile) -> String {
|
||||
func n(_ v: Float) -> String {
|
||||
var s = String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)
|
||||
while s.hasSuffix("0") { s.removeLast() }
|
||||
if s.hasSuffix(".") { s.removeLast() }
|
||||
return s
|
||||
}
|
||||
var out = "# \(p.name), from Stemify\n"
|
||||
out += "Preamp: \(n(p.preamp)) dB\n"
|
||||
var channel = EqChannel.Both
|
||||
var k = 1
|
||||
for f in EqFilter.graphic(p.gains) + p.filters {
|
||||
if f.channel != channel {
|
||||
channel = f.channel
|
||||
out += "Channel: " + (channel == .Left ? "L" : channel == .Right ? "R" : "all") + "\n"
|
||||
}
|
||||
let head = "\(f.on ? "ON" : "OFF") \(f.type.apo) Fc \(n(f.freq)) Hz"
|
||||
if f.type.hasGain {
|
||||
out += "Filter \(k): \(head) Gain \(n(f.gain)) dB Q \(n(f.q))\n"; k += 1
|
||||
} else if !f.type.hasSlope || f.slope == 12 {
|
||||
out += "Filter \(k): \(head) Q \(n(f.q))\n"; k += 1
|
||||
} else if f.slope < 12 {
|
||||
out += "# 6 dB/oct \(f.type.label.lowercased()): APO has no first-order filter, so this is a gentle 12 dB/oct one\n"
|
||||
out += "Filter \(k): \(head) Q 0.5\n"; k += 1
|
||||
} else {
|
||||
// Steeper slopes are Butterworth: one APO filter per second-order section.
|
||||
let order = f.slope / 6
|
||||
for j in 1...(order / 2) {
|
||||
let q = Float(1 / (2 * cos(Double(2 * j - 1) * .pi / Double(2 * order))))
|
||||
out += "Filter \(k): \(head) Q \(n(q))\n"; k += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum DeviceKind: String { case Speaker, Wired, Bluetooth, Usb, Hdmi, Car, Other }
|
||||
|
||||
/// An output route, identified by AVAudioSession port type + port name.
|
||||
struct OutputDevice: Codable, Hashable {
|
||||
var type: String
|
||||
var name: String
|
||||
|
||||
var id: String { "\(type)|\(name)" }
|
||||
var kind: DeviceKind { Self.kindOf(type) }
|
||||
var label: String {
|
||||
switch kind {
|
||||
case .Speaker: "Phone Speaker"
|
||||
case .Wired: name.trimmingCharacters(in: .whitespaces).isEmpty || name == "Headphones" ? "Wired Headphones" : name
|
||||
default: name.trimmingCharacters(in: .whitespaces).isEmpty ? kind.rawValue : name
|
||||
}
|
||||
}
|
||||
|
||||
static let speaker = OutputDevice(type: AVAudioSession.Port.builtInSpeaker.rawValue, name: "")
|
||||
|
||||
static func of(_ port: AVAudioSessionPortDescription) -> OutputDevice {
|
||||
// One speaker profile is enough; the receiver (earpiece) counts as the speaker too.
|
||||
if kindOf(port.portType.rawValue) == .Speaker { return speaker }
|
||||
return OutputDevice(type: port.portType.rawValue, name: port.portName)
|
||||
}
|
||||
|
||||
static func kindOf(_ type: String) -> DeviceKind {
|
||||
switch AVAudioSession.Port(rawValue: type) {
|
||||
case .builtInSpeaker, .builtInReceiver: .Speaker
|
||||
case .headphones, .lineOut: .Wired
|
||||
case .bluetoothA2DP, .bluetoothLE, .bluetoothHFP: .Bluetooth
|
||||
case .usbAudio: .Usb
|
||||
case .HDMI, .airPlay: .Hdmi
|
||||
case .carAudio: .Car
|
||||
default: .Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EqProfile: Codable, Hashable, Identifiable {
|
||||
var id: String
|
||||
var name: String
|
||||
var gains: [Float] = Array(repeating: 0, count: EQ_BANDS.count)
|
||||
var devices: [OutputDevice] = []
|
||||
/// Parametric filters, after the graphic bands.
|
||||
var filters: [EqFilter] = []
|
||||
/// Manual preamp in dB, ahead of the anti-clip one (Equalizer APO's "Preamp:").
|
||||
var preamp: Float = 0
|
||||
|
||||
init(id: String, name: String, gains: [Float] = Array(repeating: 0, count: EQ_BANDS.count),
|
||||
devices: [OutputDevice] = [], filters: [EqFilter] = [], preamp: Float = 0) {
|
||||
self.id = id; self.name = name; self.gains = gains; self.devices = devices; self.filters = filters; self.preamp = preamp
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("id") ?? UUID().uuidString
|
||||
name = c.str("name") ?? "Profile"
|
||||
gains = (try? c.decode([Float].self, forKey: AnyKey("gains"))) ?? Array(repeating: 0, count: EQ_BANDS.count)
|
||||
if gains.count != EQ_BANDS.count { gains = (gains + Array(repeating: 0, count: EQ_BANDS.count)).prefix(EQ_BANDS.count).map { $0 } }
|
||||
devices = c.list("devices")
|
||||
filters = c.list("filters")
|
||||
preamp = Float(c.double("preamp") ?? 0)
|
||||
}
|
||||
|
||||
/// Everything the engine runs: graphic bands as peaks, then the filters that are on.
|
||||
var chain: [EqFilter] { EqFilter.graphic(gains) + filters.filter(\.active) }
|
||||
}
|
||||
|
||||
struct EqState: Codable, Equatable {
|
||||
var enabled = true
|
||||
var automatic = true
|
||||
/// Picked by hand; wins over automatic switching until cleared.
|
||||
var manualId: String?
|
||||
var defaultId = "flat"
|
||||
var antiClip = true
|
||||
var profiles = [EqProfile(id: "flat", name: "Flat")]
|
||||
var seenDevices: [OutputDevice] = []
|
||||
|
||||
init() {}
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
enabled = c.bool("enabled") ?? true
|
||||
automatic = c.bool("automatic") ?? true
|
||||
manualId = c.str("manualId")
|
||||
defaultId = c.str("defaultId") ?? "flat"
|
||||
antiClip = c.bool("antiClip") ?? true
|
||||
let p: [EqProfile] = c.list("profiles")
|
||||
if !p.isEmpty { profiles = p }
|
||||
seenDevices = c.list("seenDevices")
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS Music's EQ list, approximated on ten octave bands.
|
||||
let EQ_PRESETS: [(String, [Float])] = [
|
||||
("Flat", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
("Acoustic", [4.5, 4.5, 3.5, 1, 1.5, 1.5, 3, 3.5, 3, 2]),
|
||||
("Bass Booster", [5.5, 4.5, 3.5, 2.5, 1.5, 0, 0, 0, 0, 0]),
|
||||
("Bass Reducer", [-5.5, -4.5, -3.5, -2.5, -1.5, 0, 0, 0, 0, 0]),
|
||||
("Classical", [4.5, 3.5, 3, 2.5, -1.5, -1.5, 0, 2, 3, 3.5]),
|
||||
("Dance", [3.5, 6.5, 5, 0, 2, 3.5, 5, 4.5, 3.5, 0]),
|
||||
("Deep", [5, 3.5, 1.5, 1, 3, 2.5, 1.5, -2, -3.5, -4.5]),
|
||||
("Electronic", [4, 3.5, 1, 0, -2, 2, 1, 1.5, 4, 5]),
|
||||
("Hip-Hop", [5, 4, 1.5, 3, -1, -1, 1.5, -0.5, 2, 3]),
|
||||
("Jazz", [4, 3, 1.5, 2, -1.5, -1.5, 0, 1.5, 3, 3.5]),
|
||||
("Late Night", [4.5, 3, 2, -0.5, 1.5, 1.5, 2, 3, 3.5, 4]),
|
||||
("Loudness", [6, 4, 0, 0, -2, 0, -1, -5, 5, 1]),
|
||||
("Piano", [3, 2, 0, 2.5, 3, 1.5, 3.5, 4.5, 3, 3.5]),
|
||||
("Pop", [-1.5, -1, 0, 2, 4, 4, 2, 0, -1, -1.5]),
|
||||
("R&B", [2.5, 7, 5.5, 1.5, -2.5, -1.5, 2, 2.5, 3, 3.5]),
|
||||
("Rock", [5, 4, 3, 1.5, -0.5, -1, 0.5, 2.5, 3.5, 4.5]),
|
||||
("Small Speakers", [5.5, 4.5, 3.5, 2.5, 1.5, 0, -1.5, -2.5, -3.5, -4]),
|
||||
("Spoken Word", [-3.5, -0.5, 0, 0.5, 3.5, 4.5, 4.5, 4, 2, 0]),
|
||||
("Treble Booster", [0, 0, 0, 0, 0, 1.5, 2.5, 3.5, 4.5, 5.5]),
|
||||
("Treble Reducer", [0, 0, 0, 0, 0, -1.5, -2.5, -3.5, -4.5, -5.5]),
|
||||
("Vocal Booster", [-1.5, -3, -3, 1.5, 3.5, 3.5, 3, 1.5, 0, -1.5]),
|
||||
]
|
||||
|
||||
/// EQ profiles and the current output route. In automatic mode the active profile is the
|
||||
/// one linked to the route (else the default); a hand-picked profile overrides that until
|
||||
/// the user goes back to automatic. Main thread only.
|
||||
@Observable
|
||||
final class EqController: @unchecked Sendable {
|
||||
static let freqMin: Float = 10
|
||||
static let freqMax: Float = 22000
|
||||
static let filterGain: Float = 24
|
||||
static let qMin: Float = 0.1
|
||||
static let qMax: Float = 20
|
||||
static let preampRange: Float = 24
|
||||
|
||||
static func newFilterId() -> String { UUID().uuidString.lowercased() }
|
||||
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private var saveWork: DispatchWorkItem?
|
||||
@ObservationIgnored var onParams: ((EqParams?) -> Void)?
|
||||
|
||||
private(set) var state: EqState
|
||||
private(set) var device = OutputDevice.speaker
|
||||
private(set) var active: EqProfile?
|
||||
|
||||
init(dir: URL) {
|
||||
file = dir.appendingPathComponent("eq.json")
|
||||
state = Self.load(file)
|
||||
active = nil
|
||||
active = resolve(state, device)
|
||||
#if !SWIFT_PACKAGE
|
||||
NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.refreshRoute()
|
||||
}
|
||||
refreshRoute()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Where media is going right now.
|
||||
func refreshRoute() {
|
||||
#if !SWIFT_PACKAGE
|
||||
if let port = AVAudioSession.sharedInstance().currentRoute.outputs.first { setDevice(.of(port)) }
|
||||
#endif
|
||||
}
|
||||
|
||||
func setDevice(_ d: OutputDevice) {
|
||||
if device == d && state.seenDevices.contains(d) { return }
|
||||
device = d
|
||||
if !state.seenDevices.contains(d) {
|
||||
var s = state
|
||||
s.seenDevices = Array(([d] + s.seenDevices).prefix(24))
|
||||
update(s)
|
||||
} else {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- edits ------------------------------------------------------------------------
|
||||
|
||||
func setEnabled(_ on: Bool) { var s = state; s.enabled = on; update(s) }
|
||||
func setAntiClip(_ on: Bool) { var s = state; s.antiClip = on; update(s) }
|
||||
|
||||
/// Automatic switching on clears the manual pick.
|
||||
func setAutomatic(_ on: Bool) {
|
||||
var s = state
|
||||
s.automatic = on
|
||||
s.manualId = on ? nil : (s.manualId ?? active?.id)
|
||||
update(s)
|
||||
}
|
||||
|
||||
/// Picking a profile by hand overrides automatic switching.
|
||||
func select(_ id: String) { var s = state; s.manualId = id; s.automatic = false; update(s) }
|
||||
|
||||
func setGain(_ id: String, band: Int, db: Float) {
|
||||
edit(id) { $0.gains[band] = Self.clamp(db, EQ_MAX_DB) }
|
||||
}
|
||||
|
||||
func setGains(_ id: String, _ gains: [Float]) { edit(id) { $0.gains = gains.map { Self.clamp($0, EQ_MAX_DB) } } }
|
||||
|
||||
func rename(_ id: String, _ name: String) {
|
||||
edit(id) { p in if !name.trimmingCharacters(in: .whitespaces).isEmpty { p.name = name } }
|
||||
}
|
||||
|
||||
func setPreamp(_ id: String, _ db: Float) { edit(id) { $0.preamp = Self.clamp(db, Self.preampRange) } }
|
||||
|
||||
/// Adds or replaces (by id) a parametric filter.
|
||||
func putFilter(_ id: String, _ f: EqFilter) {
|
||||
var clean = f
|
||||
clean.freq = min(max(f.freq, Self.freqMin), Self.freqMax)
|
||||
clean.gain = Self.clamp(f.gain, Self.filterGain)
|
||||
clean.q = min(max(f.q, Self.qMin), Self.qMax)
|
||||
edit(id) { p in
|
||||
if let i = p.filters.firstIndex(where: { $0.id == f.id }) { p.filters[i] = clean } else { p.filters.append(clean) }
|
||||
}
|
||||
}
|
||||
|
||||
func removeFilter(_ id: String, _ filterId: String) { edit(id) { $0.filters.removeAll { $0.id == filterId } } }
|
||||
|
||||
/// Replaces the filters (and the preamp, when given): an Equalizer APO / AutoEQ import.
|
||||
func setFilters(_ id: String, _ filters: [EqFilter], preamp: Float?) {
|
||||
edit(id) { p in
|
||||
p.filters = filters
|
||||
if let preamp { p.preamp = Self.clamp(preamp, Self.preampRange) }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func create(_ name: String, from: EqProfile? = nil) -> EqProfile {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
||||
let p = EqProfile(id: UUID().uuidString.lowercased(), name: trimmed.isEmpty ? "Profile" : name,
|
||||
gains: from?.gains ?? Array(repeating: 0, count: EQ_BANDS.count),
|
||||
filters: from?.filters.map { var f = $0; f.id = Self.newFilterId(); return f } ?? [],
|
||||
preamp: from?.preamp ?? 0)
|
||||
var s = state
|
||||
s.profiles.append(p)
|
||||
update(s)
|
||||
return p
|
||||
}
|
||||
|
||||
func delete(_ id: String) {
|
||||
var s = state
|
||||
guard s.profiles.count > 1 else { return }
|
||||
s.profiles.removeAll { $0.id == id }
|
||||
if s.defaultId == id { s.defaultId = s.profiles[0].id }
|
||||
if s.manualId == id { s.manualId = nil }
|
||||
update(s)
|
||||
}
|
||||
|
||||
func setDefault(_ id: String) { var s = state; s.defaultId = id; update(s) }
|
||||
|
||||
/// Links [device] to [id], unlinking it from any other profile (one profile per route).
|
||||
func link(_ id: String, _ device: OutputDevice, _ on: Bool) {
|
||||
var s = state
|
||||
s.profiles = s.profiles.map { p in
|
||||
var p = p
|
||||
p.devices.removeAll { $0 == device }
|
||||
if p.id == id && on { p.devices.append(device) }
|
||||
return p
|
||||
}
|
||||
update(s)
|
||||
}
|
||||
|
||||
func profileFor(_ device: OutputDevice) -> EqProfile? { state.profiles.first { $0.devices.contains(device) } }
|
||||
func profile(_ id: String) -> EqProfile? { state.profiles.first { $0.id == id } }
|
||||
|
||||
private func edit(_ id: String, _ f: (inout EqProfile) -> Void) {
|
||||
var s = state
|
||||
if let i = s.profiles.firstIndex(where: { $0.id == id }) { f(&s.profiles[i]) }
|
||||
update(s)
|
||||
}
|
||||
|
||||
private func update(_ s: EqState) {
|
||||
state = s
|
||||
publish()
|
||||
// Slider drags update many times a second; write once they settle.
|
||||
saveWork?.cancel()
|
||||
let snapshot = s
|
||||
let w = DispatchWorkItem { [file] in
|
||||
if let d = try? JSONEncoder().encode(snapshot) { try? d.write(to: file, options: .atomic) }
|
||||
}
|
||||
saveWork = w
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.4, execute: w)
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
let p = resolve(state, device)
|
||||
if active != p { active = p }
|
||||
onParams?(Self.paramsFor(state, p))
|
||||
}
|
||||
|
||||
func currentParams() -> EqParams? { Self.paramsFor(state, active) }
|
||||
|
||||
private func resolve(_ s: EqState, _ d: OutputDevice) -> EqProfile? {
|
||||
if !s.automatic, let m = s.manualId, let p = s.profiles.first(where: { $0.id == m }) { return p }
|
||||
return s.profiles.first { $0.devices.contains(d) }
|
||||
?? s.profiles.first { $0.id == s.defaultId }
|
||||
?? s.profiles.first
|
||||
}
|
||||
|
||||
static func paramsFor(_ s: EqState, _ p: EqProfile?) -> EqParams? {
|
||||
guard s.enabled, let p else { return nil }
|
||||
let chain = p.chain
|
||||
if chain.isEmpty && p.preamp == 0 { return nil }
|
||||
return EqParams(filters: chain, preampDb: p.preamp + (s.antiClip ? EqMath.autoPreampDb(chain, manualDb: p.preamp) : 0))
|
||||
}
|
||||
|
||||
private static func clamp(_ v: Float, _ r: Float) -> Float { min(max(v, -r), r) }
|
||||
|
||||
private static func load(_ file: URL) -> EqState {
|
||||
if let d = try? Data(contentsOf: file), let s = try? JSONDecoder().decode(EqState.self, from: d) { return s }
|
||||
var s = EqState()
|
||||
s.profiles = [
|
||||
EqProfile(id: "flat", name: "Flat"),
|
||||
EqProfile(id: "speaker", name: "Small Speaker Tune", gains: EQ_PRESETS.first { $0.0 == "Small Speakers" }!.1, devices: [.speaker]),
|
||||
]
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
|
||||
/// ISO octave centres for the 10-band graphic EQ.
|
||||
let EQ_BANDS: [Float] = [31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
||||
let EQ_LABELS = ["31", "62", "125", "250", "500", "1K", "2K", "4K", "8K", "16K"]
|
||||
let EQ_MAX_DB: Float = 12
|
||||
let EQ_Q: Float = 1.41 // one octave
|
||||
|
||||
/// Equalizer APO's filter types; [apo] is the name its config files use.
|
||||
enum FilterType: String, Codable, CaseIterable {
|
||||
case Peak, LowShelf, HighShelf, LowPass, HighPass, BandPass, Notch, AllPass
|
||||
|
||||
var apo: String {
|
||||
switch self {
|
||||
case .Peak: "PK"
|
||||
case .LowShelf: "LSC"
|
||||
case .HighShelf: "HSC"
|
||||
case .LowPass: "LPQ"
|
||||
case .HighPass: "HPQ"
|
||||
case .BandPass: "BP"
|
||||
case .Notch: "NO"
|
||||
case .AllPass: "AP"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .Peak: "Peak"
|
||||
case .LowShelf: "Low Shelf"
|
||||
case .HighShelf: "High Shelf"
|
||||
case .LowPass: "Low Pass"
|
||||
case .HighPass: "High Pass"
|
||||
case .BandPass: "Band Pass"
|
||||
case .Notch: "Notch"
|
||||
case .AllPass: "All Pass"
|
||||
}
|
||||
}
|
||||
|
||||
var hasGain: Bool { self == .Peak || self == .LowShelf || self == .HighShelf }
|
||||
var hasSlope: Bool { self == .LowPass || self == .HighPass }
|
||||
}
|
||||
|
||||
enum EqChannel: String, Codable, CaseIterable {
|
||||
case Both, Left, Right
|
||||
var label: String { rawValue }
|
||||
}
|
||||
|
||||
/// Slopes offered for low and high pass, in dB per octave. 12 uses the filter's Q; steeper ones are Butterworth.
|
||||
let EQ_SLOPES = [6, 12, 24, 36, 48]
|
||||
|
||||
/// One parametric filter, as in Equalizer APO. Gain only matters for peaks and shelves.
|
||||
struct EqFilter: Codable, Hashable, Identifiable {
|
||||
var id: String
|
||||
var type: FilterType = .Peak
|
||||
var freq: Float = 1000
|
||||
var gain: Float = 0
|
||||
var q: Float = 0.707
|
||||
var slope = 12
|
||||
var channel: EqChannel = .Both
|
||||
var on = true
|
||||
|
||||
init(id: String, type: FilterType = .Peak, freq: Float = 1000, gain: Float = 0, q: Float = 0.707,
|
||||
slope: Int = 12, channel: EqChannel = .Both, on: Bool = true) {
|
||||
self.id = id; self.type = type; self.freq = freq; self.gain = gain; self.q = q
|
||||
self.slope = slope; self.channel = channel; self.on = on
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("id") ?? UUID().uuidString
|
||||
type = c.str("type").flatMap(FilterType.init(rawValue:)) ?? .Peak
|
||||
freq = Float(c.double("freq") ?? 1000)
|
||||
gain = Float(c.double("gain") ?? 0)
|
||||
q = Float(c.double("q") ?? 0.707)
|
||||
slope = c.int("slope") ?? 12
|
||||
channel = c.str("channel").flatMap(EqChannel.init(rawValue:)) ?? .Both
|
||||
on = c.bool("on") ?? true
|
||||
}
|
||||
|
||||
/// Whether it changes the sound at all (a 0 dB peak or shelf doesn't).
|
||||
var active: Bool { on && (!type.hasGain || abs(gain) >= 0.01) }
|
||||
|
||||
/// Its biquad sections at [fs]: [b0, b1, b2, a1, a2] each, normalised by a0.
|
||||
func sections(_ fs: Double) -> [[Double]] {
|
||||
guard active else { return [] }
|
||||
let f0 = min(max(Double(freq), 1), fs * 0.49)
|
||||
let q = min(max(Double(self.q), 0.025), 100)
|
||||
let g = Double(gain)
|
||||
switch type {
|
||||
case .LowPass, .HighPass:
|
||||
let lp = type == .LowPass
|
||||
if slope <= 6 { return [firstOrder(fs, f0, lp)] }
|
||||
if slope <= 12 { return [Biquad.design(type, fs, f0, 0, q)] }
|
||||
// Butterworth of order slope/6, as cascaded sections (plus a first-order one when odd).
|
||||
let n = slope / 6
|
||||
var out = (1...(n / 2)).map { k in Biquad.design(type, fs, f0, 0, 1 / (2 * cos(Double(2 * k - 1) * .pi / Double(2 * n)))) }
|
||||
if n % 2 == 1 { out.append(firstOrder(fs, f0, lp)) }
|
||||
return out
|
||||
default:
|
||||
return [Biquad.design(type, fs, f0, g, q)]
|
||||
}
|
||||
}
|
||||
|
||||
private func firstOrder(_ fs: Double, _ f0: Double, _ lowPass: Bool) -> [Double] {
|
||||
let k = tan(.pi * f0 / fs)
|
||||
let a1 = (k - 1) / (k + 1)
|
||||
return lowPass ? [k / (k + 1), k / (k + 1), 0, a1, 0] : [1 / (k + 1), -1 / (k + 1), 0, a1, 0]
|
||||
}
|
||||
|
||||
func applies(left: Bool) -> Bool { channel == .Both || (channel == .Left) == left }
|
||||
|
||||
/// The 10-band graphic EQ as peaking filters, ahead of the parametric ones.
|
||||
static func graphic(_ gains: [Float]) -> [EqFilter] {
|
||||
gains.enumerated().compactMap { i, g in
|
||||
abs(g) < 0.01 || i >= EQ_BANDS.count ? nil : EqFilter(id: "band\(i)", type: .Peak, freq: EQ_BANDS[i], gain: g, q: EQ_Q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine needs from an EQ profile; nil means bypass.
|
||||
struct EqParams: Equatable {
|
||||
var filters: [EqFilter]
|
||||
var preampDb: Float
|
||||
}
|
||||
|
||||
/// A biquad section, transposed direct form II, double state per channel.
|
||||
final class Biquad {
|
||||
private var b0 = 1.0, b1 = 0.0, b2 = 0.0, a1 = 0.0, a2 = 0.0
|
||||
private var zl1 = 0.0, zl2 = 0.0, zr1 = 0.0, zr2 = 0.0
|
||||
|
||||
func set(_ c: [Double]) {
|
||||
b0 = c[0]; b1 = c[1]; b2 = c[2]; a1 = c[3]; a2 = c[4]
|
||||
}
|
||||
|
||||
func reset() { zl1 = 0; zl2 = 0; zr1 = 0; zr2 = 0 }
|
||||
|
||||
/// [buf] is interleaved stereo.
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int, left: Bool = true, right: Bool = true) {
|
||||
var l1 = zl1, l2 = zl2, r1 = zr1, r2 = zr2
|
||||
for i in 0..<frames {
|
||||
if left {
|
||||
let xl = Double(buf[i * 2])
|
||||
let yl = b0 * xl + l1
|
||||
l1 = b1 * xl - a1 * yl + l2
|
||||
l2 = b2 * xl - a2 * yl
|
||||
buf[i * 2] = Float(yl)
|
||||
}
|
||||
if right {
|
||||
let xr = Double(buf[i * 2 + 1])
|
||||
let yr = b0 * xr + r1
|
||||
r1 = b1 * xr - a1 * yr + r2
|
||||
r2 = b2 * xr - a2 * yr
|
||||
buf[i * 2 + 1] = Float(yr)
|
||||
}
|
||||
}
|
||||
// Flush denormals so a silent tail doesn't cost CPU.
|
||||
zl1 = abs(l1) < 1e-25 ? 0 : l1; zl2 = abs(l2) < 1e-25 ? 0 : l2
|
||||
zr1 = abs(r1) < 1e-25 ? 0 : r1; zr2 = abs(r2) < 1e-25 ? 0 : r2
|
||||
}
|
||||
|
||||
/// RBJ Audio EQ Cookbook coefficients, [b0, b1, b2, a1, a2] normalised by a0.
|
||||
static func design(_ type: FilterType, _ fs: Double, _ f0: Double, _ gainDb: Double, _ q: Double) -> [Double] {
|
||||
let a = pow(10, gainDb / 40)
|
||||
let w0 = 2 * .pi * min(f0, fs * 0.49) / fs
|
||||
let alpha = sin(w0) / (2 * q)
|
||||
let cw = cos(w0)
|
||||
let sa = 2 * a.squareRoot() * alpha
|
||||
let c: [Double]
|
||||
switch type {
|
||||
case .Peak: c = [1 + alpha * a, -2 * cw, 1 - alpha * a, 1 + alpha / a, -2 * cw, 1 - alpha / a]
|
||||
case .LowShelf: c = [
|
||||
a * ((a + 1) - (a - 1) * cw + sa), 2 * a * ((a - 1) - (a + 1) * cw), a * ((a + 1) - (a - 1) * cw - sa),
|
||||
(a + 1) + (a - 1) * cw + sa, -2 * ((a - 1) + (a + 1) * cw), (a + 1) + (a - 1) * cw - sa,
|
||||
]
|
||||
case .HighShelf: c = [
|
||||
a * ((a + 1) + (a - 1) * cw + sa), -2 * a * ((a - 1) + (a + 1) * cw), a * ((a + 1) + (a - 1) * cw - sa),
|
||||
(a + 1) - (a - 1) * cw + sa, 2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - sa,
|
||||
]
|
||||
case .LowPass: c = [(1 - cw) / 2, 1 - cw, (1 - cw) / 2, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .HighPass: c = [(1 + cw) / 2, -(1 + cw), (1 + cw) / 2, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .BandPass: c = [alpha, 0, -alpha, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .Notch: c = [1, -2 * cw, 1, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
case .AllPass: c = [1 - alpha, -2 * cw, 1 + alpha, 1 + alpha, -2 * cw, 1 - alpha]
|
||||
}
|
||||
let a0 = c[3]
|
||||
return [c[0] / a0, c[1] / a0, c[2] / a0, c[4] / a0, c[5] / a0]
|
||||
}
|
||||
|
||||
/// |H| in dB at frequency f.
|
||||
static func magnitudeDb(_ c: [Double], _ fs: Double, _ f: Double) -> Double {
|
||||
let w = 2 * .pi * f / fs
|
||||
let cw = cos(w), sw = sin(w), c2 = cos(2 * w), s2 = sin(2 * w)
|
||||
let nr = c[0] + c[1] * cw + c[2] * c2
|
||||
let ni = -(c[1] * sw + c[2] * s2)
|
||||
let dr = 1 + c[3] * cw + c[4] * c2
|
||||
let di = -(c[3] * sw + c[4] * s2)
|
||||
return 10 * log10(max(1e-30, nr * nr + ni * ni) / max(1e-30, dr * dr + di * di))
|
||||
}
|
||||
}
|
||||
|
||||
enum EqMath {
|
||||
/// Combined response of [filters] on one channel, in dB at each of [freqs].
|
||||
static func response(_ filters: [EqFilter], _ freqs: [Double], left: Bool = true, fs: Double = 48000) -> [Double] {
|
||||
let coeffs = filters.filter { $0.applies(left: left) }.flatMap { $0.sections(fs) }
|
||||
return freqs.map { f in coeffs.reduce(0) { $0 + Biquad.magnitudeDb($1, fs, f) } }
|
||||
}
|
||||
|
||||
static func logFreqs(_ n: Int, lo: Double = 20, hi: Double = 20000) -> [Double] {
|
||||
(0..<n).map { lo * pow(hi / lo, Double($0) / (Double(n) - 1)) }
|
||||
}
|
||||
|
||||
/// Anti-clipping preamp: the negative of the curve's highest point (manual preamp included,
|
||||
/// louder channel), so a full-scale signal at the loudest frequency still peaks at 0 dBFS.
|
||||
static func autoPreampDb(_ filters: [EqFilter], manualDb: Float = 0) -> Float {
|
||||
let f = logFreqs(512)
|
||||
let peak = max(response(filters, f, left: true).max() ?? 0, response(filters, f, left: false).max() ?? 0)
|
||||
return -Float(max(0, peak + Double(manualDb)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs on the render thread; [configure] is called there too, between buffers.
|
||||
final class Equalizer {
|
||||
private struct Stage { let filter: Biquad; let left: Bool; let right: Bool }
|
||||
private struct Shape: Equatable { let id: String; let channel: EqChannel; let type: FilterType }
|
||||
|
||||
private var stages: [Stage] = []
|
||||
private var shape: [Shape] = []
|
||||
private var fs = 0
|
||||
private var params: EqParams?
|
||||
private var preamp: Float = 1
|
||||
private(set) var enabled = false
|
||||
|
||||
func configure(_ p: EqParams?, sampleRate: Int) {
|
||||
enabled = p != nil
|
||||
guard let p, p != params || sampleRate != fs else { return }
|
||||
let rateChanged = sampleRate != fs
|
||||
fs = sampleRate
|
||||
params = p
|
||||
preamp = dbToGain(p.preampDb)
|
||||
let designed = p.filters.flatMap { f in f.sections(Double(fs)).map { ($0, f) } }
|
||||
// Coefficient changes (a slider drag) keep the filters' state; a different layout starts fresh.
|
||||
let newShape = designed.map { Shape(id: $0.1.id, channel: $0.1.channel, type: $0.1.type) }
|
||||
if rateChanged || newShape != shape {
|
||||
stages = designed.map { Stage(filter: Biquad(), left: $0.1.channel != .Right, right: $0.1.channel != .Left) }
|
||||
shape = newShape
|
||||
}
|
||||
for (i, d) in designed.enumerated() { stages[i].filter.set(d.0) }
|
||||
}
|
||||
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int) {
|
||||
guard enabled else { return }
|
||||
if preamp != 1 { for i in 0..<(frames * 2) { buf[i] *= preamp } }
|
||||
for s in stages { s.filter.process(buf, frames, left: s.left, right: s.right) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Peak limiter on the master bus: stems pushed past unity or an EQ boost never clip.
|
||||
/// Instant attack, ~120 ms release, ceiling just under full scale.
|
||||
final class Limiter {
|
||||
private let ceiling: Float
|
||||
private var gain: Float = 1
|
||||
private var release: Float = 0
|
||||
|
||||
init(ceiling: Float = 0.985) { self.ceiling = ceiling }
|
||||
|
||||
func setSampleRate(_ fs: Int) { release = 1 - exp(-1 / (0.12 * Float(fs))) }
|
||||
|
||||
func process(_ buf: UnsafeMutablePointer<Float>, _ frames: Int) {
|
||||
var g = gain
|
||||
for i in 0..<frames {
|
||||
let l = buf[i * 2], r = buf[i * 2 + 1]
|
||||
let peak = max(abs(l), abs(r))
|
||||
let need: Float = peak * g > ceiling ? ceiling / peak : 1
|
||||
g = need < g ? need : g + (1 - g) * release
|
||||
if peak * g > ceiling { g = ceiling / peak }
|
||||
buf[i * 2] = l * g
|
||||
buf[i * 2 + 1] = r * g
|
||||
}
|
||||
gain = g
|
||||
}
|
||||
}
|
||||
|
||||
func dbToGain(_ db: Float) -> Float { pow(10, db / 20) }
|
||||
func gainToDb(_ g: Float) -> Float { g <= 1e-6 ? -120 : 20 * log10(g) }
|
||||
|
||||
/// Master slider taper (same cubic curve as the desktop app).
|
||||
func masterTaper(_ v: Float) -> Float { let x = min(max(v, 0), 1); return x * x * x }
|
||||
|
||||
/// Stem slider: 0..1 with unity at 0.8 and +3.9 dB at the top.
|
||||
func stemTaper(_ v: Float) -> Float { let x = min(max(v, 0), 1) / 0.8; return x * x }
|
||||
func stemUntaper(_ g: Float) -> Float { min(max(max(g, 0).squareRoot() * 0.8, 0), 1) }
|
||||
@@ -0,0 +1,379 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// os_unfair_lock in a stable heap slot. Short critical sections only; the render thread takes it.
|
||||
final class UnfairLock: @unchecked Sendable {
|
||||
private let p: UnsafeMutablePointer<os_unfair_lock>
|
||||
init() {
|
||||
p = .allocate(capacity: 1)
|
||||
p.initialize(to: os_unfair_lock())
|
||||
}
|
||||
deinit { p.deallocate() }
|
||||
@inline(__always) func lock() { os_unfair_lock_lock(p) }
|
||||
@inline(__always) func unlock() { os_unfair_lock_unlock(p) }
|
||||
@inline(__always) func with<T>(_ f: () throws -> T) rethrows -> T {
|
||||
os_unfair_lock_lock(p); defer { os_unfair_lock_unlock(p) }
|
||||
return try f()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-producer single-consumer ring of interleaved stereo float frames.
|
||||
final class FloatRing: @unchecked Sendable {
|
||||
private let buf: UnsafeMutablePointer<Float>
|
||||
private let cap: Int
|
||||
private var readPos: Int64 = 0
|
||||
private var writePos: Int64 = 0
|
||||
private let lock = UnfairLock()
|
||||
private let notFull = DispatchSemaphore(value: 0)
|
||||
private var eosFlag = false
|
||||
|
||||
init(capacityFrames: Int) {
|
||||
cap = capacityFrames * 2
|
||||
buf = .allocate(capacity: cap)
|
||||
buf.initialize(repeating: 0, count: cap)
|
||||
}
|
||||
deinit { buf.deallocate() }
|
||||
|
||||
var eos: Bool {
|
||||
get { lock.with { eosFlag } }
|
||||
set { lock.with { eosFlag = newValue } }
|
||||
}
|
||||
|
||||
var availableFrames: Int { lock.with { Int((writePos - readPos) / 2) } }
|
||||
var freeFrames: Int { lock.with { Int((Int64(cap) - (writePos - readPos)) / 2) } }
|
||||
|
||||
/// Blocks while full; returns false if [abort] became true first. [len] counts floats.
|
||||
func write(_ src: UnsafePointer<Float>, _ len: Int, abort: () -> Bool) -> Bool {
|
||||
var done = 0
|
||||
while done < len {
|
||||
lock.lock()
|
||||
let free = Int(Int64(cap) - (writePos - readPos))
|
||||
if free <= 0 {
|
||||
lock.unlock()
|
||||
if abort() { return false }
|
||||
_ = notFull.wait(timeout: .now() + .milliseconds(20))
|
||||
continue
|
||||
}
|
||||
lock.unlock()
|
||||
if abort() { return false }
|
||||
lock.lock()
|
||||
let n = min(Int(Int64(cap) - (writePos - readPos)), len - done)
|
||||
let w = Int(writePos % Int64(cap))
|
||||
let first = min(n, cap - w)
|
||||
(buf + w).update(from: src + done, count: first)
|
||||
if n > first { buf.update(from: src + done + first, count: n - first) }
|
||||
writePos += Int64(n)
|
||||
done += n
|
||||
lock.unlock()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Copies up to [frames] frames into dst; returns frames copied. Never blocks on data.
|
||||
func read(_ dst: UnsafeMutablePointer<Float>, _ frames: Int) -> Int {
|
||||
lock.lock()
|
||||
let n = Int(min(Int64(frames * 2), writePos - readPos))
|
||||
let r = Int(readPos % Int64(cap))
|
||||
let first = min(n, cap - r)
|
||||
dst.update(from: buf + r, count: first)
|
||||
if n > first { (dst + first).update(from: buf, count: n - first) }
|
||||
readPos += Int64(n)
|
||||
lock.unlock()
|
||||
if n > 0 { notFull.signal() }
|
||||
return n / 2
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lock.with {
|
||||
readPos = writePos
|
||||
eosFlag = false
|
||||
}
|
||||
notFull.signal()
|
||||
}
|
||||
|
||||
func wake() { notFull.signal() }
|
||||
}
|
||||
|
||||
/// The decoder's view of a cached resource. Retries brief network trouble before giving up.
|
||||
final class ResourceDataSource: @unchecked Sendable {
|
||||
private let res: CachedResource
|
||||
private let onClose: () -> Void
|
||||
private let lock = NSLock()
|
||||
private var failure: Error?
|
||||
private var isClosed = false
|
||||
|
||||
init(_ res: CachedResource, onClose: @escaping () -> Void = {}) {
|
||||
self.res = res
|
||||
self.onClose = onClose
|
||||
}
|
||||
|
||||
var failed: Error? { lock.withLock { failure } }
|
||||
var closed: Bool { lock.withLock { isClosed } }
|
||||
|
||||
/// Bytes read, 0 at end of file.
|
||||
func readAt(_ position: Int64, _ buffer: UnsafeMutableRawPointer, _ size: Int) throws -> Int {
|
||||
var attempt = 0
|
||||
while true {
|
||||
do {
|
||||
let n = try res.readAt(position, buffer, size)
|
||||
lock.withLock { failure = nil }
|
||||
if n > 0 && attempt == 0 { res.prefetch() }
|
||||
return max(n, 0)
|
||||
} catch {
|
||||
attempt += 1
|
||||
if closed || attempt > 8 {
|
||||
lock.withLock { failure = error }
|
||||
throw error
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.25 * Double(attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func size() -> Int64 {
|
||||
do { return try res.size() } catch {
|
||||
lock.withLock { failure = error }
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
let first = lock.withLock { () -> Bool in
|
||||
if isClosed { return false }
|
||||
isClosed = true
|
||||
return true
|
||||
}
|
||||
if first { onClose() }
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodeError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Decodes one stem on its own thread into [ring], as stereo float at the file's own rate.
|
||||
/// FLAC, Ogg Opus and whatever else Core Audio reads all go through ExtAudioFile, whose seeks
|
||||
/// are sample-accurate, so every stem of a track resumes on exactly the same frame.
|
||||
final class StemDecoder: @unchecked Sendable {
|
||||
let name: String
|
||||
let ring: FloatRing
|
||||
private let source: ResourceDataSource
|
||||
private let typeHint: AudioFileTypeID
|
||||
private let prepared = NSCondition()
|
||||
private var isPrepared = false
|
||||
private let state = UnfairLock()
|
||||
private var _sampleRate = 0
|
||||
private var _durationUs: Int64 = 0
|
||||
private var _error: Error?
|
||||
private var _readyGen = -1
|
||||
|
||||
var sampleRate: Int { state.with { _sampleRate } }
|
||||
var durationUs: Int64 { state.with { _durationUs } }
|
||||
var error: Error? { state.with { _error } }
|
||||
/// Generation whose data is in the ring (or that hit EOS).
|
||||
var readyGen: Int { state.with { _readyGen } }
|
||||
|
||||
private let seekLock = NSCondition()
|
||||
private var pendingGen = -1
|
||||
private var pendingFrame: Int64 = 0
|
||||
private var releasedFlag = false
|
||||
private var released: Bool { seekLock.withLock { releasedFlag } }
|
||||
private var thread: Thread?
|
||||
|
||||
/// [hint] is the file's extension or format ("flac", "opus", "mp3", ...), so Core Audio
|
||||
/// doesn't have to guess from the bytes.
|
||||
init(name: String, source: ResourceDataSource, ringFrames: Int, hint: String) {
|
||||
self.name = name
|
||||
self.source = source
|
||||
ring = FloatRing(capacityFrames: ringFrames)
|
||||
typeHint = Self.fileType(hint)
|
||||
}
|
||||
|
||||
static func fileType(_ hint: String) -> AudioFileTypeID {
|
||||
switch hint.lowercased() {
|
||||
case "flac": return kAudioFileFLACType
|
||||
case "opus", "ogg", "oga": return AudioFileTypeID(0x4F67_6773) // 'Oggs'
|
||||
case "mp3": return kAudioFileMP3Type
|
||||
case "m4a", "mp4", "alac": return kAudioFileM4AType
|
||||
case "aac": return kAudioFileAAC_ADTSType
|
||||
case "wav": return kAudioFileWAVEType
|
||||
case "aif", "aiff": return kAudioFileAIFFType
|
||||
case "caf": return kAudioFileCAFType
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func start(gen: Int, frame: Int64) {
|
||||
seek(gen: gen, frame: frame)
|
||||
let t = Thread { [self] in run() }
|
||||
t.name = "stem-\(name)"
|
||||
t.qualityOfService = .userInteractive
|
||||
thread = t
|
||||
t.start()
|
||||
}
|
||||
|
||||
/// Blocks until the stream's format is known. False on error or timeout.
|
||||
func awaitPrepared(timeoutMs: Int) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
|
||||
prepared.lock()
|
||||
while !isPrepared { if !prepared.wait(until: deadline) { break } }
|
||||
let ok = isPrepared
|
||||
prepared.unlock()
|
||||
return ok && error == nil
|
||||
}
|
||||
|
||||
private func markPrepared() {
|
||||
prepared.lock()
|
||||
isPrepared = true
|
||||
prepared.broadcast()
|
||||
prepared.unlock()
|
||||
}
|
||||
|
||||
func seek(gen: Int, frame: Int64) {
|
||||
seekLock.lock()
|
||||
pendingGen = gen
|
||||
pendingFrame = frame
|
||||
seekLock.broadcast()
|
||||
seekLock.unlock()
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private func seekPending(_ current: Int) -> Bool { seekLock.withLock { pendingGen != current || releasedFlag } }
|
||||
|
||||
func release() {
|
||||
seekLock.lock()
|
||||
releasedFlag = true
|
||||
seekLock.broadcast()
|
||||
seekLock.unlock()
|
||||
source.close()
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private static let readProc: AudioFile_ReadProc = { ud, pos, req, buf, actual in
|
||||
let src = Unmanaged<ResourceDataSource>.fromOpaque(ud).takeUnretainedValue()
|
||||
do {
|
||||
actual.pointee = UInt32(try src.readAt(pos, buf, Int(req)))
|
||||
return noErr
|
||||
} catch {
|
||||
actual.pointee = 0
|
||||
return kAudioFileUnspecifiedError
|
||||
}
|
||||
}
|
||||
|
||||
private static let sizeProc: AudioFile_GetSizeProc = { ud in
|
||||
max(0, Unmanaged<ResourceDataSource>.fromOpaque(ud).takeUnretainedValue().size())
|
||||
}
|
||||
|
||||
private func run() {
|
||||
var fileId: AudioFileID?
|
||||
var ext: ExtAudioFileRef?
|
||||
let srcRef = Unmanaged.passRetained(source)
|
||||
defer {
|
||||
if let ext { ExtAudioFileDispose(ext) }
|
||||
if let fileId { AudioFileClose(fileId) }
|
||||
srcRef.release()
|
||||
markPrepared()
|
||||
}
|
||||
do {
|
||||
var st = AudioFileOpenWithCallbacks(srcRef.toOpaque(), Self.readProc, nil, Self.sizeProc, nil, typeHint, &fileId)
|
||||
if st != noErr || fileId == nil {
|
||||
throw source.failed ?? DecodeError("Can't read \(name) (\(st))")
|
||||
}
|
||||
st = ExtAudioFileWrapAudioFileID(fileId!, false, &ext)
|
||||
guard st == noErr, let ext else { throw DecodeError("Can't decode \(name) (\(st))") }
|
||||
|
||||
var fmt = AudioStreamBasicDescription()
|
||||
var sz = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||
st = ExtAudioFileGetProperty(ext, kExtAudioFileProperty_FileDataFormat, &sz, &fmt)
|
||||
guard st == noErr, fmt.mSampleRate > 0 else { throw DecodeError("No audio in \(name)") }
|
||||
let rate = Int(fmt.mSampleRate.rounded())
|
||||
var frames: Int64 = 0
|
||||
sz = UInt32(MemoryLayout<Int64>.size)
|
||||
ExtAudioFileGetProperty(ext, kExtAudioFileProperty_FileLengthFrames, &sz, &frames)
|
||||
// Asking for stereo makes the converter upmix mono and drop channels past the
|
||||
// first two, the same as Android's toStereo.
|
||||
var client = AudioStreamBasicDescription(
|
||||
mSampleRate: fmt.mSampleRate, mFormatID: kAudioFormatLinearPCM,
|
||||
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked,
|
||||
mBytesPerPacket: 8, mFramesPerPacket: 1, mBytesPerFrame: 8,
|
||||
mChannelsPerFrame: 2, mBitsPerChannel: 32, mReserved: 0)
|
||||
st = ExtAudioFileSetProperty(ext, kExtAudioFileProperty_ClientDataFormat, UInt32(MemoryLayout.size(ofValue: client)), &client)
|
||||
guard st == noErr else { throw DecodeError("Can't convert \(name) (\(st))") }
|
||||
if fmt.mChannelsPerFrame > 2 {
|
||||
// Keep L/R instead of letting the converter downmix surround.
|
||||
var conv: AudioConverterRef?
|
||||
sz = UInt32(MemoryLayout<AudioConverterRef?>.size)
|
||||
if ExtAudioFileGetProperty(ext, kExtAudioFileProperty_AudioConverter, &sz, &conv) == noErr, let conv {
|
||||
var map: [Int32] = [0, 1]
|
||||
AudioConverterSetProperty(conv, kAudioConverterChannelMap, UInt32(MemoryLayout<Int32>.size * 2), &map)
|
||||
var nul: Unmanaged<CFPropertyList>?
|
||||
ExtAudioFileSetProperty(ext, kExtAudioFileProperty_ConverterConfig, UInt32(MemoryLayout<Unmanaged<CFPropertyList>?>.size), &nul)
|
||||
}
|
||||
}
|
||||
state.with {
|
||||
_sampleRate = rate
|
||||
_durationUs = frames > 0 ? frames * 1_000_000 / Int64(rate) : 0
|
||||
}
|
||||
markPrepared()
|
||||
|
||||
let chunk = 4096
|
||||
let scratch = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
defer { scratch.deallocate() }
|
||||
var gen = -1
|
||||
var outputDone = false
|
||||
var pos: Int64 = 0
|
||||
|
||||
while !released {
|
||||
seekLock.lock()
|
||||
if pendingGen != gen {
|
||||
gen = pendingGen
|
||||
let target = pendingFrame
|
||||
seekLock.unlock()
|
||||
ring.clear()
|
||||
st = ExtAudioFileSeek(ext, target)
|
||||
if st != noErr { throw source.failed ?? DecodeError("Seek failed in \(name) (\(st))") }
|
||||
pos = target
|
||||
outputDone = false
|
||||
} else if outputDone {
|
||||
ring.eos = true
|
||||
state.with { _readyGen = gen }
|
||||
_ = seekLock.wait(until: Date().addingTimeInterval(0.1))
|
||||
seekLock.unlock()
|
||||
continue
|
||||
} else {
|
||||
seekLock.unlock()
|
||||
}
|
||||
if released { break }
|
||||
|
||||
var n = UInt32(chunk)
|
||||
var abl = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 2, mDataByteSize: UInt32(chunk * 8), mData: scratch))
|
||||
st = ExtAudioFileRead(ext, &n, &abl)
|
||||
if st != noErr {
|
||||
if released { break }
|
||||
throw source.failed ?? DecodeError("Decode failed in \(name) (\(st))")
|
||||
}
|
||||
if n == 0 {
|
||||
// A short file is only an error if the source gave up well before the end.
|
||||
if let f = source.failed, frames > 0, pos < frames - Int64(rate) * 2 { throw f }
|
||||
outputDone = true
|
||||
continue
|
||||
}
|
||||
pos += Int64(n)
|
||||
let myGen = gen
|
||||
if ring.write(scratch, Int(n) * 2, abort: { seekPending(myGen) }) {
|
||||
state.with { _readyGen = gen }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if !released {
|
||||
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
|
||||
state.with { _error = error }
|
||||
}
|
||||
ring.eos = true
|
||||
state.with { _readyGen = Int.max }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@inline(__always) func monoNanos() -> Int64 { Int64(DispatchTime.now().uptimeNanoseconds) }
|
||||
|
||||
/// One track's stems, decoding in sync. Built off the main thread, then handed to the engine.
|
||||
final class TrackSession: @unchecked Sendable {
|
||||
let token: Int64
|
||||
let names: [String]
|
||||
let decoders: [StemDecoder]
|
||||
var sampleRate: Int { decoders.first?.sampleRate ?? 0 }
|
||||
var durationFrames: Int64 { (decoders.map(\.durationUs).max() ?? 0) * Int64(sampleRate) / 1_000_000 }
|
||||
fileprivate var gen = 0
|
||||
fileprivate var framePos: Int64 = 0
|
||||
fileprivate var target: [Float]
|
||||
fileprivate var current: [Float]
|
||||
fileprivate var mixVersion = -1
|
||||
|
||||
init(token: Int64, names: [String], decoders: [StemDecoder]) {
|
||||
self.token = token
|
||||
self.names = names
|
||||
self.decoders = decoders
|
||||
target = Array(repeating: 1, count: names.count)
|
||||
current = Array(repeating: 1, count: names.count)
|
||||
}
|
||||
|
||||
func error() -> Error? { decoders.lazy.compactMap(\.error).first }
|
||||
|
||||
fileprivate func ready(_ need: Int) -> Bool {
|
||||
decoders.allSatisfy { d in
|
||||
d.error != nil || (d.readyGen == gen || d.readyGen == Int.max) && (d.ring.eos || d.ring.availableFrames >= need)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func finished() -> Bool {
|
||||
decoders.allSatisfy { d in
|
||||
d.error != nil || (d.readyGen == gen || d.readyGen == Int.max) && d.ring.eos && d.ring.availableFrames == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-targets decoders; only valid before the session is handed to the engine.
|
||||
func startAt(_ frame: Int64) {
|
||||
gen += 1
|
||||
framePos = frame
|
||||
decoders.forEach { $0.seek(gen: gen, frame: frame) }
|
||||
}
|
||||
|
||||
func release() { decoders.forEach { $0.release() } }
|
||||
}
|
||||
|
||||
/// Per-stem RMS of short blocks as they're mixed, stamped with their track frame, so meters can
|
||||
/// show what's coming out of the speaker now rather than what was just mixed.
|
||||
final class MeterHistory: @unchecked Sendable {
|
||||
static let block = 256
|
||||
static let maxStems = 8
|
||||
/// ~5 s at 48 kHz: well past the output buffer.
|
||||
private static let cap = 1024
|
||||
private static let staleFrames: Int64 = 8192
|
||||
|
||||
private var tokens = [Int64](repeating: 0, count: cap)
|
||||
private var starts = [Int64](repeating: 0, count: cap)
|
||||
private var values = [Float](repeating: 0, count: cap * maxStems)
|
||||
private var head = 0
|
||||
private var count = 0
|
||||
private let lock = UnfairLock()
|
||||
|
||||
func clear() { lock.with { count = 0 } }
|
||||
|
||||
func add(_ token: Int64, _ start: Int64, _ rms: UnsafePointer<Float>, _ stems: Int) {
|
||||
lock.with {
|
||||
tokens[head] = token
|
||||
starts[head] = start
|
||||
let base = head * Self.maxStems
|
||||
for k in 0..<Self.maxStems { values[base + k] = k < stems ? rms[k] : 0 }
|
||||
head = (head + 1) % Self.cap
|
||||
count = min(count + 1, Self.cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills [out] with the levels of [token]'s block playing at [frame]; false if there's none.
|
||||
func read(_ token: Int64, _ frame: Int64, _ out: inout [Float]) -> Bool {
|
||||
lock.with {
|
||||
guard count > 0 else { return false }
|
||||
for k in 1...count {
|
||||
let i = (head - k + Self.cap) % Self.cap
|
||||
if tokens[i] != token || starts[i] > frame { continue }
|
||||
if frame - starts[i] > Self.staleFrames { return false }
|
||||
for j in 0..<min(out.count, Self.maxStems) { out[j] = values[i * Self.maxStems + j] }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The far end of the output ring: the render callback takes mixed frames from it and counts
|
||||
/// what it took, so the engine knows what has reached the speaker.
|
||||
private final class OutputTap: @unchecked Sendable {
|
||||
let ring: FloatRing
|
||||
let rate: Int
|
||||
private let lock = UnfairLock()
|
||||
private var consumed: Int64 = 0
|
||||
private var lastStart: Int64 = 0
|
||||
private var lastNanos: Int64 = 0
|
||||
private var runningFlag = false
|
||||
private var latencyNanos: Int64 = 0
|
||||
private let scratch: UnsafeMutablePointer<Float>
|
||||
private static let scratchFrames = 4096
|
||||
|
||||
init(rate: Int, frames: Int) {
|
||||
self.rate = rate
|
||||
ring = FloatRing(capacityFrames: frames)
|
||||
scratch = .allocate(capacity: Self.scratchFrames * 2)
|
||||
}
|
||||
deinit { scratch.deallocate() }
|
||||
|
||||
var running: Bool {
|
||||
get { lock.with { runningFlag } }
|
||||
set { lock.with { runningFlag = newValue } }
|
||||
}
|
||||
|
||||
func setLatency(_ seconds: Double) { lock.with { latencyNanos = Int64(seconds * 1e9) } }
|
||||
|
||||
func flush() {
|
||||
lock.with {
|
||||
ring.clear()
|
||||
consumed = 0
|
||||
lastStart = 0
|
||||
lastNanos = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames heard so far (since the last flush), never more than [written].
|
||||
func presented(_ now: Int64, _ written: Int64) -> Int64 {
|
||||
lock.with {
|
||||
if lastNanos == 0 { return 0 }
|
||||
let elapsed = runningFlag ? max(0, now - lastNanos) : 0
|
||||
let est = lastStart + elapsed * Int64(rate) / 1_000_000_000 - latencyNanos * Int64(rate) / 1_000_000_000
|
||||
return min(max(0, min(est, consumed)), written)
|
||||
}
|
||||
}
|
||||
|
||||
func render(_ silence: UnsafeMutablePointer<ObjCBool>, _ ts: UnsafePointer<AudioTimeStamp>, _ frames: AVAudioFrameCount, _ abl: UnsafeMutablePointer<AudioBufferList>) -> OSStatus {
|
||||
let list = UnsafeMutableAudioBufferListPointer(abl)
|
||||
let n = Int(frames)
|
||||
guard list.count >= 2, let l = list[0].mData?.assumingMemoryBound(to: Float.self),
|
||||
let r = list[1].mData?.assumingMemoryBound(to: Float.self) else { return noErr }
|
||||
lock.lock()
|
||||
if !runningFlag {
|
||||
lock.unlock()
|
||||
l.update(repeating: 0, count: n)
|
||||
r.update(repeating: 0, count: n)
|
||||
silence.pointee = true
|
||||
return noErr
|
||||
}
|
||||
let host = ts.pointee.mFlags.contains(.hostTimeValid) ? Int64(AVAudioTime.seconds(forHostTime: ts.pointee.mHostTime) * 1e9) : monoNanos()
|
||||
lastStart = consumed
|
||||
lastNanos = host
|
||||
var done = 0
|
||||
while done < n {
|
||||
let want = min(n - done, Self.scratchFrames)
|
||||
let got = ring.read(scratch, want)
|
||||
for i in 0..<got {
|
||||
l[done + i] = scratch[i * 2]
|
||||
r[done + i] = scratch[i * 2 + 1]
|
||||
}
|
||||
consumed += Int64(got)
|
||||
done += got
|
||||
if got < want { break }
|
||||
}
|
||||
lock.unlock()
|
||||
if done < n {
|
||||
(l + done).update(repeating: 0, count: n - done)
|
||||
(r + done).update(repeating: 0, count: n - done)
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
}
|
||||
|
||||
protocol StemEngineListener: AnyObject {
|
||||
func onEngineState()
|
||||
/// [token]'s audio is now what's coming out of the speaker.
|
||||
func onTransition(_ token: Int64)
|
||||
func onEnded(_ token: Int64)
|
||||
func onError(_ token: Int64, _ message: String)
|
||||
}
|
||||
|
||||
/// Mixes a [TrackSession]'s stems into one output: per-stem gain (ramped), EQ with anti-clip
|
||||
/// preamp, master gain, then a peak limiter. All output calls happen on the engine thread; the
|
||||
/// public methods just queue commands.
|
||||
final class StemEngine: @unchecked Sendable {
|
||||
static let chunk = 1024
|
||||
/// Per stem, decoded ahead of the mixer.
|
||||
static let ringSeconds = 3
|
||||
/// Frames each stem must have before (re)starting output.
|
||||
static let prefill = 8192
|
||||
static let bufferMs = 120
|
||||
|
||||
/// Where playback is; position = frame + (now - nanos) while [running].
|
||||
struct Snapshot {
|
||||
var token: Int64
|
||||
var frame: Int64
|
||||
var rate: Int
|
||||
var nanos: Int64
|
||||
var running: Bool
|
||||
func positionMs(_ now: Int64 = monoNanos()) -> Int64 {
|
||||
if rate <= 0 { return 0 }
|
||||
let f = running ? frame + (now - nanos) * Int64(rate) / 1_000_000_000 : frame
|
||||
return f * 1000 / Int64(rate)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Segment { let writtenStart: Int64; let token: Int64; let frameStart: Int64 }
|
||||
|
||||
private weak var listener: StemEngineListener?
|
||||
private let cmdLock = NSCondition()
|
||||
private var commands: [() -> Void] = []
|
||||
private var released = false
|
||||
|
||||
private var session: TrackSession?
|
||||
private var next: TrackSession?
|
||||
private var wantPlay = false
|
||||
private let av = AVAudioEngine()
|
||||
private var node: AVAudioSourceNode?
|
||||
private var tap: OutputTap?
|
||||
private var trackRate = 0
|
||||
private var written: Int64 = 0
|
||||
private var segments: [Segment] = []
|
||||
private var drainUntil: Int64 = -1
|
||||
private var drainDeadline: Int64 = 0
|
||||
private var drainToken: Int64 = -1
|
||||
private var waitingSince: Int64 = 0
|
||||
private var errorReported: Int64 = -1
|
||||
private let eq = Equalizer()
|
||||
private let limiter = Limiter()
|
||||
private var masterCur: Float = 1
|
||||
private let out = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
private let tmp = UnsafeMutablePointer<Float>.allocate(capacity: chunk * 2)
|
||||
private let meters = MeterHistory()
|
||||
private var meterSums = [Float](repeating: 0, count: chunk / MeterHistory.block * MeterHistory.maxStems)
|
||||
|
||||
private let shared = UnfairLock()
|
||||
private var _eqParams: EqParams?
|
||||
private var _masterGain: Float = 1
|
||||
private var _mix: [String: Float] = [:]
|
||||
private var _mixVersion = 0
|
||||
private var _snapshot = Snapshot(token: -1, frame: 0, rate: 44100, nanos: 0, running: false)
|
||||
private var _playing = false
|
||||
private var _buffering = false
|
||||
|
||||
var eqParams: EqParams? {
|
||||
get { shared.with { _eqParams } }
|
||||
set { shared.with { _eqParams = newValue } }
|
||||
}
|
||||
var masterGain: Float {
|
||||
get { shared.with { _masterGain } }
|
||||
set { shared.with { _masterGain = newValue } }
|
||||
}
|
||||
private(set) var snapshot: Snapshot {
|
||||
get { shared.with { _snapshot } }
|
||||
set { shared.with { _snapshot = newValue } }
|
||||
}
|
||||
var playing: Bool { shared.with { _playing } }
|
||||
var buffering: Bool { shared.with { _buffering } }
|
||||
|
||||
init(listener: StemEngineListener) {
|
||||
self.listener = listener
|
||||
out.initialize(repeating: 0, count: Self.chunk * 2)
|
||||
tmp.initialize(repeating: 0, count: Self.chunk * 2)
|
||||
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: av, queue: nil) { [weak self] _ in
|
||||
// The route or hardware format changed and the engine stopped; start it again.
|
||||
self?.post { self?.restartOutput() }
|
||||
}
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: nil) { [weak self] _ in
|
||||
self?.post { self?.tap?.setLatency(Self.outputLatency()) }
|
||||
}
|
||||
#endif
|
||||
let t = Thread { [weak self] in self?.loop() }
|
||||
t.name = "stem-engine"
|
||||
t.qualityOfService = .userInteractive
|
||||
t.start()
|
||||
}
|
||||
|
||||
// ---- commands (any thread) --------------------------------------------------------
|
||||
|
||||
private func post(_ cmd: @escaping () -> Void) {
|
||||
cmdLock.lock()
|
||||
commands.append(cmd)
|
||||
cmdLock.broadcast()
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
/// Replaces whatever is playing with [s], which must already be prepared.
|
||||
func load(_ s: TrackSession, play: Bool) {
|
||||
post { [self] in
|
||||
dropAll()
|
||||
session = s
|
||||
wantPlay = play
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The track to continue into gaplessly when the current one ends (or nil).
|
||||
func queueNext(_ s: TrackSession?) {
|
||||
post { [self] in
|
||||
if next !== s { next?.release() }
|
||||
next = s
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ play: Bool) {
|
||||
post { [self] in
|
||||
wantPlay = play
|
||||
if !play { pauseOutput() }
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func seekTo(_ ms: Int64) {
|
||||
post { [self] in
|
||||
guard let s = session else { return }
|
||||
let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1))
|
||||
s.gen += 1
|
||||
s.framePos = frame
|
||||
s.decoders.forEach { $0.seek(gen: s.gen, frame: frame) }
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
post { [self] in
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
func setMix(_ gains: [String: Float]) {
|
||||
shared.with {
|
||||
_mix = gains
|
||||
_mixVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
cmdLock.lock()
|
||||
released = true
|
||||
cmdLock.broadcast()
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
/// The meters' levels for what's audible right now, into [out] (one per stem). False when
|
||||
/// nothing is playing, so the meters can fall.
|
||||
func levelsNow(_ out: inout [Float]) -> Bool {
|
||||
let snap = snapshot
|
||||
if !snap.running || snap.rate <= 0 { return false }
|
||||
let frame = snap.frame + (monoNanos() - snap.nanos) * Int64(snap.rate) / 1_000_000_000
|
||||
return meters.read(snap.token, frame, &out)
|
||||
}
|
||||
|
||||
// ---- engine thread ----------------------------------------------------------------
|
||||
|
||||
private var isReleased: Bool { cmdLock.withLock { released } }
|
||||
|
||||
private func dropAll() {
|
||||
session?.release()
|
||||
next?.release()
|
||||
session = nil
|
||||
next = nil
|
||||
drainUntil = -1
|
||||
pauseOutput()
|
||||
tap?.flush()
|
||||
written = 0
|
||||
segments.removeAll()
|
||||
}
|
||||
|
||||
private func resetOutput(_ s: TrackSession) {
|
||||
pauseOutput()
|
||||
tap?.flush()
|
||||
written = 0
|
||||
segments = [Segment(writtenStart: 0, token: s.token, frameStart: s.framePos)]
|
||||
drainUntil = -1
|
||||
waitingSince = 0
|
||||
meters.clear()
|
||||
errorReported = -1
|
||||
}
|
||||
|
||||
private func loop() {
|
||||
while !isReleased {
|
||||
while true {
|
||||
cmdLock.lock()
|
||||
let cmd = commands.isEmpty ? nil : commands.removeFirst()
|
||||
cmdLock.unlock()
|
||||
guard let cmd else { break }
|
||||
cmd()
|
||||
}
|
||||
do {
|
||||
try step()
|
||||
} catch {
|
||||
let tok = session?.token ?? -1
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
let msg = error.localizedDescription
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onError(tok, msg) }
|
||||
}
|
||||
}
|
||||
dropAll()
|
||||
av.stop()
|
||||
}
|
||||
|
||||
private func idle(_ ms: Int) {
|
||||
cmdLock.lock()
|
||||
if commands.isEmpty && !released { _ = cmdLock.wait(until: Date().addingTimeInterval(Double(ms) / 1000)) }
|
||||
cmdLock.unlock()
|
||||
}
|
||||
|
||||
private func step() throws {
|
||||
guard let s = session, wantPlay else {
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
idle(100)
|
||||
return
|
||||
}
|
||||
if let e = s.error() {
|
||||
if errorReported != s.token {
|
||||
errorReported = s.token
|
||||
wantPlay = false
|
||||
pauseOutput()
|
||||
setFlags(false, false)
|
||||
let tok = s.token, msg = e.localizedDescription
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onError(tok, msg.isEmpty ? "Playback failed" : msg) }
|
||||
}
|
||||
idle(50)
|
||||
return
|
||||
}
|
||||
if drainUntil >= 0 {
|
||||
drain(s)
|
||||
return
|
||||
}
|
||||
let need = written == 0 || buffering ? Self.prefill : Self.chunk
|
||||
if !s.ready(need) && !s.finished() {
|
||||
let now = monoNanos()
|
||||
if waitingSince == 0 { waitingSince = now }
|
||||
if !buffering && (written == 0 || now - waitingSince > 60_000_000) {
|
||||
tap?.running = false
|
||||
setFlags(false, true)
|
||||
}
|
||||
publish()
|
||||
idle(5)
|
||||
return
|
||||
}
|
||||
waitingSince = 0
|
||||
try ensureOutput(s.sampleRate)
|
||||
try startOutput()
|
||||
setFlags(true, false)
|
||||
|
||||
guard let tap else { return }
|
||||
if tap.ring.freeFrames < Self.chunk {
|
||||
publish()
|
||||
idle(5)
|
||||
return
|
||||
}
|
||||
let n = mixChunk(s)
|
||||
if n == 0 {
|
||||
if s.finished() { endOfTrack(s) } else { idle(2) }
|
||||
return
|
||||
}
|
||||
eq.configure(eqParams, sampleRate: s.sampleRate)
|
||||
eq.process(out, n)
|
||||
let m = masterGain
|
||||
rampGain(out, n, masterCur, m)
|
||||
masterCur = m
|
||||
limiter.process(out, n)
|
||||
_ = tap.ring.write(out, n * 2, abort: { false })
|
||||
written += Int64(n)
|
||||
publish()
|
||||
}
|
||||
|
||||
private func endOfTrack(_ s: TrackSession) {
|
||||
if let nx = next, nx.sampleRate == s.sampleRate, nx.error() == nil {
|
||||
// Gapless: keep writing into the same output.
|
||||
segments.append(Segment(writtenStart: written, token: nx.token, frameStart: nx.framePos))
|
||||
session = nx
|
||||
next = nil
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
// Let the output play out, then hand over.
|
||||
drainUntil = written
|
||||
drainToken = s.token
|
||||
drainDeadline = monoNanos() + written * 1_000_000_000 / Int64(max(1, trackRate)) + 1_000_000_000
|
||||
}
|
||||
|
||||
private func drain(_ s: TrackSession) {
|
||||
let (presented, _) = self.presented()
|
||||
publish()
|
||||
if presented < drainUntil - 64 && monoNanos() < drainDeadline {
|
||||
idle(10)
|
||||
return
|
||||
}
|
||||
drainUntil = -1
|
||||
if let nx = next, nx.error() == nil {
|
||||
session = nx
|
||||
next = nil
|
||||
s.release()
|
||||
resetOutput(nx)
|
||||
let tok = nx.token
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onTransition(tok) }
|
||||
} else {
|
||||
let tok = drainToken
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onEnded(tok) }
|
||||
}
|
||||
}
|
||||
|
||||
private func mixChunk(_ s: TrackSession) -> Int {
|
||||
if s.finished() { return 0 }
|
||||
var n = Self.chunk
|
||||
for d in s.decoders {
|
||||
if d.error != nil || d.ring.eos && d.ring.availableFrames == 0 { continue }
|
||||
n = min(n, d.ring.availableFrames)
|
||||
}
|
||||
if n <= 0 { return 0 }
|
||||
let (gains, version) = shared.with { (_mix, _mixVersion) }
|
||||
if s.mixVersion != version {
|
||||
for i in s.names.indices { s.target[i] = gains[s.names[i]] ?? 1 }
|
||||
s.mixVersion = version
|
||||
}
|
||||
out.update(repeating: 0, count: n * 2)
|
||||
let stride = MeterHistory.maxStems
|
||||
let block = MeterHistory.block
|
||||
let blocks = (n + block - 1) / block
|
||||
if meterSums.count < blocks * stride { meterSums = [Float](repeating: 0, count: blocks * stride) }
|
||||
for k in meterSums.indices { meterSums[k] = 0 }
|
||||
for (i, d) in s.decoders.enumerated() {
|
||||
let got = d.ring.read(tmp, n)
|
||||
if got < n { (tmp + got * 2).update(repeating: 0, count: (n - got) * 2) }
|
||||
let g0 = s.current[i]
|
||||
let g1 = s.target[i]
|
||||
s.current[i] = g1
|
||||
if g0 == 0 && g1 == 0 { continue }
|
||||
let step = (g1 - g0) / Float(n)
|
||||
var g = g0
|
||||
for b in 0..<blocks {
|
||||
let end = min(n, (b + 1) * block)
|
||||
var sum: Float = 0
|
||||
for f in (b * block)..<end {
|
||||
let l = tmp[f * 2] * g
|
||||
let r = tmp[f * 2 + 1] * g
|
||||
out[f * 2] += l
|
||||
out[f * 2 + 1] += r
|
||||
sum += l * l + r * r
|
||||
g += step
|
||||
}
|
||||
if i < stride { meterSums[b * stride + i] = (sum / Float(2 * (end - b * block))).squareRoot() }
|
||||
}
|
||||
}
|
||||
meterSums.withUnsafeBufferPointer { p in
|
||||
for b in 0..<blocks { meters.add(s.token, s.framePos + Int64(b * block), p.baseAddress! + b * stride, s.names.count) }
|
||||
}
|
||||
s.framePos += Int64(n)
|
||||
return n
|
||||
}
|
||||
|
||||
private func rampGain(_ buf: UnsafeMutablePointer<Float>, _ n: Int, _ from: Float, _ to: Float) {
|
||||
if from == 1 && to == 1 { return }
|
||||
let step = (to - from) / Float(n)
|
||||
var g = from
|
||||
for f in 0..<n {
|
||||
buf[f * 2] *= g
|
||||
buf[f * 2 + 1] *= g
|
||||
g += step
|
||||
}
|
||||
}
|
||||
|
||||
private static func outputLatency() -> Double {
|
||||
#if os(iOS)
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
return s.outputLatency + s.ioBufferDuration
|
||||
#else
|
||||
return 0.01
|
||||
#endif
|
||||
}
|
||||
|
||||
private func ensureOutput(_ rate: Int) throws {
|
||||
if node != nil && trackRate == rate { return }
|
||||
av.stop()
|
||||
if let node { av.detach(node) }
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setPreferredSampleRate(Double(rate))
|
||||
#endif
|
||||
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
|
||||
t.setLatency(Self.outputLatency())
|
||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
||||
throw DecodeError("Unsupported sample rate \(rate)")
|
||||
}
|
||||
let n = AVAudioSourceNode(format: fmt) { silence, ts, frames, abl in t.render(silence, ts, frames, abl) }
|
||||
av.attach(n)
|
||||
av.connect(n, to: av.mainMixerNode, format: fmt)
|
||||
av.prepare()
|
||||
node = n
|
||||
tap = t
|
||||
trackRate = rate
|
||||
limiter.setSampleRate(rate)
|
||||
}
|
||||
|
||||
private func startOutput() throws {
|
||||
guard let tap else { return }
|
||||
if !av.isRunning {
|
||||
#if os(iOS)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
try av.start()
|
||||
tap.setLatency(Self.outputLatency())
|
||||
}
|
||||
tap.running = true
|
||||
}
|
||||
|
||||
private func pauseOutput() {
|
||||
tap?.running = false
|
||||
if av.isRunning { av.pause() }
|
||||
}
|
||||
|
||||
private func restartOutput() {
|
||||
tap?.setLatency(Self.outputLatency())
|
||||
// A paused engine also gets stopped by a route change; step() starts it again when it plays.
|
||||
if wantPlay, tap?.running == true, !av.isRunning { try? av.start() }
|
||||
}
|
||||
|
||||
/// (frames presented since the last flush, at monoNanos).
|
||||
private func presented() -> (Int64, Int64) {
|
||||
let now = monoNanos()
|
||||
guard let tap else { return (0, now) }
|
||||
return (tap.presented(now, written), now)
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
let (p, nanos) = presented()
|
||||
while segments.count > 1 && segments[1].writtenStart <= p {
|
||||
segments.removeFirst()
|
||||
let tok = segments[0].token
|
||||
DispatchQueue.main.async { [weak self] in self?.listener?.onTransition(tok) }
|
||||
}
|
||||
let s = session
|
||||
let rate = s.map { $0.decoders.isEmpty ? trackRate : $0.sampleRate } ?? trackRate
|
||||
if let seg = segments.first {
|
||||
let running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil
|
||||
snapshot = Snapshot(token: seg.token, frame: seg.frameStart + max(0, p - seg.writtenStart), rate: rate, nanos: nanos, running: running)
|
||||
} else {
|
||||
snapshot = Snapshot(token: s?.token ?? -1, frame: s?.framePos ?? 0, rate: rate, nanos: nanos, running: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func setFlags(_ playing: Bool, _ buffering: Bool) {
|
||||
let changed = shared.with { () -> Bool in
|
||||
if playing == _playing && buffering == _buffering { return false }
|
||||
_playing = playing
|
||||
_buffering = buffering
|
||||
return true
|
||||
}
|
||||
if changed { DispatchQueue.main.async { [weak self] in self?.listener?.onEngineState() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
|
||||
struct MixPreset: Identifiable, Equatable {
|
||||
let label: String
|
||||
let build: ([String]) -> [String: StemSetting]?
|
||||
var id: String { label }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
|
||||
}
|
||||
|
||||
enum MixPresets {
|
||||
static let full = MixPreset(label: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
|
||||
static let all: [MixPreset] = [
|
||||
full,
|
||||
karaoke,
|
||||
MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "Rhythm") { n in
|
||||
n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil
|
||||
},
|
||||
MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
]
|
||||
|
||||
/// Drops stems left at their defaults, so mixes compare by what they actually change.
|
||||
static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } }
|
||||
|
||||
/// The preset [mix] matches for these stems, or nil for a hand-made mix.
|
||||
static func current(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
|
||||
let m = normalized(mix)
|
||||
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
|
||||
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = all.filter { $0.build(names) != nil }
|
||||
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
|
||||
return usable[(i + 1) % usable.count]
|
||||
}
|
||||
|
||||
/// True when the vocals stem can't be heard (muted, or another stem soloed).
|
||||
static func vocalsSilent(_ names: [String], _ mix: [String: StemSetting]) -> Bool {
|
||||
if !names.contains("vocals") { return false }
|
||||
let v = mix["vocals"] ?? StemSetting()
|
||||
let anySolo = names.contains { mix[$0]?.solo == true }
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
/// Lock screen, Control Center, headphone and car controls: publishes the current track to
|
||||
/// MPNowPlayingInfoCenter and routes MPRemoteCommandCenter back into [PlaybackManager].
|
||||
/// Stands in for the Media3 session on Android.
|
||||
final class NowPlaying: @unchecked Sendable {
|
||||
private let pm: PlaybackManager
|
||||
private let plex: PlexClient
|
||||
private let center = MPNowPlayingInfoCenter.default()
|
||||
private var artKey: String?
|
||||
private var artwork: MPMediaItemArtwork?
|
||||
|
||||
init(pm: PlaybackManager, plex: PlexClient) {
|
||||
self.pm = pm
|
||||
self.plex = plex
|
||||
pm.addListener { [weak self] in self?.publish() }
|
||||
wireCommands()
|
||||
publish()
|
||||
}
|
||||
|
||||
// ---- shared with CarPlay ---------------------------------------------------------
|
||||
|
||||
/// The mix shown next to the artist: nil unless stems are playing or the mix is Full.
|
||||
var mixLabel: String? {
|
||||
guard pm.source == .Stems else { return nil }
|
||||
guard let p = MixPresets.current(pm.stemNames, pm.mix) else { return "Custom mix" }
|
||||
return p == MixPresets.full ? nil : p.label
|
||||
}
|
||||
|
||||
var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") }
|
||||
var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) }
|
||||
var nextMix: MixPreset { MixPresets.next(pm.stemNames, pm.mix) }
|
||||
|
||||
/// One tap: hear the vocals again, or take them out.
|
||||
func toggleVocals() {
|
||||
let names = pm.stemNames, mix = pm.mix
|
||||
let anySolo = names.contains { mix[$0]?.solo == true }
|
||||
if MixPresets.vocalsSilent(names, mix) {
|
||||
if mix["vocals"]?.muted == true && !anySolo { pm.toggleMute("vocals") } else { pm.applyMix([:]) }
|
||||
} else if anySolo {
|
||||
pm.applyMix(MixPresets.karaoke.build(names) ?? [:])
|
||||
} else {
|
||||
pm.toggleMute("vocals")
|
||||
}
|
||||
}
|
||||
|
||||
func cycleMix() { pm.applyMix(nextMix.build(pm.stemNames) ?? [:]) }
|
||||
|
||||
// ---- info --------------------------------------------------------------------------
|
||||
|
||||
private func publish() {
|
||||
guard let t = pm.current?.track else {
|
||||
center.nowPlayingInfo = nil
|
||||
center.playbackState = .stopped
|
||||
return
|
||||
}
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: t.title,
|
||||
MPMediaItemPropertyArtist: mixLabel.map { "\(t.artist) · \($0)" } ?? t.artist,
|
||||
MPMediaItemPropertyAlbumTitle: t.album,
|
||||
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? 1.0 : 0.0,
|
||||
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
|
||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: pm.queue.count,
|
||||
MPNowPlayingInfoPropertyExternalContentIdentifier: t.ratingKey,
|
||||
]
|
||||
if let n = t.index { info[MPMediaItemPropertyAlbumTrackNumber] = n }
|
||||
let key = t.thumb
|
||||
if key == artKey, let artwork { info[MPMediaItemPropertyArtwork] = artwork }
|
||||
center.nowPlayingInfo = info
|
||||
center.playbackState = pm.isPlaying ? .playing : .paused
|
||||
updateCommands()
|
||||
if key != artKey {
|
||||
artKey = key
|
||||
artwork = nil
|
||||
if let url = plex.artUrl(key, px: 600) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return }
|
||||
self.artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
|
||||
self.publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- commands ----------------------------------------------------------------------
|
||||
|
||||
private func wireCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
cmd.isEnabled = true
|
||||
cmd.addTarget { e in MainActor.assumeIsolated { f(e) } }
|
||||
}
|
||||
on(c.playCommand) { [pm] _ in pm.play(); return .success }
|
||||
on(c.pauseCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.stopCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.togglePlayPauseCommand) { [pm] _ in pm.togglePlay(); return .success }
|
||||
on(c.nextTrackCommand) { [pm] _ in pm.next(); return .success }
|
||||
on(c.previousTrackCommand) { [pm] _ in pm.previous(); return .success }
|
||||
on(c.changePlaybackPositionCommand) { [pm] e in
|
||||
guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
pm.seekTo(Int64(e.positionTime * 1000))
|
||||
return .success
|
||||
}
|
||||
on(c.changeShuffleModeCommand) { [pm] e in
|
||||
guard let e = e as? MPChangeShuffleModeCommandEvent else { return .commandFailed }
|
||||
pm.setShuffle(e.shuffleType != .off)
|
||||
return .success
|
||||
}
|
||||
on(c.changeRepeatModeCommand) { [pm] e in
|
||||
guard let e = e as? MPChangeRepeatModeCommandEvent else { return .commandFailed }
|
||||
pm.setRepeat(e.repeatType == .one ? .One : e.repeatType == .all ? .All : .Off)
|
||||
return .success
|
||||
}
|
||||
c.skipForwardCommand.isEnabled = false
|
||||
c.skipBackwardCommand.isEnabled = false
|
||||
c.seekForwardCommand.isEnabled = false
|
||||
c.seekBackwardCommand.isEnabled = false
|
||||
}
|
||||
|
||||
private func updateCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.changeShuffleModeCommand.currentShuffleType = pm.shuffle ? .items : .off
|
||||
c.changeRepeatModeCommand.currentRepeatType = pm.repeatMode == .One ? .one : pm.repeatMode == .All ? .all : .off
|
||||
c.nextTrackCommand.isEnabled = pm.index + 1 < pm.queue.count || pm.repeatMode != .Off
|
||||
c.previousTrackCommand.isEnabled = pm.current != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Display order and colours for stems, matching the desktop app.
|
||||
enum Stems {
|
||||
static let order = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
|
||||
private static let colors: [String: UInt32] = [
|
||||
"vocals": 0xFF7AB6, "drums": 0xFFB547, "bass": 0x7C8CFF, "other": 0x4CD07D,
|
||||
"guitar": 0xFF8A5C, "piano": 0xB48CFF, "instrumental": 0x3DD6C6, "mix": 0x8E8E93,
|
||||
]
|
||||
|
||||
static func color(_ name: String) -> UInt32 { colors[name] ?? 0x8E8E93 }
|
||||
static func label(_ name: String) -> String { name == "mix" ? "Original Mix" : name.prefix(1).uppercased() + name.dropFirst() }
|
||||
static func sort(_ names: [String]) -> [String] {
|
||||
names.enumerated().sorted { a, b in
|
||||
let ia = order.firstIndex(of: a.element) ?? 99, ib = order.firstIndex(of: b.element) ?? 99
|
||||
return ia != ib ? ia < ib : a.offset < b.offset
|
||||
}.map(\.element)
|
||||
}
|
||||
}
|
||||
|
||||
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
||||
struct StemSetting: Codable, Hashable {
|
||||
static let unity: Float = 0.8
|
||||
var level: Float = StemSetting.unity
|
||||
var muted = false
|
||||
var solo = false
|
||||
|
||||
init(level: Float = StemSetting.unity, muted: Bool = false, solo: Bool = false) {
|
||||
self.level = level; self.muted = muted; self.solo = solo
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
level = c.double("level").map(Float.init) ?? Self.unity
|
||||
muted = c.bool("muted") ?? false
|
||||
solo = c.bool("solo") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum RepeatMode: String, Codable, CaseIterable { case Off, All, One }
|
||||
enum SourceKind { case None, Stems, Original }
|
||||
|
||||
struct QueueItem: Codable, Hashable, Identifiable {
|
||||
var uid: Int64
|
||||
var track: Track
|
||||
var id: Int64 { uid }
|
||||
}
|
||||
|
||||
/// Waveform data for the seek bar: per-stem envelopes from stemserve, or Plex loudness in dB.
|
||||
struct Waveform {
|
||||
var stems: [String: [Float]]?
|
||||
var loudnessDb: [Float]?
|
||||
}
|
||||
|
||||
private struct SavedQueue: Codable {
|
||||
var items: [QueueItem] = []
|
||||
var unshuffled: [QueueItem]?
|
||||
var index = -1
|
||||
var positionMs: Int64 = 0
|
||||
var repeatMode: RepeatMode = .Off
|
||||
var mix: [String: StemSetting] = [:]
|
||||
|
||||
init(items: [QueueItem], unshuffled: [QueueItem]?, index: Int, positionMs: Int64, repeatMode: RepeatMode, mix: [String: StemSetting]) {
|
||||
self.items = items; self.unshuffled = unshuffled; self.index = index
|
||||
self.positionMs = positionMs; self.repeatMode = repeatMode; self.mix = mix
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey { case items, unshuffled, index, positionMs, repeatMode = "repeat", mix }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? c.decode([QueueItem].self, forKey: .items)) ?? []
|
||||
unshuffled = try? c.decode([QueueItem].self, forKey: .unshuffled)
|
||||
index = (try? c.decode(Int.self, forKey: .index)) ?? -1
|
||||
positionMs = (try? c.decode(Int64.self, forKey: .positionMs)) ?? 0
|
||||
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off
|
||||
mix = (try? c.decode([String: StemSetting].self, forKey: .mix)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// A toast for the UI; [id] changes even when the text repeats.
|
||||
struct AppMessage: Equatable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// Owns the queue and the [StemEngine]. Everything public is main-thread only; the UI reads the
|
||||
/// observable state, and NowPlaying mirrors it into the lock screen and CarPlay.
|
||||
@Observable
|
||||
final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
static let ringFrames = StemEngine.ringSeconds * 48_000
|
||||
static let waveBuckets = 256
|
||||
|
||||
static func stemKey(_ entry: StemEntry, _ file: String, _ size: Int64, _ q: StemQuality) -> String {
|
||||
"stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)"
|
||||
}
|
||||
static func plexKey(_ track: Track) -> String {
|
||||
"plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
}
|
||||
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let cache: MediaCache
|
||||
@ObservationIgnored private let eq: EqController
|
||||
@ObservationIgnored private(set) var engine: StemEngine!
|
||||
@ObservationIgnored private let queueFile: URL
|
||||
|
||||
private(set) var queue: [QueueItem] = []
|
||||
private(set) var index = -1
|
||||
private(set) var current: QueueItem?
|
||||
private(set) var playWhenReady = false
|
||||
private(set) var buffering = false
|
||||
private(set) var shuffle = false
|
||||
private(set) var repeatMode = RepeatMode.Off
|
||||
private(set) var source = SourceKind.None
|
||||
private(set) var stemNames: [String] = []
|
||||
private(set) var mix: [String: StemSetting] = [:]
|
||||
private(set) var master: Float
|
||||
private(set) var waveform: Waveform?
|
||||
private(set) var durationMs: Int64 = 0
|
||||
private(set) var message: AppMessage?
|
||||
|
||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
||||
@ObservationIgnored private var unshuffled: [QueueItem]?
|
||||
@ObservationIgnored private var nextUid: Int64 = 1
|
||||
@ObservationIgnored private let tokenLock = NSLock()
|
||||
@ObservationIgnored private var tokens: Int64 = 0
|
||||
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var nextTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var currentToken: Int64 = -1
|
||||
@ObservationIgnored private var nextToken: Int64 = -1
|
||||
@ObservationIgnored private var nextIndex = -1
|
||||
@ObservationIgnored private var nextEntry: StemEntry?
|
||||
@ObservationIgnored private var currentEntry: StemEntry?
|
||||
@ObservationIgnored private var pendingPositionMs: Int64 = 0
|
||||
@ObservationIgnored private var failures = 0
|
||||
@ObservationIgnored private var scrobbled: Int64 = -1
|
||||
@ObservationIgnored private var resumeAfterInterruption = false
|
||||
@ObservationIgnored private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(settings: Settings, plex: PlexClient, stems: StemServer, cache: MediaCache, eq: EqController, dir: URL) {
|
||||
self.settings = settings
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.cache = cache
|
||||
self.eq = eq
|
||||
queueFile = dir.appendingPathComponent("queue.json")
|
||||
master = settings.masterVolume
|
||||
engine = StemEngine(listener: self)
|
||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||
engine.eqParams = eq.currentParams()
|
||||
engine.masterGain = masterTaper(master)
|
||||
restore()
|
||||
observeSession()
|
||||
Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard let self else { return }
|
||||
if self.playWhenReady { self.reportTimeline("playing") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
||||
func report(_ text: String) {
|
||||
message = AppMessage(text: text)
|
||||
messageListeners.values.forEach { $0(text) }
|
||||
}
|
||||
|
||||
@discardableResult func addListener(_ f: @escaping () -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
listeners[id] = f
|
||||
return id
|
||||
}
|
||||
func removeListener(_ id: UUID) { listeners[id] = nil; messageListeners[id] = nil }
|
||||
@discardableResult func addMessageListener(_ f: @escaping (String) -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
messageListeners[id] = f
|
||||
return id
|
||||
}
|
||||
private func changed() { listeners.values.forEach { $0() } }
|
||||
|
||||
func positionMs() -> Int64 {
|
||||
let snap = engine.snapshot
|
||||
let pos = currentToken >= 0 && snap.token == currentToken ? snap.positionMs() : pendingPositionMs
|
||||
let d = durationMs
|
||||
return d > 0 ? min(max(pos, 0), d) : max(pos, 0)
|
||||
}
|
||||
|
||||
var isPlaying: Bool { playWhenReady }
|
||||
|
||||
// ---- queue --------------------------------------------------------------------------
|
||||
|
||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
||||
tracks.map { t in
|
||||
defer { nextUid += 1 }
|
||||
return QueueItem(uid: nextUid, track: t)
|
||||
}
|
||||
}
|
||||
|
||||
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
|
||||
shuffle = true
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
queue = items
|
||||
load(min(max(start, 0), items.count - 1), 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
||||
|
||||
private func insert(_ at: Int, _ tracks: [Track]) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
var q = queue
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
unshuffled = unshuffled.map { $0 + items }
|
||||
if index < 0 {
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
func removeAt(_ i: Int) {
|
||||
var q = queue
|
||||
guard q.indices.contains(i) else { return }
|
||||
let removed = q.remove(at: i)
|
||||
unshuffled = unshuffled?.filter { $0.uid != removed.uid }
|
||||
queue = q
|
||||
let idx = index
|
||||
if q.isEmpty {
|
||||
stop()
|
||||
} else if i == idx {
|
||||
load(min(i, q.count - 1), 0, playWhenReady)
|
||||
} else if i < idx {
|
||||
index = idx - 1
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func move(_ from: Int, _ to: Int) {
|
||||
var q = queue
|
||||
guard q.indices.contains(from), q.indices.contains(to), from != to else { return }
|
||||
q.insert(q.remove(at: from), at: to)
|
||||
queue = q
|
||||
let idx = index
|
||||
if from == idx { index = to }
|
||||
else if from < idx && to >= idx { index = idx - 1 }
|
||||
else if from > idx && to <= idx { index = idx + 1 }
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func clearUpNext() {
|
||||
guard index >= 0 else { return }
|
||||
queue = Array(queue.prefix(index + 1))
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleShuffle() {
|
||||
let q = queue
|
||||
let idx = index
|
||||
let cur = q.indices.contains(idx) ? q[idx] : nil
|
||||
if !shuffle {
|
||||
unshuffled = q
|
||||
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
|
||||
shuffle = true
|
||||
} else {
|
||||
let orig = unshuffled ?? q
|
||||
queue = orig
|
||||
index = max(orig.firstIndex { $0.uid == cur?.uid } ?? 0, 0)
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func cycleRepeat() {
|
||||
let all = RepeatMode.allCases
|
||||
repeatMode = all[(all.firstIndex(of: repeatMode)! + 1) % all.count]
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func setRepeat(_ mode: RepeatMode) {
|
||||
repeatMode = mode
|
||||
requeueNext()
|
||||
changed()
|
||||
}
|
||||
|
||||
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
|
||||
|
||||
// ---- transport ----------------------------------------------------------------------
|
||||
|
||||
func play() {
|
||||
if queue.isEmpty { return }
|
||||
if !activateSession() { return }
|
||||
playWhenReady = true
|
||||
if currentToken < 0 && loadTask == nil {
|
||||
load(max(index, 0), pendingPositionMs, true)
|
||||
return
|
||||
}
|
||||
engine.setPlaying(true)
|
||||
reportTimeline("playing")
|
||||
changed()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playWhenReady = false
|
||||
engine.setPlaying(false)
|
||||
pendingPositionMs = positionMs()
|
||||
reportTimeline("paused")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func togglePlay() { if playWhenReady { pause() } else { play() } }
|
||||
|
||||
func next() {
|
||||
if queue.isEmpty { return }
|
||||
let i = index + 1
|
||||
if i < queue.count { load(i, 0, playWhenReady) }
|
||||
else if repeatMode != .Off { load(0, 0, playWhenReady) }
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
func previous() {
|
||||
if positionMs() > 3000 || index <= 0 {
|
||||
seekTo(0)
|
||||
return
|
||||
}
|
||||
load(index - 1, 0, playWhenReady)
|
||||
}
|
||||
|
||||
func skipTo(_ i: Int, positionMs: Int64 = 0) {
|
||||
if queue.indices.contains(i) { load(i, positionMs, true) }
|
||||
}
|
||||
|
||||
func seekTo(_ ms: Int64) {
|
||||
pendingPositionMs = max(ms, 0)
|
||||
if currentToken >= 0 { engine.seekTo(pendingPositionMs) }
|
||||
changed()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
nextTask?.cancel()
|
||||
engine.stop()
|
||||
reportTimeline("stopped")
|
||||
currentToken = -1
|
||||
nextToken = -1
|
||||
queue = []
|
||||
index = -1
|
||||
current = nil
|
||||
playWhenReady = false
|
||||
source = .None
|
||||
stemNames = []
|
||||
waveform = nil
|
||||
deactivateSession()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
// ---- mixer --------------------------------------------------------------------------
|
||||
|
||||
func setStemLevel(_ name: String, _ level: Float) { editMix(name) { $0.level = min(max(level, 0), 1) } }
|
||||
func toggleMute(_ name: String) { editMix(name) { $0.muted.toggle() } }
|
||||
func toggleSolo(_ name: String) { editMix(name) { $0.solo.toggle() } }
|
||||
|
||||
func resetMix() {
|
||||
mix = [:]
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/// Karaoke and friends: set every stem at once.
|
||||
func applyMix(_ m: [String: StemSetting]) {
|
||||
mix = m
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
||||
var s = mix[name] ?? StemSetting()
|
||||
f(&s)
|
||||
mix[name] = s
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private func pushMix() {
|
||||
let m = mix
|
||||
let anySolo = stemNames.contains { m[$0]?.solo == true }
|
||||
var gains: [String: Float] = [:]
|
||||
for n in stemNames {
|
||||
let s = m[n] ?? StemSetting()
|
||||
gains[n] = anySolo && !s.solo ? 0 : s.muted ? 0 : stemTaper(s.level)
|
||||
}
|
||||
engine.setMix(gains)
|
||||
// Now Playing shows the mix (artist line, Vocals/Mix buttons), so it needs to hear about it.
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
/// True when [track] would play without the home network: its stems (or, lacking stems, its file) are fully cached.
|
||||
func playableOffline(_ track: Track) -> Bool {
|
||||
if let entry = stems.find(track) {
|
||||
return StemQuality.allCases.contains { q in entry.stems.allSatisfy { cache.isComplete(Self.stemKey(entry, $0.file, $0.size, q)) } }
|
||||
}
|
||||
return track.partKey != nil && cache.isComplete(Self.plexKey(track))
|
||||
}
|
||||
|
||||
func hasStems(_ track: Track) -> Bool { stems.find(track) != nil }
|
||||
|
||||
func setMaster(_ v: Float) {
|
||||
master = min(max(v, 0), 1)
|
||||
engine.masterGain = masterTaper(master)
|
||||
settings.masterVolume = master
|
||||
}
|
||||
|
||||
// ---- loading ------------------------------------------------------------------------
|
||||
|
||||
private func nextTokenValue() -> Int64 { tokenLock.withLock { tokens += 1; return tokens } }
|
||||
|
||||
private func load(_ i: Int, _ positionMs: Int64, _ play: Bool) {
|
||||
guard queue.indices.contains(i) else { return }
|
||||
let item = queue[i]
|
||||
loadTask?.cancel()
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
index = i
|
||||
current = item
|
||||
pendingPositionMs = positionMs
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
currentToken = -1
|
||||
if play && !activateSession() { return }
|
||||
playWhenReady = play
|
||||
buffering = true
|
||||
if !settings.keepStemMix { mix = [:] }
|
||||
changed()
|
||||
save()
|
||||
loadTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let (s, entry) = try await self.openDetached(item.track, positionMs)
|
||||
if Task.isCancelled {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.loadTask = nil
|
||||
self.currentToken = s.token
|
||||
self.currentEntry = entry
|
||||
self.applySession(s, entry)
|
||||
self.engine.load(s, play: self.playWhenReady)
|
||||
self.failures = 0
|
||||
self.scrobbled = -1
|
||||
self.reportTimeline(self.playWhenReady ? "playing" : "paused")
|
||||
self.loadWaveform(item.track, entry)
|
||||
self.prepareNext()
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
self.loadTask = nil
|
||||
self.buffering = false
|
||||
self.report("Couldn't play “\(item.track.title)”: \(error.localizedDescription)")
|
||||
self.failures += 1
|
||||
if self.failures < 3 && i + 1 < self.queue.count {
|
||||
self.load(i + 1, 0, play)
|
||||
} else {
|
||||
self.failures = 0
|
||||
self.playWhenReady = false
|
||||
}
|
||||
self.changed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySession(_ s: TrackSession, _ entry: StemEntry?) {
|
||||
source = entry != nil ? .Stems : .Original
|
||||
stemNames = s.names
|
||||
let frames = s.durationFrames
|
||||
if frames > 0 && s.sampleRate > 0 { durationMs = frames * 1000 / Int64(s.sampleRate) }
|
||||
pushMix()
|
||||
changed()
|
||||
}
|
||||
|
||||
private func openDetached(_ track: Track, _ startMs: Int64) async throws -> (TrackSession, StemEntry?) {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
cont.resume(with: Result { try open(track, startMs) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens every stem (or the original file) and waits for the decoders to know their format.
|
||||
private func open(_ track: Track, _ startMs: Int64) throws -> (TrackSession, StemEntry?) {
|
||||
let entry = stems.find(track)
|
||||
var sources: [(name: String, key: String, url: URL?, hint: String)] = []
|
||||
if let entry {
|
||||
let q = qualityFor(entry)
|
||||
stems.prepare(entry, q)
|
||||
for name in Stems.sort(entry.stems.map(\.name)) {
|
||||
guard let f = entry.stems.first(where: { $0.name == name }) else { continue }
|
||||
let hint = q.fmt == "opus" ? "opus" : (f.file as NSString).pathExtension
|
||||
sources.append((name, Self.stemKey(entry, f.file, f.size, q), stems.fileUrl(entry, f, q), hint))
|
||||
}
|
||||
} else {
|
||||
guard let part = track.partKey else { throw PlainError("Plex has no file for this track") }
|
||||
let hint = track.partFile.map { ($0 as NSString).pathExtension } ?? track.codec ?? ""
|
||||
sources.append(("mix", Self.plexKey(track), plex.partUrl(part), hint))
|
||||
}
|
||||
if sources.isEmpty { throw PlainError("No stems listed for this track") }
|
||||
let decoders = sources.map { src -> StemDecoder in
|
||||
let res = cache.acquire(src.key) { src.url }
|
||||
return StemDecoder(name: src.name, source: ResourceDataSource(res) { [cache] in cache.release(res) },
|
||||
ringFrames: Self.ringFrames, hint: src.hint)
|
||||
}
|
||||
do {
|
||||
decoders.forEach { $0.start(gen: 0, frame: 0) }
|
||||
for d in decoders where !d.awaitPrepared(timeoutMs: 45_000) {
|
||||
throw PlainError(d.error?.localizedDescription ?? "Timed out opening \(d.name)")
|
||||
}
|
||||
let rate = decoders[0].sampleRate
|
||||
if decoders.contains(where: { $0.sampleRate != rate }) { throw PlainError("Stems have mixed sample rates") }
|
||||
let s = TrackSession(token: nextTokenValue(), names: decoders.map(\.name), decoders: decoders)
|
||||
if startMs > 0 { s.startAt(startMs * Int64(rate) / 1000) }
|
||||
return (s, entry)
|
||||
} catch {
|
||||
decoders.forEach { $0.release() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a downloaded copy if there is one, whatever quality it was saved at.
|
||||
private func qualityFor(_ entry: StemEntry) -> StemQuality {
|
||||
let want = stems.effectiveQuality(settings.stemQuality)
|
||||
guard let f = entry.stems.first else { return want }
|
||||
if cache.isComplete(Self.stemKey(entry, f.file, f.size, want)) { return want }
|
||||
return StemQuality.allCases.first { cache.isComplete(Self.stemKey(entry, f.file, f.size, $0)) } ?? want
|
||||
}
|
||||
|
||||
/// Builds the next track's session now so the switch can be gapless.
|
||||
private func prepareNext() {
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
guard let i = upcomingIndex() else { return }
|
||||
let item = queue[i]
|
||||
nextTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000) // let the current track's stems get a head start
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
// The normal load path reports failures.
|
||||
guard let (s, entry) = try? await self.openDetached(item.track, 0) else { return }
|
||||
if Task.isCancelled {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.nextToken = s.token
|
||||
self.nextIndex = i
|
||||
self.nextEntry = entry
|
||||
self.engine.queueNext(s)
|
||||
}
|
||||
}
|
||||
|
||||
private func requeueNext() {
|
||||
if currentToken >= 0 { prepareNext() }
|
||||
}
|
||||
|
||||
private func upcomingIndex() -> Int? {
|
||||
let q = queue
|
||||
let i = index
|
||||
if q.isEmpty || i < 0 { return nil }
|
||||
if repeatMode == .One { return i }
|
||||
if i + 1 < q.count { return i + 1 }
|
||||
if repeatMode == .All { return 0 }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func loadWaveform(_ track: Track, _ entry: StemEntry?) {
|
||||
let uid = current?.uid
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var w: Waveform?
|
||||
if let entry {
|
||||
w = await self.stems.peaks(entry, buckets: Self.waveBuckets).map { Waveform(stems: $0, loudnessDb: nil) }
|
||||
} else if let l = try? await self.plex.loudness(track.ratingKey, samples: Self.waveBuckets) {
|
||||
w = Waveform(stems: nil, loudnessDb: l)
|
||||
}
|
||||
if self.current?.uid == uid { self.waveform = w }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- engine callbacks (main thread) ---------------------------------------------------
|
||||
|
||||
func onEngineState() {
|
||||
buffering = engine.buffering || loadTask != nil
|
||||
changed()
|
||||
}
|
||||
|
||||
func onTransition(_ token: Int64) {
|
||||
if token == currentToken || token != nextToken { return }
|
||||
scrobble()
|
||||
currentToken = token
|
||||
nextToken = -1
|
||||
index = nextIndex
|
||||
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
|
||||
currentEntry = nextEntry
|
||||
pendingPositionMs = 0
|
||||
scrobbled = -1
|
||||
guard let item = current else { return }
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
if !settings.keepStemMix { mix = [:] }
|
||||
source = currentEntry != nil ? .Stems : .Original
|
||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||
pushMix()
|
||||
loadWaveform(item.track, currentEntry)
|
||||
reportTimeline("playing")
|
||||
prepareNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func onEnded(_ token: Int64) {
|
||||
if token != currentToken { return }
|
||||
scrobble()
|
||||
currentToken = -1
|
||||
// Normally the next song was queued for a gapless switch; if preparing it failed, try again now.
|
||||
if let i = upcomingIndex() {
|
||||
load(i, 0, true)
|
||||
return
|
||||
}
|
||||
playWhenReady = false
|
||||
pendingPositionMs = 0
|
||||
reportTimeline("stopped")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func onError(_ token: Int64, _ msg: String) {
|
||||
if token != currentToken { return }
|
||||
report("Playback stopped: \(msg)")
|
||||
currentToken = -1
|
||||
let i = index
|
||||
failures += 1
|
||||
if i + 1 < queue.count && failures < 3 {
|
||||
load(i + 1, 0, playWhenReady)
|
||||
} else {
|
||||
playWhenReady = false
|
||||
changed()
|
||||
}
|
||||
}
|
||||
|
||||
private func scrobble() {
|
||||
guard let item = current, scrobbled != item.uid else { return }
|
||||
let d = durationMs
|
||||
if d > 0 && Double(positionMs()) < Double(d) * 0.5 && engine.snapshot.token == currentToken { return }
|
||||
scrobbled = item.uid
|
||||
Task { [plex] in await plex.scrobble(item.track.ratingKey) }
|
||||
}
|
||||
|
||||
private func reportTimeline(_ state: String) {
|
||||
guard let t = current?.track else { return }
|
||||
let pos = positionMs()
|
||||
if !plex.signedIn { return }
|
||||
Task { [plex] in await plex.timeline(t, state: state, positionMs: pos) }
|
||||
if state == "playing" && durationMs > 0 && Double(pos) > Double(durationMs) * 0.9 { scrobble() }
|
||||
}
|
||||
|
||||
// ---- audio session --------------------------------------------------------------------
|
||||
|
||||
private func activateSession() -> Bool {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try s.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
try s.setActive(true)
|
||||
return true
|
||||
} catch {
|
||||
report("Couldn't start audio: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func observeSession() {
|
||||
let nc = NotificationCenter.default
|
||||
nc.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] n in
|
||||
guard let self, let info = n.userInfo,
|
||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
||||
if (info[AVAudioSessionInterruptionReasonKey] as? UInt) == 1 /* appWasSuspended, deprecated but still sent */ { return }
|
||||
self.resumeAfterInterruption = self.playWhenReady
|
||||
if self.playWhenReady { self.pause() }
|
||||
case .ended:
|
||||
let opts = AVAudioSession.InterruptionOptions(rawValue: info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0)
|
||||
if self.resumeAfterInterruption && opts.contains(.shouldResume) { self.play() }
|
||||
self.resumeAfterInterruption = false
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
||||
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
||||
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
||||
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
||||
self.eq.refreshRoute()
|
||||
}
|
||||
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let was = self.playWhenReady
|
||||
let pos = self.positionMs()
|
||||
self.currentToken = -1
|
||||
self.engine.release()
|
||||
self.engine = StemEngine(listener: self)
|
||||
self.engine.eqParams = self.eq.currentParams()
|
||||
self.engine.masterGain = masterTaper(self.master)
|
||||
self.pendingPositionMs = pos
|
||||
if self.index >= 0 { self.load(self.index, pos, was) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persistence ------------------------------------------------------------------------
|
||||
|
||||
private func save() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let snap = SavedQueue(items: self.queue, unshuffled: self.unshuffled, index: self.index,
|
||||
positionMs: self.positionMs(), repeatMode: self.repeatMode, mix: self.mix)
|
||||
let file = self.queueFile
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
guard let data = try? JSONEncoder().encode(snap) else { return }
|
||||
try? data.write(to: file, options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves right away, for when the app is about to be suspended.
|
||||
func flushSave() {
|
||||
saveTask?.cancel()
|
||||
let snap = SavedQueue(items: queue, unshuffled: unshuffled, index: index, positionMs: positionMs(), repeatMode: repeatMode, mix: mix)
|
||||
if let data = try? JSONEncoder().encode(snap) { try? data.write(to: queueFile, options: .atomic) }
|
||||
}
|
||||
|
||||
private func restore() {
|
||||
guard let data = try? Data(contentsOf: queueFile), let saved = try? JSONDecoder().decode(SavedQueue.self, from: data) else { return }
|
||||
queue = saved.items
|
||||
unshuffled = saved.unshuffled
|
||||
shuffle = saved.unshuffled != nil
|
||||
repeatMode = saved.repeatMode
|
||||
mix = saved.mix
|
||||
nextUid = (saved.items.map(\.uid).max() ?? 0) + 1
|
||||
let i = min(max(saved.index, -1), saved.items.count - 1)
|
||||
index = i
|
||||
current = saved.items.indices.contains(i) ? saved.items[i] : nil
|
||||
durationMs = current?.track.durationMs ?? 0
|
||||
pendingPositionMs = saved.positionMs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct NotSignedIn: LocalizedError {
|
||||
var errorDescription: String? { "Not signed in to Plex" }
|
||||
}
|
||||
|
||||
struct PlainError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Plex Media Server and plex.tv calls. Library reads go through [http], whose cache
|
||||
/// answers from disk when the server can't be reached (see AppGraph).
|
||||
final class PlexClient: @unchecked Sendable {
|
||||
static let product = "Stemify"
|
||||
private static let page = 1000
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
private let model: String
|
||||
private let osVersion: String
|
||||
|
||||
init(http: Http, settings: Settings) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
var sys = utsname()
|
||||
uname(&sys)
|
||||
let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) }
|
||||
model = machine.isEmpty ? "iPhone" : machine
|
||||
osVersion = ProcessInfo.processInfo.operatingSystemVersionString
|
||||
.replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? ""
|
||||
}
|
||||
|
||||
var signedIn: Bool { !settings.plexToken.isEmpty }
|
||||
private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private var token: String { settings.plexToken }
|
||||
private var section: String { settings.musicSection }
|
||||
|
||||
private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product")
|
||||
r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version")
|
||||
r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier")
|
||||
r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform")
|
||||
r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version")
|
||||
r.setValue(model, forHTTPHeaderField: "X-Plex-Device")
|
||||
r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name")
|
||||
if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) throws -> MediaContainer {
|
||||
try JSONDecoder().decode(PlexResponse.self, from: data).container
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params))
|
||||
if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") }
|
||||
if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") }
|
||||
let body = try await http.data(req)
|
||||
if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() }
|
||||
return try decode(body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer {
|
||||
guard signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params), method: method)
|
||||
if method != "DELETE" { req.httpBody = Data() }
|
||||
let body = try await http.data(req)
|
||||
let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.hasPrefix("{") else { return MediaContainer() }
|
||||
return (try? decode(body)) ?? MediaContainer()
|
||||
}
|
||||
|
||||
/// Pages through a section listing; Plex caps a single response on big libraries.
|
||||
private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] {
|
||||
var out: [PlexItem] = []
|
||||
var start = 0
|
||||
while true {
|
||||
let page = try await get(path, params, start: start, size: Self.page)
|
||||
out += page.metadata
|
||||
start += page.metadata.count
|
||||
let total = page.totalSize ?? page.size
|
||||
if page.metadata.isEmpty || start >= total { break }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- server -------------------------------------------------------------------
|
||||
|
||||
func machineId() async throws -> String {
|
||||
let body = try await http.data(request(try url("/identity"), withToken: false))
|
||||
guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") }
|
||||
return id
|
||||
}
|
||||
|
||||
func musicSections() async throws -> [MusicSection] {
|
||||
try await get("/library/sections").directories
|
||||
.filter { $0.type == "artist" }
|
||||
.map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) }
|
||||
}
|
||||
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists() async throws -> [Artist] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
try await get("/library/sections/\(section)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
/// One page of every track, sorted by title. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
|
||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
|
||||
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
|
||||
// albums (an artist with only "The Best of Kansas" came back empty).
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 9), ("artist.id", key)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func track(_ key: String) async throws -> PlexItem? {
|
||||
try await get("/library/metadata/\(key)").metadata.first
|
||||
}
|
||||
|
||||
/// Title search inside the music section; used to give stem-only entries Plex metadata.
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
try await get("/library/sections/\(section)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
/// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB.
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? {
|
||||
guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id
|
||||
else { return nil }
|
||||
let levels: [PlexLevel]
|
||||
do {
|
||||
levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels
|
||||
} catch is HttpError {
|
||||
return nil // not analysed yet
|
||||
}
|
||||
return levels.isEmpty ? nil : levels.map(\.v)
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", section)]).hubs
|
||||
func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] }
|
||||
return SearchResults(
|
||||
artists: hub("artist").map { $0.toArtist() },
|
||||
albums: hub("album").map { $0.toAlbum() },
|
||||
tracks: hub("track").map { $0.toTrack() },
|
||||
playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() }
|
||||
)
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------
|
||||
|
||||
func playlists() async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
try await all("/playlists/\(key)/items").map { $0.toTrack() }
|
||||
}
|
||||
|
||||
private func libraryUri(_ keys: [String]) async throws -> String {
|
||||
var machine = settings.plexMachineId
|
||||
if machine.isEmpty {
|
||||
machine = try await machineId()
|
||||
settings.plexMachineId = machine
|
||||
}
|
||||
return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))"
|
||||
}
|
||||
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)])
|
||||
.metadata.first?.toPlaylist()
|
||||
}
|
||||
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
try await send("PUT", "/playlists/\(key)/items", [("uri", uri)])
|
||||
}
|
||||
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)")
|
||||
}
|
||||
|
||||
/// Moves an item to just after [afterItemId], or to the top when it's nil.
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws {
|
||||
try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)])
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("PUT", "/playlists/\(key)", [("title", title)])
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)")
|
||||
}
|
||||
|
||||
// ---- playback reporting ---------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
_ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")])
|
||||
}
|
||||
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async {
|
||||
_ = try? await get("/:/timeline", [
|
||||
("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"),
|
||||
("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1),
|
||||
])
|
||||
}
|
||||
|
||||
// ---- URLs -----------------------------------------------------------------------
|
||||
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL? {
|
||||
guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil }
|
||||
return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)])
|
||||
}
|
||||
|
||||
func partUrl(_ partKey: String) -> URL? {
|
||||
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
|
||||
}
|
||||
|
||||
// ---- plex.tv sign-in ------------------------------------------------------------
|
||||
|
||||
func createPin() async throws -> PlexPin {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST")
|
||||
req.httpBody = Data()
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
func checkPin(_ id: Int64) async throws -> PlexPin {
|
||||
let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false)
|
||||
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
func user(token: String) async throws -> PlexUser {
|
||||
var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req))
|
||||
}
|
||||
|
||||
/// True if [url] answers as a Plex server with the given token.
|
||||
func probe(_ url: String, token: String) async -> Bool {
|
||||
let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let u = URL(string: trimmed + "/library/sections") else { return false }
|
||||
var req = request(u, withToken: false)
|
||||
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
|
||||
return await http.ok(req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import Foundation
|
||||
|
||||
// ---- lenient JSON ----------------------------------------------------------------
|
||||
|
||||
/// Plex is loose with types (numbers as strings, "0"/"1" booleans); never fail a whole page over it.
|
||||
struct AnyKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init(_ s: String) { stringValue = s }
|
||||
init?(stringValue: String) { self.stringValue = stringValue }
|
||||
init?(intValue: Int) { nil }
|
||||
}
|
||||
|
||||
extension KeyedDecodingContainer where Key == AnyKey {
|
||||
func str(_ k: String) -> String? {
|
||||
let key = AnyKey(k)
|
||||
if let s = try? decodeIfPresent(String.self, forKey: key) { return s }
|
||||
if let i = try? decodeIfPresent(Int64.self, forKey: key) { return String(i) }
|
||||
if let d = try? decodeIfPresent(Double.self, forKey: key) { return String(d) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func int64(_ k: String) -> Int64? {
|
||||
let key = AnyKey(k)
|
||||
if let i = try? decodeIfPresent(Int64.self, forKey: key) { return i }
|
||||
if let d = try? decodeIfPresent(Double.self, forKey: key) { return Int64(d) }
|
||||
if let s = try? decodeIfPresent(String.self, forKey: key) { return Int64(s) ?? Double(s).map { Int64($0) } }
|
||||
return nil
|
||||
}
|
||||
|
||||
func int(_ k: String) -> Int? { int64(k).map { Int(clamping: $0) } }
|
||||
|
||||
func double(_ k: String) -> Double? {
|
||||
let key = AnyKey(k)
|
||||
if let d = try? decodeIfPresent(Double.self, forKey: key) { return d }
|
||||
if let s = try? decodeIfPresent(String.self, forKey: key) { return Double(s) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func bool(_ k: String) -> Bool? {
|
||||
let key = AnyKey(k)
|
||||
if let b = try? decodeIfPresent(Bool.self, forKey: key) { return b }
|
||||
if let i = try? decodeIfPresent(Int.self, forKey: key) { return i != 0 }
|
||||
if let s = try? decodeIfPresent(String.self, forKey: key) { return s == "1" || s == "true" }
|
||||
return nil
|
||||
}
|
||||
|
||||
func list<T: Decodable>(_ k: String) -> [T] {
|
||||
(try? decodeIfPresent([Lenient<T>].self, forKey: AnyKey(k)))?.compactMap(\.value) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops a single bad array element instead of the array.
|
||||
struct Lenient<T: Decodable>: Decodable {
|
||||
let value: T?
|
||||
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
|
||||
}
|
||||
|
||||
// ---- wire format (Accept: application/json) --------------------------------------
|
||||
|
||||
struct PlexResponse: Decodable {
|
||||
let container: MediaContainer
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
container = (try? c.decodeIfPresent(MediaContainer.self, forKey: AnyKey("MediaContainer"))) ?? MediaContainer()
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaContainer: Decodable {
|
||||
var size = 0
|
||||
var totalSize: Int?
|
||||
var machineIdentifier: String?
|
||||
var metadata: [PlexItem] = []
|
||||
var directories: [PlexDirectory] = []
|
||||
var hubs: [PlexHub] = []
|
||||
var levels: [PlexLevel] = []
|
||||
|
||||
init() {}
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
size = c.int("size") ?? 0
|
||||
totalSize = c.int("totalSize")
|
||||
machineIdentifier = c.str("machineIdentifier")
|
||||
metadata = c.list("Metadata")
|
||||
directories = c.list("Directory")
|
||||
hubs = c.list("Hub")
|
||||
levels = c.list("Level")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexItem: Decodable {
|
||||
var ratingKey = ""
|
||||
var key = ""
|
||||
var type = ""
|
||||
var title = ""
|
||||
var titleSort: String?
|
||||
var originalTitle: String?
|
||||
var parentTitle: String?
|
||||
var grandparentTitle: String?
|
||||
var parentRatingKey: String?
|
||||
var grandparentRatingKey: String?
|
||||
var index: Int?
|
||||
var parentIndex: Int?
|
||||
var duration: Int64?
|
||||
var thumb: String?
|
||||
var parentThumb: String?
|
||||
var grandparentThumb: String?
|
||||
var composite: String?
|
||||
var year: Int?
|
||||
var parentYear: Int?
|
||||
/// Release date, "1975-11-14", when Plex knows the day.
|
||||
var originallyAvailableAt: String?
|
||||
var addedAt: Int64?
|
||||
var leafCount: Int?
|
||||
var playlistItemID: Int64?
|
||||
var playlistType: String?
|
||||
/// Boolean in current servers, "0"/"1" in some older ones.
|
||||
var smart: Bool?
|
||||
var media: [PlexMedia] = []
|
||||
var genres: [PlexTag] = []
|
||||
|
||||
init() {}
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
ratingKey = c.str("ratingKey") ?? ""
|
||||
key = c.str("key") ?? ""
|
||||
type = c.str("type") ?? ""
|
||||
title = c.str("title") ?? ""
|
||||
titleSort = c.str("titleSort")
|
||||
originalTitle = c.str("originalTitle")
|
||||
parentTitle = c.str("parentTitle")
|
||||
grandparentTitle = c.str("grandparentTitle")
|
||||
parentRatingKey = c.str("parentRatingKey")
|
||||
grandparentRatingKey = c.str("grandparentRatingKey")
|
||||
index = c.int("index")
|
||||
parentIndex = c.int("parentIndex")
|
||||
duration = c.int64("duration")
|
||||
thumb = c.str("thumb")
|
||||
parentThumb = c.str("parentThumb")
|
||||
grandparentThumb = c.str("grandparentThumb")
|
||||
composite = c.str("composite")
|
||||
year = c.int("year")
|
||||
parentYear = c.int("parentYear")
|
||||
originallyAvailableAt = c.str("originallyAvailableAt")
|
||||
addedAt = c.int64("addedAt")
|
||||
leafCount = c.int("leafCount")
|
||||
playlistItemID = c.int64("playlistItemID")
|
||||
playlistType = c.str("playlistType")
|
||||
smart = c.bool("smart")
|
||||
media = c.list("Media")
|
||||
genres = c.list("Genre")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexMedia: Decodable {
|
||||
var id: Int64 = 0
|
||||
var duration: Int64?
|
||||
var bitrate: Int?
|
||||
var audioCodec: String?
|
||||
var container: String?
|
||||
var parts: [PlexPart] = []
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.int64("id") ?? 0
|
||||
duration = c.int64("duration")
|
||||
bitrate = c.int("bitrate")
|
||||
audioCodec = c.str("audioCodec")
|
||||
container = c.str("container")
|
||||
parts = c.list("Part")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexPart: Decodable {
|
||||
var id: Int64 = 0
|
||||
var key = ""
|
||||
var duration: Int64?
|
||||
var file: String?
|
||||
var size: Int64?
|
||||
var container: String?
|
||||
var streams: [PlexStream] = []
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.int64("id") ?? 0
|
||||
key = c.str("key") ?? ""
|
||||
duration = c.int64("duration")
|
||||
file = c.str("file")
|
||||
size = c.int64("size")
|
||||
container = c.str("container")
|
||||
streams = c.list("Stream")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexStream: Decodable {
|
||||
var id: Int64 = 0
|
||||
var streamType = 0
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.int64("id") ?? 0
|
||||
streamType = c.int("streamType") ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexTag: Decodable {
|
||||
var tag = ""
|
||||
init(from decoder: Decoder) throws {
|
||||
tag = (try decoder.container(keyedBy: AnyKey.self)).str("tag") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexDirectory: Decodable {
|
||||
var key = ""
|
||||
var type = ""
|
||||
var title = ""
|
||||
var locations: [PlexLocation] = []
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
key = c.str("key") ?? ""
|
||||
type = c.str("type") ?? ""
|
||||
title = c.str("title") ?? ""
|
||||
locations = c.list("Location")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexLocation: Decodable {
|
||||
var path = ""
|
||||
init(from decoder: Decoder) throws {
|
||||
path = (try decoder.container(keyedBy: AnyKey.self)).str("path") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexHub: Decodable {
|
||||
var type = ""
|
||||
var hubIdentifier = ""
|
||||
var title = ""
|
||||
var metadata: [PlexItem] = []
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
type = c.str("type") ?? ""
|
||||
hubIdentifier = c.str("hubIdentifier") ?? ""
|
||||
title = c.str("title") ?? ""
|
||||
metadata = c.list("Metadata")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexLevel: Decodable {
|
||||
var v: Float = 0
|
||||
init(from decoder: Decoder) throws {
|
||||
v = Float((try decoder.container(keyedBy: AnyKey.self)).double("v") ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexPin: Decodable {
|
||||
var id: Int64 = 0
|
||||
var code = ""
|
||||
var authToken: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.int64("id") ?? 0
|
||||
code = c.str("code") ?? ""
|
||||
authToken = c.str("authToken")
|
||||
}
|
||||
}
|
||||
|
||||
struct PlexUser: Decodable {
|
||||
var username: String?
|
||||
var title: String?
|
||||
var email: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
username = c.str("username")
|
||||
title = c.str("title")
|
||||
email = c.str("email")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- app models ------------------------------------------------------------------
|
||||
|
||||
struct Track: Codable, Hashable, Identifiable {
|
||||
var ratingKey: String
|
||||
var title: String
|
||||
var artist: String
|
||||
var album: String
|
||||
var albumKey: String?
|
||||
var artistKey: String?
|
||||
var index: Int?
|
||||
var disc: Int?
|
||||
var durationMs: Int64 = 0
|
||||
var thumb: String?
|
||||
var partKey: String?
|
||||
var partId: Int64?
|
||||
var partFile: String?
|
||||
var partSize: Int64?
|
||||
var codec: String?
|
||||
var year: Int?
|
||||
var playlistItemId: Int64?
|
||||
/// Set for tracks known only from the stems index (no Plex match): play this stem folder.
|
||||
var stemKey: String?
|
||||
|
||||
var id: String { playlistItemId.map { "\(ratingKey)#\($0)" } ?? ratingKey }
|
||||
|
||||
init(ratingKey: String, title: String, artist: String, album: String, albumKey: String? = nil,
|
||||
artistKey: String? = nil, index: Int? = nil, disc: Int? = nil, durationMs: Int64 = 0,
|
||||
thumb: String? = nil, partKey: String? = nil, partId: Int64? = nil, partFile: String? = nil,
|
||||
partSize: Int64? = nil, codec: String? = nil, year: Int? = nil, playlistItemId: Int64? = nil,
|
||||
stemKey: String? = nil) {
|
||||
self.ratingKey = ratingKey; self.title = title; self.artist = artist; self.album = album
|
||||
self.albumKey = albumKey; self.artistKey = artistKey; self.index = index; self.disc = disc
|
||||
self.durationMs = durationMs; self.thumb = thumb; self.partKey = partKey; self.partId = partId
|
||||
self.partFile = partFile; self.partSize = partSize; self.codec = codec; self.year = year
|
||||
self.playlistItemId = playlistItemId; self.stemKey = stemKey
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
self.init(ratingKey: c.str("ratingKey") ?? "", title: c.str("title") ?? "", artist: c.str("artist") ?? "",
|
||||
album: c.str("album") ?? "", albumKey: c.str("albumKey"), artistKey: c.str("artistKey"),
|
||||
index: c.int("index"), disc: c.int("disc"), durationMs: c.int64("durationMs") ?? 0,
|
||||
thumb: c.str("thumb"), partKey: c.str("partKey"), partId: c.int64("partId"),
|
||||
partFile: c.str("partFile"), partSize: c.int64("partSize"), codec: c.str("codec"),
|
||||
year: c.int("year"), playlistItemId: c.int64("playlistItemId"), stemKey: c.str("stemKey"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Album: Codable, Hashable, Identifiable {
|
||||
var ratingKey: String
|
||||
var title: String
|
||||
var artist: String
|
||||
var artistKey: String?
|
||||
var year: Int?
|
||||
var thumb: String?
|
||||
var trackCount: Int?
|
||||
var genre: String?
|
||||
/// "1975-11-14" when Plex knows the release day.
|
||||
var released: String?
|
||||
var id: String { ratingKey }
|
||||
}
|
||||
|
||||
struct Artist: Codable, Hashable, Identifiable {
|
||||
var ratingKey: String
|
||||
var title: String
|
||||
var sortTitle: String
|
||||
var thumb: String?
|
||||
var id: String { ratingKey }
|
||||
}
|
||||
|
||||
struct Playlist: Codable, Hashable, Identifiable {
|
||||
var ratingKey: String
|
||||
var title: String
|
||||
var thumb: String?
|
||||
var trackCount = 0
|
||||
var durationMs: Int64 = 0
|
||||
var smart = false
|
||||
var id: String { ratingKey }
|
||||
}
|
||||
|
||||
struct MusicSection: Hashable, Identifiable {
|
||||
var key: String
|
||||
var title: String
|
||||
var locations: [String]
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct SearchResults {
|
||||
var artists: [Artist] = []
|
||||
var albums: [Album] = []
|
||||
var tracks: [Track] = []
|
||||
var playlists: [Playlist] = []
|
||||
var isEmpty: Bool { artists.isEmpty && albums.isEmpty && tracks.isEmpty && playlists.isEmpty }
|
||||
}
|
||||
|
||||
extension PlexItem {
|
||||
func toTrack() -> Track {
|
||||
let m = media.first
|
||||
let part = m?.parts.first
|
||||
let orig = originalTitle.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
return Track(
|
||||
ratingKey: ratingKey,
|
||||
title: title,
|
||||
artist: orig ?? grandparentTitle ?? "Unknown Artist",
|
||||
album: parentTitle ?? "",
|
||||
albumKey: parentRatingKey,
|
||||
artistKey: grandparentRatingKey,
|
||||
index: index,
|
||||
disc: parentIndex,
|
||||
durationMs: duration ?? m?.duration ?? part?.duration ?? 0,
|
||||
thumb: thumb ?? parentThumb ?? grandparentThumb,
|
||||
partKey: part?.key,
|
||||
partId: part?.id,
|
||||
partFile: part?.file,
|
||||
partSize: part?.size,
|
||||
codec: m?.audioCodec ?? part?.container,
|
||||
year: parentYear ?? year,
|
||||
playlistItemId: playlistItemID
|
||||
)
|
||||
}
|
||||
|
||||
func toAlbum() -> Album {
|
||||
Album(ratingKey: ratingKey, title: title, artist: parentTitle ?? "Unknown Artist", artistKey: parentRatingKey,
|
||||
year: year, thumb: thumb, trackCount: leafCount, genre: genres.first?.tag,
|
||||
released: originallyAvailableAt.flatMap { $0.count >= 10 ? $0 : nil })
|
||||
}
|
||||
|
||||
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
||||
|
||||
func toPlaylist() -> Playlist {
|
||||
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0,
|
||||
durationMs: duration ?? 0, smart: smart ?? false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"colors":[{"idiom":"universal"}],"info":{"author":"xcode","version":1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"images":[{"filename":"icon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
||||
{"info":{"author":"xcode","version":1}}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// How many of an album's (or artist's) songs have stems.
|
||||
struct Coverage: Equatable {
|
||||
var total: Int
|
||||
var withStems: Int
|
||||
var none: Bool { total > 0 && withStems == 0 }
|
||||
var partial: Bool { withStems >= 1 && withStems < total }
|
||||
}
|
||||
|
||||
/// Which albums and artists have stems. Plex albums carry no file paths, so this keeps a slim
|
||||
/// copy of every track's path (fetched in pages, cached on disk, refreshed every few hours) and
|
||||
/// matches it against the stems index whenever that changes. Until the first full listing
|
||||
/// arrives [ready] is false and nothing is flagged.
|
||||
@Observable
|
||||
final class StemCoverage: @unchecked Sendable {
|
||||
private struct Slim: Codable { var k: String; var al: String?; var ar: String?; var f: String?; var d: Int64 = 0 }
|
||||
private struct Listing: Codable { var section: String; var at: Double; var tracks: [Slim] }
|
||||
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let lock = AsyncLock()
|
||||
@ObservationIgnored private var listing: Listing?
|
||||
private static let page = 1000
|
||||
private static let staleSeconds: Double = 6 * 3600
|
||||
|
||||
private(set) var albums: [String: Coverage] = [:]
|
||||
private(set) var artists: [String: Coverage] = [:]
|
||||
/// True once the whole library has been listed and matched against a non-empty stems index.
|
||||
private(set) var ready = false
|
||||
|
||||
init(plex: PlexClient, stems: StemServer, settings: Settings, dir: URL) {
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.settings = settings
|
||||
file = dir.appendingPathComponent("library_tracks.json")
|
||||
listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) }
|
||||
settings.onChange("musicSection") { [weak self] in self?.sectionChanged() }
|
||||
stems.onIndexChange { [weak self] in self?.recompute() }
|
||||
sectionChanged()
|
||||
recompute()
|
||||
}
|
||||
|
||||
private func sectionChanged() {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty else { return }
|
||||
if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return }
|
||||
Task { await refresh() }
|
||||
}
|
||||
|
||||
/// Lists every track in the library again (every few hours).
|
||||
func refresh() async {
|
||||
await lock.run {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty, plex.signedIn else { return }
|
||||
var all: [Slim] = []
|
||||
do {
|
||||
var start = 0
|
||||
while true {
|
||||
let (page, total) = try await plex.tracksPage(start: start, size: Self.page)
|
||||
all += page.map { Slim(k: $0.ratingKey, al: $0.albumKey, ar: $0.artistKey, f: $0.partFile, d: $0.durationMs) }
|
||||
start += page.count
|
||||
if page.isEmpty || start >= total { break }
|
||||
}
|
||||
} catch { return }
|
||||
let l = Listing(section: section, at: Date().timeIntervalSince1970, tracks: all)
|
||||
if let data = try? JSONEncoder().encode(l) { try? data.write(to: file, options: .atomic) }
|
||||
await MainActor.run {
|
||||
self.listing = l
|
||||
self.recompute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return }
|
||||
let tracks = l.tracks
|
||||
Task.detached { [self] in
|
||||
var al: [String: (Int, Int)] = [:]
|
||||
var ar: [String: (Int, Int)] = [:]
|
||||
for t in tracks {
|
||||
let has = stems.find(Track(ratingKey: t.k, title: "", artist: "", album: "", durationMs: t.d, partFile: t.f)) != nil
|
||||
if let k = t.al { let c = al[k] ?? (0, 0); al[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
if let k = t.ar { let c = ar[k] ?? (0, 0); ar[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
}
|
||||
let albums = al.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
let artists = ar.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
await MainActor.run {
|
||||
self.albums = albums
|
||||
self.artists = artists
|
||||
self.ready = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
struct StemFile: Codable, Hashable {
|
||||
var name: String
|
||||
var file: String
|
||||
var size: Int64
|
||||
var mtime: Int64 = 0
|
||||
|
||||
init(name: String, file: String, size: Int64, mtime: Int64 = 0) {
|
||||
self.name = name; self.file = file; self.size = size; self.mtime = mtime
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
name = c.str("name") ?? ""
|
||||
file = c.str("file") ?? ""
|
||||
size = c.int64("size") ?? 0
|
||||
mtime = c.int64("mtime") ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct StemEntry: Codable, Hashable {
|
||||
var key: String
|
||||
var source: String
|
||||
var duration: Double?
|
||||
var samplerate: Int?
|
||||
var model: String?
|
||||
var finished: Double?
|
||||
var stems: [StemFile] = []
|
||||
|
||||
init(key: String, source: String, duration: Double? = nil, samplerate: Int? = nil, model: String? = nil,
|
||||
finished: Double? = nil, stems: [StemFile] = []) {
|
||||
self.key = key; self.source = source; self.duration = duration; self.samplerate = samplerate
|
||||
self.model = model; self.finished = finished; self.stems = stems
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
key = c.str("key") ?? ""
|
||||
source = c.str("source") ?? ""
|
||||
duration = c.double("duration")
|
||||
samplerate = c.int("samplerate")
|
||||
model = c.str("model")
|
||||
finished = c.double("finished")
|
||||
stems = c.list("stems")
|
||||
}
|
||||
|
||||
/// "Artist/Album/01 Song" -> "01 Song" etc., for stem-only browsing.
|
||||
var title: String { key.split(separator: "/", omittingEmptySubsequences: false).last.map(String.init) ?? key }
|
||||
var folder: String {
|
||||
guard let i = key.lastIndex(of: "/") else { return "" }
|
||||
return String(key[..<i])
|
||||
}
|
||||
}
|
||||
|
||||
struct StemIndexFile: Codable {
|
||||
var version = 1
|
||||
var tracks: [StemEntry] = []
|
||||
|
||||
init(version: Int = 1, tracks: [StemEntry] = []) { self.version = version; self.tracks = tracks }
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
version = c.int("version") ?? 1
|
||||
tracks = c.list("tracks")
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Plex file paths to stem folders. The stems folder mirrors the music folder, so the
|
||||
/// source path relative to the music root identifies a track; Plex reports absolute paths
|
||||
/// as its container sees them, so the root is unknown up front.
|
||||
///
|
||||
/// Matching tries every suffix of the Plex path, longest first. A suffix that is only a
|
||||
/// file name, or whose duration disagrees, is accepted only under a root prefix learned
|
||||
/// from an earlier, longer match, so "Intro.flac" in one album can't grab another's stems.
|
||||
final class StemIndex: @unchecked Sendable {
|
||||
let entries: [StemEntry]
|
||||
private var bySource: [String: StemEntry] = [:]
|
||||
private var learned: [String] = []
|
||||
private let lock = NSLock()
|
||||
|
||||
init(_ entries: [StemEntry], roots: [String] = []) {
|
||||
self.entries = entries
|
||||
bySource.reserveCapacity(entries.count * 2)
|
||||
for e in entries {
|
||||
let k = Self.norm(e.source)
|
||||
if bySource[k] == nil { bySource[k] = e }
|
||||
}
|
||||
for r in roots where !learned.contains(r) { learned.append(r) }
|
||||
}
|
||||
|
||||
var size: Int { entries.count }
|
||||
var roots: [String] { lock.lock(); defer { lock.unlock() }; return learned }
|
||||
|
||||
/// Returns the entry for a Plex part path, learning the path's root on a confident match.
|
||||
func find(_ plexPath: String?, durationMs: Int64 = 0) -> StemEntry? {
|
||||
guard let plexPath, !plexPath.isEmpty, !entries.isEmpty else { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let path = Self.norm(plexPath)
|
||||
for root in learned where path.hasPrefix(root) {
|
||||
if let hit = bySource[String(path.dropFirst(root.count))] { return hit }
|
||||
}
|
||||
let parts = path.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count > 1 else { return nil }
|
||||
for i in 1..<parts.count {
|
||||
let rel = parts[i...].joined(separator: "/")
|
||||
guard let hit = bySource[rel] else { continue }
|
||||
let deep = parts.count - i >= 2
|
||||
if deep && durationMatches(hit, durationMs) {
|
||||
let root = parts[0..<i].joined(separator: "/") + "/"
|
||||
if !learned.contains(root) { learned.append(root) }
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func durationMatches(_ e: StemEntry, _ durationMs: Int64) -> Bool {
|
||||
guard let d = e.duration, durationMs > 0 else { return true }
|
||||
return abs(d * 1000 - Double(durationMs)) < 3000
|
||||
}
|
||||
|
||||
/// NFC, forward slashes, no extension, lower case.
|
||||
static func norm(_ path: String) -> String {
|
||||
var p = path.precomposedStringWithCanonicalMapping.replacingOccurrences(of: "\\", with: "/")
|
||||
let slash = p.lastIndex(of: "/").map { p.distance(from: p.startIndex, to: $0) } ?? -1
|
||||
if let dotIdx = p.lastIndex(of: ".") {
|
||||
let dot = p.distance(from: p.startIndex, to: dotIdx)
|
||||
if dot > slash + 1 { p = String(p[..<dotIdx]) }
|
||||
}
|
||||
return p.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum StemServerStatus: Equatable {
|
||||
case unknown
|
||||
case online(tracks: Int, transcode: Bool)
|
||||
case offline(reason: String, cachedTracks: Int)
|
||||
|
||||
var isOnline: Bool { if case .online = self { true } else { false } }
|
||||
}
|
||||
|
||||
private struct ServerInfo: Decodable {
|
||||
var tracks = 0
|
||||
var ffmpeg = false
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
tracks = c.int("tracks") ?? 0
|
||||
ffmpeg = c.bool("ffmpeg") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Client for server/stemserve.py: the stems index, stem file URLs and waveforms.
|
||||
@Observable
|
||||
final class StemServer: @unchecked Sendable {
|
||||
@ObservationIgnored private let http: Http
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let indexFile: URL
|
||||
@ObservationIgnored private let etagFile: URL
|
||||
@ObservationIgnored private let refreshLock = AsyncLock()
|
||||
@ObservationIgnored private var transcode = false
|
||||
@ObservationIgnored private var indexListeners: [() -> Void] = []
|
||||
private static let checkSeconds: UInt64 = 60
|
||||
|
||||
private(set) var index = StemIndex([])
|
||||
private(set) var status = StemServerStatus.unknown
|
||||
/// When the index was last checked against the server, nil before the first check.
|
||||
private(set) var checkedAt: Date?
|
||||
|
||||
init(http: Http, settings: Settings, dir: URL) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
indexFile = dir.appendingPathComponent("stem_index.json")
|
||||
etagFile = dir.appendingPathComponent("stem_index.etag")
|
||||
if let data = try? Data(contentsOf: indexFile),
|
||||
let parsed = try? JSONDecoder().decode(StemIndexFile.self, from: data) {
|
||||
index = StemIndex(parsed.tracks, roots: roots())
|
||||
}
|
||||
// stemd on the server separates new music around the clock; look for its new stems
|
||||
// every minute. Unchanged indexes cost one 304.
|
||||
Task.detached { [weak self] in
|
||||
while let self {
|
||||
await self.refresh()
|
||||
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var base: String { settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private func roots() -> [String] { settings.stemRoots.split(separator: "\n").map(String.init).filter { !$0.isEmpty } }
|
||||
|
||||
/// Main-thread callback after the index is replaced.
|
||||
func onIndexChange(_ f: @escaping () -> Void) { indexListeners.append(f) }
|
||||
|
||||
private func req(_ url: URL, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
let t = settings.stemToken
|
||||
if !t.isEmpty { r.setValue(t, forHTTPHeaderField: "X-Stem-Token") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad stemserve address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
await refreshLock.run {
|
||||
do {
|
||||
let info = try JSONDecoder().decode(ServerInfo.self, from: try await http.data(req(try url("/api/info"))))
|
||||
transcode = info.ffmpeg
|
||||
let fm = FileManager.default
|
||||
let etag = (!force && fm.fileExists(atPath: indexFile.path)) ? try? String(contentsOf: etagFile, encoding: .utf8) : nil
|
||||
var r = req(try url("/api/index"))
|
||||
if let etag { r.setValue(etag, forHTTPHeaderField: "If-None-Match") }
|
||||
// Straight to the session: a 304 must not be confused with a cached 200.
|
||||
let (body, resp) = try await http.session.data(for: r)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
var newIndex: StemIndex?
|
||||
if code == 304 {
|
||||
} else if (200..<300).contains(code) {
|
||||
let parsed = try JSONDecoder().decode(StemIndexFile.self, from: body)
|
||||
newIndex = StemIndex(parsed.tracks, roots: roots())
|
||||
try? body.write(to: indexFile, options: .atomic)
|
||||
if let tag = (resp as? HTTPURLResponse)?.value(forHTTPHeaderField: "ETag") {
|
||||
try? tag.write(to: etagFile, atomically: true, encoding: .utf8)
|
||||
}
|
||||
} else {
|
||||
throw HttpError(code: code, message: "stemserve answered \(code)")
|
||||
}
|
||||
let fresh = newIndex
|
||||
await MainActor.run {
|
||||
if let newIndex = fresh {
|
||||
self.index = newIndex
|
||||
self.indexListeners.forEach { $0() }
|
||||
}
|
||||
self.status = .online(tracks: self.index.size, transcode: info.ffmpeg)
|
||||
self.checkedAt = Date()
|
||||
}
|
||||
} catch {
|
||||
let reason = error.localizedDescription
|
||||
await MainActor.run { self.status = .offline(reason: reason, cachedTracks: self.index.size) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func find(_ track: Track) -> StemEntry? {
|
||||
let idx = index
|
||||
if let k = track.stemKey { return idx.entries.first { $0.key == k } }
|
||||
let hit = idx.find(track.partFile, durationMs: track.durationMs)
|
||||
let learned = idx.roots.joined(separator: "\n")
|
||||
if learned != settings.stemRoots { settings.stemRoots = learned }
|
||||
return hit
|
||||
}
|
||||
|
||||
/// Format the server will actually send: Opus needs ffmpeg there.
|
||||
func effectiveQuality(_ q: StemQuality) -> StemQuality {
|
||||
q.fmt == "opus" && !transcode && status.isOnline ? .Lossless : q
|
||||
}
|
||||
|
||||
func fileUrl(_ entry: StemEntry, _ stem: StemFile, _ quality: StemQuality) -> URL? {
|
||||
let path = (entry.key.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + [stem.file])
|
||||
.map(urlEncode).joined(separator: "/")
|
||||
guard let u = URL(string: base + "/file/" + path) else { return nil }
|
||||
var params: [(String, Any?)] = []
|
||||
if quality.fmt == "opus" { params += [("fmt", "opus"), ("br", quality.kbps)] }
|
||||
if !settings.stemToken.isEmpty { params.append(("token", settings.stemToken)) }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
/// Asks the server to start the Opus transcodes now, so the stems are ready when we need them.
|
||||
func prepare(_ entry: StemEntry, _ quality: StemQuality) {
|
||||
guard quality.fmt == "opus" else { return }
|
||||
Task.detached { [self] in
|
||||
guard let u = try? url("/api/prepare", [("key", entry.key), ("fmt", "opus"), ("br", quality.kbps)]) else { return }
|
||||
var r = req(u, method: "POST")
|
||||
r.httpBody = Data()
|
||||
_ = try? await http.session.data(for: r)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Stem Now": asks stemd (through stemserve) to separate these songs before the rest of the
|
||||
/// library. Returns how many it queued: the ones with no stems yet.
|
||||
func stemNow(_ tracks: [Track]) async throws -> Int {
|
||||
var seen = Set<String>()
|
||||
let paths = tracks.filter { find($0) == nil }.compactMap(\.partFile).filter { seen.insert($0).inserted }
|
||||
if paths.isEmpty { return 0 }
|
||||
var r = req(try url("/api/stem"), method: "POST")
|
||||
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
r.httpBody = try JSONSerialization.data(withJSONObject: ["paths": paths])
|
||||
let (body, resp) = try await http.session.data(for: r)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if !(200..<300).contains(code) {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["error"] as? String
|
||||
throw HttpError(code: code, message: code == 404 ? "stemserve needs updating for Stem Now" : msg ?? "stemserve answered \(code)")
|
||||
}
|
||||
return paths.count
|
||||
}
|
||||
|
||||
/// Per-stem RMS envelopes (0..1 of full scale), [buckets] values each.
|
||||
func peaks(_ entry: StemEntry, buckets: Int) async -> [String: [Float]]? {
|
||||
guard let u = try? url("/api/peaks", [("key", entry.key), ("n", buckets)]),
|
||||
let body = try? await http.data(req(u)),
|
||||
let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
||||
let stems = obj["stems"] as? [String: [Any]] else { return nil }
|
||||
return stems.mapValues { arr in arr.map { Float(($0 as? NSNumber)?.intValue ?? 0) / 1000 } }
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple async mutex: one closure at a time, in arrival order.
|
||||
actor AsyncLock {
|
||||
private var busy = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func run<T>(_ body: () async throws -> T) async rethrows -> T {
|
||||
if busy {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
busy = true
|
||||
defer {
|
||||
if waiters.isEmpty { busy = false } else { waiters.removeFirst().resume() }
|
||||
}
|
||||
return try await body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
|
||||
let MARGIN: CGFloat = 20
|
||||
|
||||
func formatDuration(_ ms: Int64) -> String {
|
||||
let s = max(ms / 1000, 0)
|
||||
return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s / 60 % 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
|
||||
func formatMinutes(_ ms: Int64) -> String {
|
||||
let m = Int(ms / 60000)
|
||||
return m >= 60 ? "\(m / 60) hr \(m % 60) min" : "\(m) minute\(m == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
func songs(_ n: Int) -> String { "\(n) song\(n == 1 ? "" : "s")" }
|
||||
|
||||
func gb(_ bytes: Int64) -> String {
|
||||
let b = Double(bytes)
|
||||
return b < 1e9 ? String(format: "%.0f MB", b / 1e6) : String(format: "%.1f GB", b / 1e9)
|
||||
}
|
||||
|
||||
/// Plex artwork through the photo transcoder, sized to the screen's pixels.
|
||||
struct Artwork: View {
|
||||
let thumb: String?
|
||||
let size: CGFloat
|
||||
var corner: CGFloat = 6
|
||||
var circle = false
|
||||
var placeholder = "music.note"
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
@State private var image: UIImage?
|
||||
|
||||
private var url: URL? {
|
||||
// Round the request up so nearby sizes share cache entries.
|
||||
let px = min(max(Int((size * scale / 120).rounded(.up)) * 120, 120), 1440)
|
||||
return AppGraph.shared.plex.artUrl(thumb, px: px)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let shape = circle ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||
ZStack {
|
||||
p.placeholder
|
||||
Image(systemName: placeholder).font(.system(size: size * 0.38)).foregroundStyle(p.label3)
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.stroke(p.separator.opacity(0.6), lineWidth: 0.5))
|
||||
.task(id: url) {
|
||||
guard let url else { image = nil; return }
|
||||
if let c = ArtLoader.shared.cached(url) { image = c; return }
|
||||
image = nil
|
||||
image = await ArtLoader.shared.image(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings.app-style coloured rounded-square icon.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let bg: UInt32
|
||||
var body: some View {
|
||||
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon, title and grey value, as in Settings.app.
|
||||
struct ValueCell: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var icon: String?
|
||||
var iconBg: UInt32 = 0x8E8E93
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
if let icon { SettingsIcon(symbol: icon, bg: iconBg) }
|
||||
Text(title).foregroundStyle(p.label)
|
||||
Spacer(minLength: 8)
|
||||
if let value { Text(value).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyState: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: icon).font(.system(size: 52)).foregroundStyle(p.label3)
|
||||
Text(title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).padding(.top, 14)
|
||||
Text(message).font(.subheadline).foregroundStyle(p.label2).multilineTextAlignment(.center).padding(.top, 6)
|
||||
if let action {
|
||||
Button(action: onAction) {
|
||||
Text(action).font(.headline).foregroundStyle(.white).padding(.horizontal, 20).padding(.vertical, 10)
|
||||
.background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40).padding(.vertical, 70)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingRow: View {
|
||||
var body: some View { ProgressView().controlSize(.large).frame(maxWidth: .infinity).padding(.vertical, 40) }
|
||||
}
|
||||
|
||||
struct ErrorRow: View {
|
||||
let message: String
|
||||
let retry: () -> Void
|
||||
@Environment(Ui.self) private var ui
|
||||
var body: some View {
|
||||
if !AppGraph.shared.plex.signedIn {
|
||||
EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PillButton: View {
|
||||
let text: String
|
||||
let icon: String
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon).font(.system(size: 17, weight: .semibold))
|
||||
Text(text).font(.headline)
|
||||
}
|
||||
.foregroundStyle(p.accent)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Play / Shuffle pair under a header.
|
||||
struct PlayShuffle: View {
|
||||
let play: () -> Void
|
||||
let shuffle: () -> Void
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
PillButton(text: "Play", icon: "play.fill", action: play)
|
||||
PillButton(text: "Shuffle", icon: "shuffle", action: shuffle)
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
/// Capsule chip, accent-filled when selected.
|
||||
struct Chip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? .white : p.label)
|
||||
.padding(.horizontal, 14).frame(height: 32)
|
||||
.background(selected ? p.accent : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Small outlined tag, e.g. "NO STEMS" next to a track.
|
||||
struct Tag: View {
|
||||
let text: String
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
let c = color ?? p.label2
|
||||
Text(text).font(.system(size: 10, weight: .bold)).foregroundStyle(c)
|
||||
.padding(.horizontal, 4).padding(.vertical, 1)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(c, lineWidth: 1))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloaded marker: filled circle with a down arrow, as in Apple Music's lists.
|
||||
struct DownloadedMark: View {
|
||||
var color: Color?
|
||||
var size: CGFloat = 14
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Image(systemName: "arrow.down.circle.fill").font(.system(size: size)).foregroundStyle(color ?? p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download progress ring.
|
||||
struct ProgressRing: View {
|
||||
let progress: Float
|
||||
var size: CGFloat = 18
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(color.map { $0.opacity(0.3) } ?? p.fillStrong, lineWidth: 2)
|
||||
Circle().trim(from: 0, to: CGFloat(min(max(progress, 0), 1)))
|
||||
.stroke(color ?? p.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: size - 2, height: size - 2)
|
||||
.animation(.linear(duration: 0.2), value: progress)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated "now playing" bars used in place of a track number.
|
||||
struct PlayingBars: View {
|
||||
let playing: Bool
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: !playing)) { tl in
|
||||
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
|
||||
Canvas { ctx, size in
|
||||
let w = size.width / 4.5
|
||||
for i in 0..<3 {
|
||||
let h = playing ? 0.35 + 0.65 * abs(sin(phase + Double(i) * 1.9)) : 0.3 + Double(i) * 0.15
|
||||
let bh = size.height * h
|
||||
let r = CGRect(x: Double(i) * w * 1.6 + w * 0.3, y: size.height - bh, width: w, height: bh)
|
||||
ctx.fill(Path(roundedRect: r, cornerRadius: w / 3), with: .color(color ?? p.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Section title with an optional accent action, as in Apple Music ("Recently Added").
|
||||
struct SectionTitle: View {
|
||||
let title: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title).font(.title2.bold()).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
if let action { Button(action, action: onAction).foregroundStyle(p.accent) }
|
||||
}
|
||||
.padding(.leading, MARGIN).padding(.trailing, 12).padding(.top, 22).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a screen's data is doing. A failed refresh keeps the data already on screen.
|
||||
enum LoadState<T> {
|
||||
case loading
|
||||
case ok(T)
|
||||
case err(String)
|
||||
|
||||
var value: T? { if case .ok(let v) = self { v } else { nil } }
|
||||
}
|
||||
|
||||
extension LoadState {
|
||||
/// Runs [f], keeping old data if it fails after a success.
|
||||
@MainActor
|
||||
static func load(_ state: Binding<LoadState<T>>, _ f: () async throws -> T) async {
|
||||
do {
|
||||
state.wrappedValue = .ok(try await f())
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
if state.wrappedValue.value == nil { state.wrappedValue = .err(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The loading spinner, error page or content for a [LoadState].
|
||||
struct Loaded<T, Content: View>: View {
|
||||
let state: LoadState<T>
|
||||
let retry: () -> Void
|
||||
@ViewBuilder let content: (T) -> Content
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .loading: LoadingRow()
|
||||
case .err(let m): ErrorRow(message: m, retry: retry)
|
||||
case .ok(let v): content(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain scrolling page with a large title, like the Android shell's Page.
|
||||
struct Page<Content: View>: View {
|
||||
let title: String
|
||||
var large = true
|
||||
@ViewBuilder let content: () -> Content
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) { content() }
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(p.bg)
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(large ? .large : .inline)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Row padding plus the inset hairline under it.
|
||||
func listRow(inset: CGFloat = MARGIN, leading: CGFloat = MARGIN, trailing: CGFloat = 6, line: Bool = true) -> some View {
|
||||
modifier(ListRow(inset: inset, leading: leading, trailing: trailing, line: line))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ListRow: ViewModifier {
|
||||
let inset: CGFloat, leading: CGFloat, trailing: CGFloat, line: Bool
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
func body(content: Content) -> some View {
|
||||
content.padding(.leading, leading).padding(.trailing, trailing)
|
||||
.overlay(alignment: .bottom) {
|
||||
if line { p.separator.frame(height: 1 / scale).padding(.leading, inset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
|
||||
/// offer the front or end of the queue, download, and add-to-playlist (playlists included).
|
||||
/// Each call returns the actions; follow-up choices go through [Overlays].
|
||||
@MainActor
|
||||
struct MediaMenu {
|
||||
let overlays: Overlays
|
||||
let ui: Ui
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
var a = queueActions { [t] }
|
||||
if g.downloads.records[t.ratingKey] == nil {
|
||||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") })
|
||||
} else {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||
if allowGoTo, let k = t.albumKey {
|
||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||
ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)))
|
||||
})
|
||||
}
|
||||
if allowGoTo, let k = t.artistKey {
|
||||
a.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: t.artist, sortTitle: t.artist))) })
|
||||
}
|
||||
return a + extra
|
||||
}
|
||||
|
||||
func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.albums[al.ratingKey]
|
||||
let total = cov?.total ?? al.trackCount ?? Int.max
|
||||
var more: [SheetAction] = []
|
||||
if allowGoTo, let k = al.artistKey {
|
||||
more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) })
|
||||
}
|
||||
let plex = g.plex
|
||||
return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) },
|
||||
playlistKey: nil, extra: more + extra, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func artist(_ ar: Artist) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.artists[ar.ratingKey]
|
||||
let plex = g.plex
|
||||
return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { try await plex.artistTracks(ar.ratingKey) },
|
||||
playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let plex = g.plex
|
||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
||||
confirmDownload: pl.trackCount > 50)
|
||||
}
|
||||
|
||||
/// For songs already in hand (downloads with no Plex album behind them).
|
||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||
let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil }
|
||||
return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: extra, stemmed: stemmed)
|
||||
}
|
||||
|
||||
/// True when every song is known to have stems, so there's nothing for Stem Now to do.
|
||||
private func stemmed(_ cov: Coverage?) -> Bool {
|
||||
guard g.coverage.ready, let cov else { return false }
|
||||
return cov.withStems >= cov.total
|
||||
}
|
||||
|
||||
private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track],
|
||||
playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] {
|
||||
var a = queueActions(load)
|
||||
if !allDownloaded {
|
||||
a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") {
|
||||
withTracks(load) { tracks in
|
||||
let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") }
|
||||
if confirmDownload && tracks.count > 20 {
|
||||
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if !downloaded.isEmpty {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||
})
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
||||
if !stemmed { a.append(stemNow(load)) }
|
||||
return a + extra
|
||||
}
|
||||
|
||||
/// Front of the queue (right after this song) and the end of it.
|
||||
private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] {
|
||||
[
|
||||
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }
|
||||
},
|
||||
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Has stemd on the server separate the songs that lack stems ahead of the rest of the library.
|
||||
private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction {
|
||||
SheetAction("Stem Now", icon: "waveform") {
|
||||
Task {
|
||||
do {
|
||||
let n = try await g.stems.stemNow(try await load())
|
||||
if n == 0 { overlays.hud("Already has stems", "info.circle") }
|
||||
else { overlays.hud(n == 1 ? "Stemming next" : "Stemming \(songs(n)) next", "waveform") }
|
||||
} catch {
|
||||
overlays.hud("Couldn't queue: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withTracks(_ load: @escaping () async throws -> [Track], _ then: @escaping ([Track]) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let t = try await load()
|
||||
if t.isEmpty { overlays.hud("Nothing to add", "info.circle") } else { then(t) }
|
||||
} catch {
|
||||
overlays.hud("Couldn't load songs: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
||||
Task {
|
||||
let lists: [Playlist]
|
||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
return
|
||||
}
|
||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
||||
if keys.isEmpty {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
||||
return
|
||||
}
|
||||
let plex = g.plex
|
||||
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in
|
||||
SheetAction(pl.title) {
|
||||
Task {
|
||||
do {
|
||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
overlays.hud("Added to \(pl.title)")
|
||||
} catch {
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||
let plex = g.plex
|
||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
||||
Task {
|
||||
do {
|
||||
_ = try await plex.createPlaylist(name, trackKeys: tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") })
|
||||
overlays.hud("Created “\(name)”")
|
||||
onCreated()
|
||||
} catch {
|
||||
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Ui {
|
||||
var menu: MediaMenu { MediaMenu(overlays: overlays, ui: self) }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SheetAction: Identifiable {
|
||||
let id = UUID()
|
||||
var title: String
|
||||
var icon: String?
|
||||
var destructive = false
|
||||
var run: () -> Void
|
||||
|
||||
init(_ title: String, icon: String? = nil, destructive: Bool = false, _ run: @escaping () -> Void) {
|
||||
self.title = title; self.icon = icon; self.destructive = destructive; self.run = run
|
||||
}
|
||||
}
|
||||
|
||||
/// Action sheets, alerts, text prompts and the HUD, shown from anywhere. They're drawn by
|
||||
/// [OverlayHost]: one under the tabs and one inside the Now Playing sheet, and only the
|
||||
/// topmost of those shows them.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Overlays {
|
||||
struct Actions { var title: String; var items: [SheetAction] }
|
||||
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
||||
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
||||
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
var level = 0
|
||||
|
||||
private var hudTask: Task<Void, Never>?
|
||||
|
||||
private var showing: Bool { actions != nil || confirm != nil || prompt != nil }
|
||||
|
||||
/// Presents after the current dialog has gone, since SwiftUI drops one shown on top of another.
|
||||
private func present(_ f: @escaping () -> Void) {
|
||||
if showing {
|
||||
actions = nil; confirm = nil; prompt = nil
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
f()
|
||||
}
|
||||
} else {
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
func actions(_ title: String, _ items: [SheetAction]) {
|
||||
present { self.actions = Actions(title: title, items: items) }
|
||||
}
|
||||
|
||||
func confirm(_ title: String, _ message: String?, _ button: String, destructive: Bool = true, _ run: @escaping () -> Void) {
|
||||
present { self.confirm = Confirm(title: title, message: message, button: button, destructive: destructive, run: run) }
|
||||
}
|
||||
|
||||
func prompt(_ title: String, _ message: String?, initial: String = "", _ button: String, placeholder: String = "", _ run: @escaping (String) -> Void) {
|
||||
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
||||
}
|
||||
|
||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
let secs = text.count > 40 ? 2.8 : 1.4
|
||||
hudTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(secs * 1e9))
|
||||
if Task.isCancelled { return }
|
||||
withAnimation(.easeIn(duration: 0.25)) { self.hud = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if mine, let h = overlays.hud {
|
||||
HudView(hud: h).transition(.opacity.combined(with: .scale(scale: 0.9))).allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(overlays.actions?.title ?? "", isPresented: binding(\.actions), titleVisibility: .visible, presenting: overlays.actions) { a in
|
||||
ForEach(a.items) { item in
|
||||
Button(item.title, role: item.destructive ? .destructive : nil) { item.run() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert(overlays.confirm?.title ?? "", isPresented: binding(\.confirm), presenting: overlays.confirm) { c in
|
||||
Button(c.button, role: c.destructive ? .destructive : nil) { c.run() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { c in
|
||||
if let m = c.message { Text(m) }
|
||||
}
|
||||
.alert(overlays.prompt?.title ?? "", isPresented: binding(\.prompt), presenting: overlays.prompt) { p in
|
||||
TextField(p.placeholder, text: $text).autocorrectionDisabled()
|
||||
Button(p.button) {
|
||||
let v = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !v.isEmpty { p.run(v) }
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { p in
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||
}
|
||||
|
||||
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
||||
Binding(get: { mine && overlays[keyPath: kp] != nil }, set: { if !$0 { overlays[keyPath: kp] = nil } })
|
||||
}
|
||||
}
|
||||
|
||||
private struct HudView: View {
|
||||
let hud: Overlays.Hud
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: hud.icon).font(.system(size: 40, weight: .medium))
|
||||
Text(hud.text).font(.subheadline.weight(.semibold)).multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 150, minHeight: 150)
|
||||
.frame(maxWidth: 240)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func overlayHost(_ o: Overlays, level: Int) -> some View { modifier(OverlayHost(overlays: o, level: level)) }
|
||||
|
||||
/// The same actions on long-press as the ••• menu.
|
||||
func actionsMenu(_ items: [SheetAction]) -> some View {
|
||||
contextMenu { ActionButtons(items: items) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ActionButtons: View {
|
||||
let items: [SheetAction]
|
||||
var body: some View {
|
||||
ForEach(items) { item in
|
||||
Button(role: item.destructive ? .destructive : nil) { item.run() } label: {
|
||||
if let i = item.icon { Label(item.title, systemImage: i) } else { Text(item.title) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ••• button, as a native menu.
|
||||
struct MoreMenu: View {
|
||||
let items: [SheetAction]
|
||||
var size: CGFloat = 17
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Menu { ActionButtons(items: items) } label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: size, weight: .semibold))
|
||||
.frame(width: 34, height: 34).contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "NO STEMS" / "3/10 STEMS" for an album or artist, once the whole library has been matched.
|
||||
struct StemFlag: View {
|
||||
let cov: Coverage?
|
||||
var body: some View {
|
||||
if AppGraph.shared.coverage.ready, let cov {
|
||||
if cov.none { Tag(text: "NO STEMS") } else if cov.partial { Tag(text: "\(cov.withStems)/\(cov.total) STEMS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple Music list row: art (or track number), title, artist, badges, and a ••• menu.
|
||||
/// Stems are the norm once the server has worked through the library, so the badge marks the
|
||||
/// songs without them, and only once the index has loaded.
|
||||
struct TrackRow<Leading: View>: View {
|
||||
let track: Track
|
||||
var number: Int?
|
||||
var showArt = true
|
||||
var subtitle: String?
|
||||
var menu: [SheetAction]?
|
||||
let action: () -> Void
|
||||
@ViewBuilder var leading: () -> Leading
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
let g = AppGraph.shared
|
||||
let pm = g.playback
|
||||
let isCurrent = pm.current?.track.ratingKey == track.ratingKey
|
||||
let noStems = g.stems.index.size > 0 && g.stems.find(track) == nil
|
||||
let downloaded = g.downloads.records[track.ratingKey]?.done == true
|
||||
let progress = g.downloads.progress[track.ratingKey]
|
||||
HStack(spacing: 0) {
|
||||
leading()
|
||||
if showArt {
|
||||
ZStack {
|
||||
Artwork(thumb: track.thumb, size: 48, corner: 5)
|
||||
if isCurrent {
|
||||
Color.black.opacity(0.35).frame(width: 48, height: 48).clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
PlayingBars(playing: pm.playWhenReady, color: .white)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
} else if let number {
|
||||
Group {
|
||||
if isCurrent { PlayingBars(playing: pm.playWhenReady) } else { Text("\(number)").foregroundStyle(p.label2) }
|
||||
}
|
||||
.frame(width: 32, alignment: .leading)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).foregroundStyle(isCurrent ? p.accent : p.label).lineLimit(1)
|
||||
if let s = subtitle ?? (showArt ? track.artist : nil) {
|
||||
Text(s).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
Spacer(minLength: 6)
|
||||
if noStems { Tag(text: "NO STEMS").padding(.trailing, 6) }
|
||||
if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) }
|
||||
else if downloaded { DownloadedMark().padding(.trailing, 4) }
|
||||
if let menu { MoreMenu(items: menu).foregroundStyle(p.label) }
|
||||
}
|
||||
.frame(minHeight: showArt ? 56 : 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.modifier(OptionalMenu(items: menu))
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackRow where Leading == EmptyView {
|
||||
init(track: Track, number: Int? = nil, showArt: Bool = true, subtitle: String? = nil, menu: [SheetAction]? = nil, action: @escaping () -> Void) {
|
||||
self.init(track: track, number: number, showArt: showArt, subtitle: subtitle, menu: menu, action: action) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionalMenu: ViewModifier {
|
||||
let items: [SheetAction]?
|
||||
func body(content: Content) -> some View {
|
||||
if let items { content.actionsMenu(items) } else { content }
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-column album grid. [subtitle] replaces the artist (or year) line; [badge] sits on the art's corner.
|
||||
struct AlbumGrid<Badge: View>: View {
|
||||
let albums: [Album]
|
||||
var showArtist = true
|
||||
var subtitle: ((Album) -> String?)?
|
||||
var menu: ((Album) -> [SheetAction])?
|
||||
let open: (Album) -> Void
|
||||
@ViewBuilder var badge: (Album) -> Badge
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
let cov = AppGraph.shared.coverage.albums
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 16, alignment: .top), GridItem(.flexible(), spacing: 16, alignment: .top)], spacing: 18) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
Artwork(thumb: a.thumb, size: geo.size.width, corner: 8, placeholder: "square.stack")
|
||||
.overlay(alignment: .bottomTrailing) { badge(a).padding(8) }
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
HStack(spacing: 6) {
|
||||
Text(a.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
StemFlag(cov: cov[a.ratingKey])
|
||||
}
|
||||
.padding(.top, 6)
|
||||
let sub = subtitle?(a) ?? (showArtist ? a.artist : a.year.map(String.init) ?? "")
|
||||
if !sub.isEmpty { Text(sub).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { open(a) }
|
||||
.modifier(OptionalMenu(items: menu?(a)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumGrid where Badge == EmptyView {
|
||||
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, open: @escaping (Album) -> Void) {
|
||||
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, open: open) { _ in EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistRow: View {
|
||||
let artist: Artist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: artist.thumb, size: 44, circle: true, placeholder: "person.fill")
|
||||
Text(artist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Spacer()
|
||||
StemFlag(cov: AppGraph.shared.coverage.artists[artist.ratingKey])
|
||||
}
|
||||
.frame(minHeight: 52)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Tab: String, CaseIterable, Hashable {
|
||||
case Library, Playlists, Search, Downloads, Settings
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .Library: "square.stack.fill"
|
||||
case .Playlists: "music.note.list"
|
||||
case .Search: "magnifyingglass"
|
||||
case .Downloads: "arrow.down.circle.fill"
|
||||
case .Settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist)
|
||||
}
|
||||
|
||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Ui {
|
||||
var tab: Tab = .Library
|
||||
var paths: [Tab: [Route]] = [:]
|
||||
var nowPlayingOpen = false {
|
||||
didSet { overlays.level = nowPlayingOpen ? 1 : 0 }
|
||||
}
|
||||
let overlays: Overlays
|
||||
|
||||
init(overlays: Overlays) { self.overlays = overlays }
|
||||
|
||||
func path(_ t: Tab) -> Binding<[Route]> {
|
||||
Binding(get: { self.paths[t] ?? [] }, set: { self.paths[t] = $0 })
|
||||
}
|
||||
|
||||
func push(_ r: Route) { paths[tab, default: []].append(r) }
|
||||
|
||||
func pop() { if paths[tab]?.isEmpty == false { paths[tab]?.removeLast() } }
|
||||
|
||||
/// Tapping the selected tab again pops it to its root.
|
||||
func selectTab(_ t: Tab) {
|
||||
if t == tab { paths[t] = [] } else { tab = t }
|
||||
}
|
||||
|
||||
func openNowPlaying() { nowPlayingOpen = true }
|
||||
|
||||
/// Switches to the Library tab and pushes [r] there.
|
||||
func showInLibrary(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
tab = .Library
|
||||
paths[.Library, default: []].append(r)
|
||||
}
|
||||
|
||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||
func showInSettings(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
tab = .Settings
|
||||
if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
|
||||
struct RootView: View {
|
||||
@State private var ui = Ui(overlays: Overlays())
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let p = Palette.of(s.theme, s.accent)
|
||||
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
||||
ForEach(Tab.allCases, id: \.self) { t in
|
||||
NavigationStack(path: ui.path(t)) {
|
||||
root(t).navigationDestination(for: Route.self) { destination($0) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if g.playback.current != nil { MiniPlayer() }
|
||||
}
|
||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||
.tag(t)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
.environment(ui)
|
||||
.environment(\.palette, p)
|
||||
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
||||
.tint(p.accent)
|
||||
}
|
||||
.overlayHost(ui.overlays, level: 0)
|
||||
.onChange(of: g.playback.message) { _, m in
|
||||
if let m { ui.overlays.hud(m.text, "info.circle") }
|
||||
}
|
||||
.environment(ui)
|
||||
.environment(\.palette, p)
|
||||
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
||||
.tint(p.accent)
|
||||
}
|
||||
|
||||
@ViewBuilder private func root(_ t: Tab) -> some View {
|
||||
switch t {
|
||||
case .Library: LibraryHome()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Search: SearchScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
case .Settings: SettingsScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(_ r: Route) -> some View {
|
||||
switch r {
|
||||
case .Artists: ArtistsScreen()
|
||||
case .Albums: AlbumsScreen()
|
||||
case .Songs: SongsScreen()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Eq: EqScreen()
|
||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
||||
case .PlexAccount: PlexAccountScreen()
|
||||
case .StemServer: StemServerScreen()
|
||||
case .Quality: QualityScreen()
|
||||
case .Storage: StorageScreen()
|
||||
case .Appearance: AppearanceScreen()
|
||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// ---- formatting and slider mappings ---------------------------------------------------------
|
||||
|
||||
private let F_LO = 20.0
|
||||
private let F_HI = 20000.0
|
||||
private let RIGHT = Color(hex: 0xFF9500)
|
||||
|
||||
func hz(_ f: Float) -> String {
|
||||
guard f >= 1000 else { return "\(Int(f)) Hz" }
|
||||
return String(format: f >= 10000 ? "%.1f kHz" : "%.2f kHz", f / 1000)
|
||||
.replacingOccurrences(of: ".00 ", with: " ").replacingOccurrences(of: ".0 ", with: " ")
|
||||
}
|
||||
|
||||
private func db(_ v: Float) -> String {
|
||||
v > 0.05 ? String(format: "+%.1f", v) : v < -0.05 ? String(format: "−%.1f", -v) : "0.0"
|
||||
}
|
||||
|
||||
private func signedDb(_ v: Float) -> String {
|
||||
(v > 0.005 ? "+" : v < -0.005 ? "−" : "") + String(format: "%.1f dB", abs(v))
|
||||
}
|
||||
|
||||
private func freqToPos(_ f: Float) -> Float { Float(min(max(log(Double(f) / F_LO) / log(F_HI / F_LO), 0), 1)) }
|
||||
private func posToFreq(_ p: Float) -> Float { Float(F_LO * pow(F_HI / F_LO, Double(p))) }
|
||||
private func qToPos(_ q: Float) -> Float {
|
||||
min(max(log(q / EqController.qMin) / log(EqController.qMax / EqController.qMin), 0), 1)
|
||||
}
|
||||
private func posToQ(_ p: Float) -> Float { EqController.qMin * pow(EqController.qMax / EqController.qMin, p) }
|
||||
|
||||
/// Rounds a dragged frequency to what a person would type: 3 significant figures.
|
||||
private func tidyFreq(_ f: Float) -> Float {
|
||||
let mag = Float(pow(10.0, floor(log10(Double(f))) - 2))
|
||||
return (f / mag).rounded() * mag
|
||||
}
|
||||
|
||||
private func round1(_ v: Float) -> Float { (v * 10).rounded() / 10 }
|
||||
|
||||
private func parseNumber(_ s: String) -> Float? {
|
||||
Float(s.replacingOccurrences(of: ",", with: ".").replacingOccurrences(of: "−", with: "-")
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "-" })
|
||||
}
|
||||
|
||||
private func trimmed(_ v: Float) -> String {
|
||||
var s = String(format: "%.2f", v)
|
||||
while s.hasSuffix("0") { s.removeLast() }
|
||||
if s.hasSuffix(".") { s.removeLast() }
|
||||
return s
|
||||
}
|
||||
|
||||
private func summary(_ f: EqFilter) -> String {
|
||||
var parts: [String] = []
|
||||
if f.type.hasGain { parts.append(signedDb(f.gain)) }
|
||||
if f.type.hasSlope { parts.append("\(f.slope) dB/oct") }
|
||||
if !f.type.hasSlope || f.slope == 12 { parts.append(String(format: "Q %.2f", f.q)) }
|
||||
if f.channel != .Both { parts.append(f.channel.label) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Sensible starting points, audible but mild, for a new filter of each type.
|
||||
private func newFilter(_ type: FilterType) -> EqFilter {
|
||||
var f = EqFilter(id: EqController.newFilterId(), type: type)
|
||||
switch type {
|
||||
case .Peak: f.freq = 1000; f.gain = 3; f.q = 1.41
|
||||
case .LowShelf: f.freq = 105; f.gain = 3; f.q = 0.71
|
||||
case .HighShelf: f.freq = 8000; f.gain = 3; f.q = 0.71
|
||||
case .LowPass: f.freq = 16000; f.q = 0.71
|
||||
case .HighPass: f.freq = 30; f.q = 0.71; f.slope = 24
|
||||
case .BandPass: f.freq = 1000; f.q = 0.71
|
||||
case .Notch: f.freq = 60; f.q = 4
|
||||
case .AllPass: f.freq = 1000; f.q = 0.71
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func effectivePreamp(_ p: EqProfile) -> Float {
|
||||
p.preamp + (AppGraph.shared.eq.state.antiClip ? EqMath.autoPreampDb(p.chain, manualDb: p.preamp) : 0)
|
||||
}
|
||||
|
||||
// ---- response curve -------------------------------------------------------------------------
|
||||
|
||||
/// The EQ's response, log frequency across, dB up. Left and right are drawn separately when
|
||||
/// some filter applies to only one of them (right in orange). [nodes] are drawn as numbered
|
||||
/// handles; [highlight] gets its own curve in the accent colour.
|
||||
struct EqCurve: View {
|
||||
let chain: [EqFilter]
|
||||
let preampDb: Float
|
||||
let enabled: Bool
|
||||
var range: Float = 18
|
||||
var bandMarkers = false
|
||||
var nodes: [EqFilter] = []
|
||||
var highlight: EqFilter?
|
||||
var labels = false
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
private static let freqs = EqMath.logFreqs(200, lo: F_LO, hi: F_HI)
|
||||
|
||||
var body: some View {
|
||||
let left = EqMath.response(chain, Self.freqs, left: true)
|
||||
let right = chain.contains { $0.channel != .Both } ? EqMath.response(chain, Self.freqs, left: false) : nil
|
||||
let own = highlight.map { EqMath.response([$0], Self.freqs) }
|
||||
Canvas { ctx, size in
|
||||
let r = Double(range)
|
||||
func y(_ d: Double) -> CGFloat { size.height / 2 - CGFloat(d / r) * size.height / 2 }
|
||||
func x(_ f: Double) -> CGFloat { CGFloat(log(f / F_LO) / log(F_HI / F_LO)) * size.width }
|
||||
func hline(_ yy: CGFloat, _ c: Color, _ w: CGFloat) {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: 0, y: yy)); $0.addLine(to: CGPoint(x: size.width, y: yy)) }, with: .color(c), lineWidth: w)
|
||||
}
|
||||
var d = 6.0
|
||||
while d < r { hline(y(d), p.separator, 1); hline(y(-d), p.separator, 1); d += 6 }
|
||||
hline(y(0), p.label3, 1.5)
|
||||
let grid = bandMarkers ? EQ_BANDS.map(Double.init) : [50, 100, 200, 500, 1000, 2000, 5000, 10000]
|
||||
for f in grid {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x(f), y: 0)); $0.addLine(to: CGPoint(x: x(f), y: size.height)) }, with: .color(p.separator), lineWidth: 1)
|
||||
}
|
||||
if labels {
|
||||
for (f, l) in [(100.0, "100"), (1000.0, "1k"), (10000.0, "10k")] {
|
||||
ctx.draw(Text(l).font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: x(f) + 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
ctx.draw(Text("+\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: 1), anchor: .topLeading)
|
||||
ctx.draw(Text("−\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
func curve(_ v: [Double]) -> Path {
|
||||
Path { path in
|
||||
for (i, d) in v.enumerated() {
|
||||
let pt = CGPoint(x: CGFloat(i) / CGFloat(v.count - 1) * size.width, y: y(d))
|
||||
if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) }
|
||||
}
|
||||
}
|
||||
}
|
||||
let round = StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
|
||||
let main = enabled ? p.accent : p.label3
|
||||
if let own {
|
||||
let c = enabled ? main.opacity(0.35) : p.label3
|
||||
var area = curve(own)
|
||||
area.addLine(to: CGPoint(x: size.width, y: y(0)))
|
||||
area.addLine(to: CGPoint(x: 0, y: y(0)))
|
||||
area.closeSubpath()
|
||||
ctx.fill(area, with: .color(c.opacity(0.4)))
|
||||
ctx.stroke(curve(own), with: .color(c), style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
ctx.stroke(curve(left), with: .color(highlight != nil ? p.label : main), style: round)
|
||||
if let right {
|
||||
ctx.stroke(curve(right), with: .color(enabled ? RIGHT : p.label3), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
if preampDb < 0 && highlight == nil {
|
||||
ctx.stroke(curve(left.map { $0 + Double(preampDb) }), with: .color(p.label2),
|
||||
style: StrokeStyle(lineWidth: 1.2, lineCap: .round, dash: [6, 6]))
|
||||
}
|
||||
for (i, f) in nodes.enumerated() {
|
||||
let sel = f.id == highlight?.id
|
||||
let cx = x(min(max(Double(f.freq), F_LO), F_HI))
|
||||
let cy = f.type.hasGain ? y(min(max(Double(f.gain), -r), r)) : y(0)
|
||||
let rad: CGFloat = sel ? 11 : 9
|
||||
let col = !f.on ? p.label3 : f.channel == .Right ? RIGHT : main
|
||||
let dot = Path(ellipseIn: CGRect(x: cx - rad, y: cy - rad, width: rad * 2, height: rad * 2))
|
||||
ctx.fill(dot, with: .color(sel ? .white : col))
|
||||
if sel { ctx.stroke(dot, with: .color(col), lineWidth: 2.5) }
|
||||
ctx.draw(Text("\(i + 1)").font(.caption2.bold()).foregroundStyle(sel ? col : .white), at: CGPoint(x: cx, y: cy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One graphic EQ band: a vertical track with a thumb, −1...1, snapping to 0 near the middle.
|
||||
struct VerticalBandSlider: View {
|
||||
let value: Float
|
||||
let onChange: (Float) -> Void
|
||||
@Environment(\.palette) private var p
|
||||
@State private var start: Float?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let t: CGFloat = 24, w: CGFloat = 4
|
||||
let top = t / 2, bottom = geo.size.height - t / 2, mid = (top + bottom) / 2
|
||||
let yy = mid - (bottom - top) / 2 * CGFloat(value)
|
||||
Canvas { ctx, size in
|
||||
let x = size.width / 2
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: top, width: w, height: bottom - top), cornerRadius: w / 2), with: .color(p.fillStrong))
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: min(yy, mid), width: w, height: abs(yy - mid)), cornerRadius: w / 2), with: .color(p.accent))
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x - 8, y: mid)); $0.addLine(to: CGPoint(x: x + 8, y: mid)) }, with: .color(p.label3), lineWidth: 1)
|
||||
let thumb = Path(ellipseIn: CGRect(x: x - t / 2, y: yy - t / 2, width: t, height: t))
|
||||
ctx.fill(thumb, with: .color(.white))
|
||||
ctx.stroke(thumb, with: .color(.black.opacity(0.12)), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 2)
|
||||
.onChanged { g in
|
||||
let s = start ?? value
|
||||
if start == nil { start = value }
|
||||
var v = min(max(s - 2 * Float(g.translation.height / (geo.size.height - t)), -1), 1)
|
||||
if abs(v) < 0.04 { v = 0 }
|
||||
if v == 0 && value != 0 { UISelectionFeedbackGenerator().selectionChanged() }
|
||||
onChange(v)
|
||||
}
|
||||
.onEnded { _ in start = nil })
|
||||
.onTapGesture(count: 2) { onChange(0) }
|
||||
}
|
||||
.frame(width: 32)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- main EQ screen -------------------------------------------------------------------------
|
||||
|
||||
struct EqScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
let state = eq.state
|
||||
let device = eq.device
|
||||
let linked = eq.profileFor(device)
|
||||
let (icon, bg) = deviceIcon(device)
|
||||
List {
|
||||
Section {
|
||||
toggle("Equalizer", "slider.vertical.3", accentHex, Binding(get: { state.enabled }, set: { eq.setEnabled($0) }))
|
||||
toggle("Anti-Clipping Preamp", "waveform.path.ecg", 0xFF3B30, Binding(get: { state.antiClip }, set: { eq.setAntiClip($0) }))
|
||||
} footer: {
|
||||
Text("Anti-clipping lowers the volume by the curve's highest boost so loud songs don't distort.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { deviceSheet(device) } label: {
|
||||
ValueCell(title: device.label, value: linked?.name ?? "Default", icon: icon, iconBg: bg).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Picker("Mode", selection: Binding(get: { state.automatic }, set: { eq.setAutomatic($0) })) {
|
||||
Text("Automatic").tag(true)
|
||||
Text("Manual").tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented).themedRow()
|
||||
} header: { Text("Output") } footer: {
|
||||
Text(state.automatic ? "Switches profile when you change headphones or speakers." : "Keeps the profile you picked on every output.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
if let pr = eq.active {
|
||||
let preamp = effectivePreamp(pr)
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: state.enabled, range: EQ_MAX_DB + 3, bandMarkers: true)
|
||||
.frame(height: 64)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(EQ_BANDS.indices, id: \.self) { i in
|
||||
let v = pr.gains[i]
|
||||
VStack(spacing: 4) {
|
||||
Text(db(v)).font(.caption2.monospacedDigit()).foregroundStyle(abs(v) > 0.05 ? p.accent : p.label2)
|
||||
.lineLimit(1).fixedSize()
|
||||
VerticalBandSlider(value: v / EQ_MAX_DB) { eq.setGain(pr.id, band: i, db: round1($0 * EQ_MAX_DB)) }
|
||||
.frame(height: 124)
|
||||
Text(EQ_LABELS[i]).font(.caption2).foregroundStyle(p.label2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Preamp").font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
Text(abs(preamp) < 0.05 ? "off" : "\(db(preamp)) dB").font(.subheadline.monospacedDigit()).foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: {
|
||||
Text(pr.name + (state.automatic ? "" : " (manual)"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { profilesSheet(pr, device) } label: {
|
||||
ValueCell(title: "Profile", value: pr.name).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
.onLongPressGesture { profileMenu(pr, device) }
|
||||
let on = pr.filters.filter(\.on).count
|
||||
NavigationLink(value: Route.EqFilters(profileId: pr.id)) {
|
||||
ValueCell(title: "Parametric Filters", value: pr.filters.isEmpty ? "None" : "\(on) on")
|
||||
}
|
||||
.themedRow()
|
||||
Button {
|
||||
ui.overlays.actions("Load Preset", EQ_PRESETS.map { name, gains in SheetAction(name) { eq.setGains(pr.id, gains) } })
|
||||
} label: {
|
||||
ValueCell(title: "Presets", value: EQ_PRESETS.prefix(2).map(\.0).joined(separator: ", ") + "…").contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Equalizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { eq.refreshRoute() }
|
||||
}
|
||||
|
||||
private var accentHex: UInt32 { g.settings.accent.light }
|
||||
|
||||
private func toggle(_ title: String, _ icon: String, _ bg: UInt32, _ on: Binding<Bool>) -> some View {
|
||||
Toggle(isOn: on) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: bg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
|
||||
private func deviceSheet(_ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = [SheetAction("Use Default") {
|
||||
for pr in eq.state.profiles where pr.devices.contains(device) { eq.link(pr.id, device, false) }
|
||||
}]
|
||||
acts += eq.state.profiles.map { pr in SheetAction(pr.name) { eq.link(pr.id, device, true) } }
|
||||
ui.overlays.actions("Profile for \(device.label)", acts)
|
||||
}
|
||||
|
||||
private func profilesSheet(_ current: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = eq.state.profiles.map { pr in
|
||||
let devs = pr.devices.map(\.label).joined(separator: ", ")
|
||||
return SheetAction((pr.id == current.id ? "✓ " : "") + pr.name + (devs.isEmpty ? "" : " · \(devs)")) { eq.select(pr.id) }
|
||||
}
|
||||
acts.append(SheetAction("New Profile…", icon: "plus") {
|
||||
ui.overlays.prompt("New EQ Profile", "Starts from the current curve.", "Create", placeholder: "e.g. Sony WH-1000XM5") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
let made = eq.create(n, from: current)
|
||||
eq.link(made.id, device, true)
|
||||
eq.select(made.id)
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Manage “\(current.name)”…", icon: "ellipsis.circle") { profileMenu(current, device) })
|
||||
ui.overlays.actions("Profiles", acts)
|
||||
}
|
||||
|
||||
private func profileMenu(_ pr: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
let linked = pr.devices.contains(device)
|
||||
var acts = [
|
||||
SheetAction(linked ? "Unlink from \(device.label)" : "Use for \(device.label)", icon: linked ? "link.badge.plus" : "link") {
|
||||
eq.link(pr.id, device, !linked)
|
||||
},
|
||||
]
|
||||
if eq.state.defaultId != pr.id {
|
||||
acts.append(SheetAction("Make Default", icon: "star") { eq.setDefault(pr.id) })
|
||||
}
|
||||
acts.append(SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Profile", nil, initial: pr.name, "Save") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
if !n.isEmpty { eq.rename(pr.id, n) }
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Duplicate", icon: "plus.square.on.square") { eq.select(eq.create("\(pr.name) Copy", from: pr).id) })
|
||||
if eq.state.profiles.count > 1 {
|
||||
acts.append(SheetAction("Delete Profile", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Delete “\(pr.name)”?", "Outputs linked to it go back to the default profile.", "Delete") { eq.delete(pr.id) }
|
||||
})
|
||||
}
|
||||
ui.overlays.actions(pr.name, acts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- filter list ----------------------------------------------------------------------------
|
||||
|
||||
struct EqFiltersScreen: View {
|
||||
let profileId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var curveWidth: CGFloat = 1
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId) {
|
||||
let preamp = effectivePreamp(pr)
|
||||
List {
|
||||
Section {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: eq.state.enabled, nodes: pr.filters, labels: true)
|
||||
.frame(height: 170)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { curveWidth = $0 }
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { o in tap(o, pr) }
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} header: { Text(pr.name) } footer: {
|
||||
Text("Tap a numbered handle to edit that filter. Right-channel filters are orange. Paste an AutoEQ ParametricEQ.txt (from the ••• menu) to load a headphone correction.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
FaderSlider(value: (pr.preamp + EqController.preampRange) / (2 * EqController.preampRange), fill: p.accent, detent: 0.5) { v in
|
||||
eq.setPreamp(profileId, round1((v * 2 - 1) * EqController.preampRange))
|
||||
}
|
||||
ValueLabel(text: signedDb(pr.preamp), width: 76) {
|
||||
ui.overlays.prompt("Preamp", "In dB, −24 to +24.", initial: String(format: "%.1f", pr.preamp), "Set") { s in
|
||||
if let v = parseNumber(s) { eq.setPreamp(profileId, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Preamp") } footer: {
|
||||
if eq.state.antiClip {
|
||||
Text("Anti-clipping adds \(signedDb(preamp - pr.preamp)) on top, so the total is \(signedDb(preamp)).").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(Array(pr.filters.enumerated()), id: \.element.id) { i, f in
|
||||
NavigationLink(value: Route.EqFilterPage(profileId: profileId, filterId: f.id)) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(i + 1). \(f.type.label) · \(hz(f.freq))").foregroundStyle(f.on ? p.label : p.label2)
|
||||
let s = summary(f)
|
||||
if !s.isEmpty { Text(s).font(.subheadline).foregroundStyle(p.label2) }
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(get: { f.on }, set: { on in var nf = f; nf.on = on; eq.putFilter(profileId, nf) }))
|
||||
.labelsHidden().tint(Color(hex: 0x34C759))
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
.contextMenu {
|
||||
Button { var nf = f; nf.id = EqController.newFilterId(); eq.putFilter(profileId, nf) } label: { Label("Duplicate", systemImage: "plus.square.on.square") }
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
Button { add() } label: { Text("Add Filter…").foregroundStyle(p.accent) }.themedRow()
|
||||
} header: { Text("Filters") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button { apoMenu(pr) } label: { Image(systemName: "ellipsis.circle") }
|
||||
Button { add() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func tap(_ o: CGPoint, _ pr: EqProfile) {
|
||||
let w = curveWidth, h: CGFloat = 170
|
||||
let dist: (EqFilter) -> CGFloat = { f in
|
||||
let dx = CGFloat(freqToPos(f.freq)) * w - o.x
|
||||
let dy = (f.type.hasGain ? h / 2 - CGFloat(f.gain) / 18 * h / 2 : h / 2) - o.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
guard let hit = pr.filters.min(by: { dist($0) < dist($1) }),
|
||||
abs(CGFloat(freqToPos(hit.freq)) * w - o.x) < 40 else { return }
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: hit.id))
|
||||
}
|
||||
|
||||
private func add() {
|
||||
let eq = g.eq
|
||||
ui.overlays.actions("Add Filter", FilterType.allCases.map { t in
|
||||
SheetAction(t.label) {
|
||||
let f = newFilter(t)
|
||||
eq.putFilter(profileId, f)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: f.id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func apoMenu(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
var acts = [
|
||||
SheetAction("Paste APO Config", icon: "doc.on.clipboard") { importApo(pr) },
|
||||
SheetAction("Copy as APO Config", icon: "doc.on.doc") {
|
||||
UIPasteboard.general.string = ApoConfig.format(pr)
|
||||
ui.overlays.hud("Copied")
|
||||
},
|
||||
]
|
||||
if !pr.filters.isEmpty {
|
||||
acts.append(SheetAction("Remove All Filters", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Filters?", "The graphic EQ bands stay as they are.", "Remove All") {
|
||||
eq.setFilters(profileId, [], preamp: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.overlays.actions("Equalizer APO / AutoEQ", acts)
|
||||
}
|
||||
|
||||
private func importApo(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
guard let parsed = ApoConfig.parse(UIPasteboard.general.string ?? "") else {
|
||||
ui.overlays.hud("No Equalizer APO filters on the clipboard", "info.circle")
|
||||
return
|
||||
}
|
||||
func plural(_ n: Int, _ w: String) -> String { "\(n) \(w)\(n == 1 ? "" : "s")" }
|
||||
var what: [String] = []
|
||||
if !parsed.filters.isEmpty { what.append(plural(parsed.filters.count, "filter")) }
|
||||
if let pre = parsed.preamp { what.append("preamp \(signedDb(pre))") }
|
||||
let w = what.joined(separator: " and ")
|
||||
let skipped = parsed.skipped > 0 ? " \(plural(parsed.skipped, "line")) it doesn't understand (GraphicEQ, Delay…) will be skipped." : ""
|
||||
ui.overlays.confirm("Replace Filters?", "Loads \(w) into “\(pr.name)”, replacing its \(plural(pr.filters.count, "filter")).\(skipped)", "Replace", destructive: false) {
|
||||
eq.setFilters(profileId, parsed.filters, preamp: parsed.preamp)
|
||||
ui.overlays.hud("Imported \(w)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ValueLabel: View {
|
||||
let text: String
|
||||
let width: CGFloat
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.subheadline.monospacedDigit()).foregroundStyle(p.accent).frame(width: width, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- one filter -----------------------------------------------------------------------------
|
||||
|
||||
struct EqFilterPage: View {
|
||||
let profileId: String
|
||||
let filterId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId), let i = pr.filters.firstIndex(where: { $0.id == filterId }) {
|
||||
let f = pr.filters[i]
|
||||
List {
|
||||
Section {
|
||||
GeometryReader { geo in
|
||||
EqCurve(chain: pr.chain, preampDb: 0, enabled: eq.state.enabled && f.on, nodes: pr.filters, highlight: f, labels: true)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0).onChanged { v in
|
||||
guard let cur = eq.profile(profileId)?.filters.first(where: { $0.id == filterId }) else { return }
|
||||
let pos = Float(min(max(v.location.x / geo.size.width, 0), 1))
|
||||
var nf = cur
|
||||
nf.freq = tidyFreq(posToFreq(pos))
|
||||
if cur.type.hasGain {
|
||||
nf.gain = round1(min(max(18 * (1 - 2 * Float(v.location.y / geo.size.height)), -18), 18))
|
||||
}
|
||||
eq.putFilter(profileId, nf)
|
||||
})
|
||||
}
|
||||
.frame(height: 190)
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasGain ? "Drag the handle: sideways for frequency, up and down for gain." : "Drag the handle sideways to change the frequency.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
ui.overlays.actions("Filter Type", FilterType.allCases.map { t in
|
||||
SheetAction((t == f.type ? "✓ " : "") + t.label) {
|
||||
var nf = f
|
||||
nf.type = t
|
||||
if t == .Notch && f.type != .Notch { nf.q = 4 }
|
||||
put(nf)
|
||||
}
|
||||
})
|
||||
} label: {
|
||||
HStack {
|
||||
ValueCell(title: "Type", value: f.type.label)
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Toggle("On", isOn: Binding(get: { f.on }, set: { var nf = f; nf.on = $0; put(nf) }))
|
||||
.tint(Color(hex: 0x34C759)).foregroundStyle(p.label).themedRow()
|
||||
}
|
||||
|
||||
Section {
|
||||
param("Frequency", hz(f.freq), prompt: { number("Frequency", "In Hz, 10 to 22000.", f.freq) { var nf = f; nf.freq = $0; put(nf) } }) {
|
||||
FaderSlider(value: freqToPos(f.freq), fill: p.accent) { var nf = f; nf.freq = tidyFreq(posToFreq($0)); put(nf) }
|
||||
}
|
||||
if f.type.hasGain {
|
||||
param("Gain", signedDb(f.gain), prompt: { number("Gain", "In dB, −24 to +24.", f.gain) { var nf = f; nf.gain = $0; put(nf) } }) {
|
||||
FaderSlider(value: (f.gain + EqController.filterGain) / (2 * EqController.filterGain), fill: p.accent, detent: 0.5) {
|
||||
var nf = f; nf.gain = round1(($0 * 2 - 1) * EqController.filterGain); put(nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.type.hasSlope || f.slope == 12 {
|
||||
param("Q", String(format: "%.2f", f.q), prompt: { number("Q", "0.1 (broad) to 20 (narrow). One octave is 1.41.", f.q) { var nf = f; nf.q = $0; put(nf) } }) {
|
||||
FaderSlider(value: qToPos(f.q), fill: p.accent) { var nf = f; nf.q = (posToQ($0) * 100).rounded() / 100; put(nf) }
|
||||
}
|
||||
}
|
||||
if f.type.hasSlope {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Slope (dB per octave)").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Slope", selection: Binding(get: { f.slope }, set: { var nf = f; nf.slope = $0; put(nf) })) {
|
||||
ForEach(EQ_SLOPES, id: \.self) { Text("\($0)").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Channel").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Channel", selection: Binding(get: { f.channel }, set: { var nf = f; nf.channel = $0; put(nf) })) {
|
||||
ForEach(EqChannel.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasSlope && f.slope != 12 ? "Steeper than 12 dB/oct is a Butterworth response, so there's no Q."
|
||||
: f.type == .AllPass ? "An all-pass changes phase around its frequency, not level, so the curve stays flat."
|
||||
: "Tap a value to type it.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("\(i + 1). \(f.type.label)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
ui.overlays.actions("\(f.type.label) · \(hz(f.freq))", [
|
||||
SheetAction("Duplicate", icon: "plus.square.on.square") {
|
||||
var copy = f
|
||||
copy.id = EqController.newFilterId()
|
||||
put(copy)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: copy.id))
|
||||
},
|
||||
SheetAction("Delete Filter", icon: "trash", destructive: true) { eq.removeFilter(profileId, f.id) },
|
||||
])
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func put(_ f: EqFilter) { g.eq.putFilter(profileId, f) }
|
||||
|
||||
private func number(_ title: String, _ hint: String, _ initial: Float, _ set: @escaping (Float) -> Void) {
|
||||
ui.overlays.prompt(title, hint, initial: trimmed(initial), "Set") { s in
|
||||
if let v = parseNumber(s) { set(v) }
|
||||
}
|
||||
}
|
||||
|
||||
private func param<S: View>(_ title: String, _ value: String, prompt: @escaping () -> Void, @ViewBuilder slider: () -> S) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text(title).font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
ValueLabel(text: value, width: 110, action: prompt)
|
||||
}
|
||||
slider()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import SwiftUI
|
||||
|
||||
// ---- library home ---------------------------------------------------------------------------
|
||||
|
||||
struct LibraryHome: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty
|
||||
let today = Date()
|
||||
Page(title: "Library") {
|
||||
if !ready {
|
||||
EmptyState(icon: "server.rack", title: "Connect to Plex",
|
||||
message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
}
|
||||
}
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs),
|
||||
]
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { i, r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) }
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "On This Day")
|
||||
Text(today.formatted(.dateTime.month(.wide).day())).font(.subheadline).foregroundStyle(p.label2)
|
||||
.padding(.leading, MARGIN).padding(.bottom, 10)
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
let (onDay, week) = birthdays(list, today)
|
||||
if !onDay.isEmpty {
|
||||
AlbumGrid(albums: onDay.map(\.album), subtitle: { a in
|
||||
onDay.first { $0.album.ratingKey == a.ratingKey }.map { "\(a.artist) · \(yearsAgo($0.years))" }
|
||||
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
} else {
|
||||
Text(week.isEmpty ? "No albums in your library came out this week in any year." : "No album birthdays today. Coming up and just past:")
|
||||
.font(.subheadline).foregroundStyle(p.label2).lineLimit(3)
|
||||
.padding(.horizontal, MARGIN).padding(.bottom, 12)
|
||||
if !week.isEmpty {
|
||||
AlbumGrid(albums: week.map(\.album), subtitle: { a in
|
||||
week.first { $0.album.ratingKey == a.ratingKey }.map { b in
|
||||
let verb = b.date < Calendar.current.startOfDay(for: today) ? "Turned" : "Turns"
|
||||
return "\(verb) \(b.years) · \(b.date.formatted(.dateTime.month(.abbreviated).day()))"
|
||||
}
|
||||
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.musicSection)|\(reload)") {
|
||||
guard ready else { return }
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An album's anniversary: its release day this year, and how old it is then.
|
||||
private struct Birthday { let album: Album; let date: Date; let years: Int }
|
||||
|
||||
private func yearsAgo(_ n: Int) -> String { n == 1 ? "1 year ago" : "\(n) years ago" }
|
||||
|
||||
/// Albums released on today's date in earlier years, and, for slow days, the three days either side.
|
||||
private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday]) {
|
||||
let cal = Calendar(identifier: .gregorian)
|
||||
let today = cal.startOfDay(for: now)
|
||||
let year = cal.component(.year, from: today)
|
||||
let all: [Birthday] = albums.compactMap { a in
|
||||
guard let r = a.released, r.count >= 10 else { return nil }
|
||||
let parts = r.prefix(10).split(separator: "-").compactMap { Int($0) }
|
||||
guard parts.count == 3, parts[0] < year else { return nil }
|
||||
var day = parts[2]
|
||||
// Feb 29 falls back to Feb 28 in other years.
|
||||
if parts[1] == 2, day == 29, cal.range(of: .day, in: .month, for: cal.date(from: DateComponents(year: year, month: 2))!)!.count < 29 { day = 28 }
|
||||
guard let d = cal.date(from: DateComponents(year: year, month: parts[1], day: day)) else { return nil }
|
||||
return Birthday(album: a, date: d, years: year - parts[0])
|
||||
}
|
||||
let onDay = all.filter { $0.date == today }.sorted { $0.years > $1.years }
|
||||
let week = all.filter { b in
|
||||
b.date != today && abs(cal.dateComponents([.day], from: today, to: b.date).day ?? 99) <= 3
|
||||
}.sorted { ($0.date, -$0.years) < ($1.date, -$1.years) }
|
||||
return (onDay, week)
|
||||
}
|
||||
|
||||
private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let last: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
|
||||
Text(title).font(.title3).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 52)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRow(inset: 60, trailing: 16, line: !last)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artists --------------------------------------------------------------------------------
|
||||
|
||||
struct ArtistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var artists: LoadState<[Artist]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
Loaded(state: artists, retry: { reload += 1 }) { list in
|
||||
ForEach(list) { a in
|
||||
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain)
|
||||
.actionsMenu(ui.menu.artist(a))
|
||||
.listRow(inset: MARGIN + 58, trailing: 28)
|
||||
.id(a.ratingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(p.bg)
|
||||
.overlay(alignment: .trailing) {
|
||||
if let list = artists.value, list.count > 30 {
|
||||
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func letterOf(_ s: String) -> Character {
|
||||
var t = s.drop { $0.isWhitespace }
|
||||
if t.hasPrefix("The ") { t = t.dropFirst(4) }
|
||||
guard let ch = t.first?.uppercased().first, ("A"..."Z").contains(ch) else { return "#" }
|
||||
return ch
|
||||
}
|
||||
|
||||
/// UITableView's section index: drag down the letters to jump.
|
||||
private struct SectionIndex: View {
|
||||
let titles: [String]
|
||||
let jump: (Int) -> Void
|
||||
@Environment(\.palette) private var p
|
||||
@State private var last: Character?
|
||||
private static let letters: [Character] = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ#")
|
||||
|
||||
var body: some View {
|
||||
let first: [Character: Int] = titles.enumerated().reduce(into: [:]) { m, e in
|
||||
let l = letterOf(e.element)
|
||||
if m[l] == nil { m[l] = e.offset }
|
||||
}
|
||||
GeometryReader { outer in
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Self.letters, id: \.self) { l in
|
||||
Text(String(l)).font(.caption2.weight(.semibold)).foregroundStyle(p.accent).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 22, height: outer.size.height * 0.62)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0)
|
||||
.onChanged { v in
|
||||
let h = outer.size.height * 0.62
|
||||
let i = min(max(Int(v.location.y / h * CGFloat(Self.letters.count)), 0), Self.letters.count - 1)
|
||||
let l = Self.letters[i]
|
||||
if l == last { return }
|
||||
last = l
|
||||
if let target = Self.letters[i...].lazy.compactMap({ first[$0] }).first { jump(target) }
|
||||
}
|
||||
.onEnded { _ in last = nil })
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(width: 24)
|
||||
.sensoryFeedback(.selection, trigger: last) { _, new in new != nil }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- albums ---------------------------------------------------------------------------------
|
||||
|
||||
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
|
||||
|
||||
struct AlbumsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var sort = 0
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Albums") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
|
||||
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- songs (paged) --------------------------------------------------------------------------
|
||||
|
||||
struct SongsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var tracks: [Track] = []
|
||||
@State private var total = -1
|
||||
@State private var loading = false
|
||||
@State private var error: String?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Songs") {
|
||||
if !tracks.isEmpty {
|
||||
if total > 0 {
|
||||
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
}
|
||||
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
||||
shuffle: { g.playback.playTracks(Array(tracks.prefix(1000)), shuffle: true) })
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) {
|
||||
let from = max(i - 200, 0)
|
||||
g.playback.playTracks(Array(tracks[from..<min(i + 800, tracks.count)]), start: i - from)
|
||||
}
|
||||
.listRow(inset: MARGIN + 60)
|
||||
.onAppear { if i > tracks.count - 40 { loadMore() } }
|
||||
}
|
||||
if loading { LoadingRow() }
|
||||
if let error { ErrorRow(message: error) { loadMore() } }
|
||||
}
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
}
|
||||
|
||||
private func loadMore() {
|
||||
if loading || (total >= 0 && tracks.count >= total) { return }
|
||||
loading = true
|
||||
Task {
|
||||
do {
|
||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
|
||||
tracks += page; total = n; error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- album & artist pages -------------------------------------------------------------------
|
||||
|
||||
struct AlbumScreen: View {
|
||||
let album: Album
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
@State private var tracks: LoadState<[Track]> = .loading
|
||||
@State private var detail: Album?
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let a = detail ?? album
|
||||
let list = tracks.value ?? []
|
||||
let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true }
|
||||
Page(title: a.title, large: false) {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
|
||||
Text(a.title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||
.padding(.horizontal, 32).padding(.top, 16)
|
||||
Button {
|
||||
if let k = a.artistKey { ui.push(.ArtistPage(Artist(ratingKey: k, title: a.artist, sortTitle: a.artist))) }
|
||||
} label: {
|
||||
Text(a.artist).font(.title3).foregroundStyle(p.accent).multilineTextAlignment(.center)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
let meta = [a.genre?.uppercased(), a.year.map(String.init)].compactMap { $0 }.joined(separator: " · ")
|
||||
if !meta.isEmpty { Text(meta).font(.caption.weight(.semibold)).foregroundStyle(p.label2).padding(.top, 4) }
|
||||
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) })
|
||||
.padding(.vertical, 16)
|
||||
p.separator.frame(height: 1 / scale).padding(.leading, MARGIN)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
Loaded(state: tracks, retry: { reload += 1 }) { list in
|
||||
let discs = Set(list.compactMap(\.disc)).count > 1
|
||||
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
|
||||
if discs, let d = t.disc, i == 0 || list[i - 1].disc != d {
|
||||
Text("Disc \(d)").font(.headline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 18).padding(.bottom, 6)
|
||||
}
|
||||
TrackRow(track: t, number: t.index ?? i + 1, showArt: false, subtitle: t.artist != a.artist ? t.artist : nil,
|
||||
menu: ui.menu.track(t, allowGoTo: false)) { g.playback.playTracks(list, start: i) }
|
||||
.listRow(inset: MARGIN + 32)
|
||||
}
|
||||
Text("\(songs(list.count)), \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))")
|
||||
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !list.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if allDownloaded {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) }
|
||||
} else {
|
||||
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
}
|
||||
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
|
||||
MoreMenu(items: ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: reload) {
|
||||
async let d = try? g.plex.album(album.ratingKey)
|
||||
await LoadState.load($tracks) { try await g.plex.albumTracks(album.ratingKey) }
|
||||
if let d = await d ?? nil { detail = d }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistScreen: View {
|
||||
let artist: Artist
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: artist.title) {
|
||||
PlayShuffle(play: { playAll(false) }, shuffle: { playAll(true) }).padding(.top, 6).padding(.bottom, 18)
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list, showArtist: false, menu: { ui.menu.album($0, allowGoTo: false) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { MoreMenu(items: ui.menu.artist(artist)) } }
|
||||
.task(id: reload) {
|
||||
await LoadState.load($albums) { try await g.plex.artistAlbums(artist.ratingKey).sorted { ($0.year ?? 0) > ($1.year ?? 0) } }
|
||||
}
|
||||
}
|
||||
|
||||
private func playAll(_ shuffle: Bool) {
|
||||
Task {
|
||||
do { g.playback.playTracks(try await g.plex.artistTracks(artist.ratingKey), shuffle: shuffle) }
|
||||
catch { ui.overlays.hud("Couldn't load songs", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------
|
||||
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
|
||||
|
||||
/// Downloaded songs gathered back into their albums.
|
||||
private struct DownloadedAlbum {
|
||||
let album: Album
|
||||
let records: [DownloadRecord]
|
||||
let released: String?
|
||||
var added: Int64 { records.map(\.added).max() ?? 0 }
|
||||
var done: Int { records.filter(\.done).count }
|
||||
var failed: Int { records.filter { $0.error != nil }.count }
|
||||
var complete: Bool { done == records.count }
|
||||
var tracks: [Track] {
|
||||
records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) }
|
||||
}
|
||||
}
|
||||
|
||||
private func artistSortKey(_ s: String) -> String {
|
||||
let t = s.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t
|
||||
}
|
||||
|
||||
struct DownloadsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
// Release days come from the album list (served from the HTTP cache when the server's away).
|
||||
@State private var info: [String: Album] = [:]
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let records = g.downloads.records
|
||||
let sort = g.settings.downloadSort
|
||||
let albums = group(records, sort)
|
||||
let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } }
|
||||
let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
|
||||
Page(title: "Downloads") {
|
||||
if albums.isEmpty {
|
||||
EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded",
|
||||
message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.")
|
||||
} else {
|
||||
Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
|
||||
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) })
|
||||
.padding(.bottom, 14)
|
||||
AlbumGrid(albums: albums.map(\.album), subtitle: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return a.artist }
|
||||
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
|
||||
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
|
||||
if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" }
|
||||
return a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return [] }
|
||||
var extra: [SheetAction] = []
|
||||
if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if !d.complete {
|
||||
extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) {
|
||||
g.downloads.remove(d.records.filter { !$0.done }.map(\.track))
|
||||
})
|
||||
}
|
||||
return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra)
|
||||
}, open: { a in
|
||||
guard let d = byKey[a.ratingKey] else { return }
|
||||
if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) }
|
||||
}) { a in
|
||||
if let d = byKey[a.ratingKey], !d.complete {
|
||||
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
|
||||
ZStack {
|
||||
Circle().fill(Color.black.opacity(0.55))
|
||||
if d.failed > 0 {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
|
||||
} else {
|
||||
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !albums.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in
|
||||
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
|
||||
})
|
||||
}
|
||||
MoreMenu(items: moreItems(albums, pinned))
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: g.settings.musicSection) {
|
||||
guard g.plex.signedIn else { return }
|
||||
if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) }
|
||||
}
|
||||
}
|
||||
|
||||
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
|
||||
var a: [SheetAction] = []
|
||||
if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") {
|
||||
g.downloads.remove(g.downloads.records.values.map(\.track))
|
||||
}
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] {
|
||||
let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in
|
||||
let t = rs[0].track
|
||||
let known = info[key]
|
||||
let artists = Set(rs.map(\.track.artist))
|
||||
let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album,
|
||||
artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
|
||||
return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init))
|
||||
}
|
||||
switch sort {
|
||||
case 1:
|
||||
return grouped.sorted {
|
||||
(artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) <
|
||||
(artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased())
|
||||
}
|
||||
case 2:
|
||||
return grouped.sorted { a, b in
|
||||
(a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased()
|
||||
}
|
||||
default:
|
||||
return grouped.sorted { $0.added > $1.added }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum NpPanel { case Art, Mixer, Queue }
|
||||
|
||||
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
|
||||
switch d.kind {
|
||||
case .Speaker: ("iphone", 0x8E8E93)
|
||||
case .Bluetooth: ("headphones", 0x007AFF)
|
||||
case .Wired: ("headphones", 0x34C759)
|
||||
case .Usb: ("cable.connector", 0xFF9500)
|
||||
case .Hdmi: ("airplayaudio", 0x5856D6)
|
||||
case .Car: ("car.fill", 0x34C759)
|
||||
case .Other: ("hifispeaker", 0x8E8E93)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- album colour ---------------------------------------------------------------------------
|
||||
|
||||
/// A solid colour from the album art, pushed dark (or light for the White theme) so text
|
||||
/// stays readable. One flat colour: no gradients.
|
||||
@MainActor
|
||||
private func albumColor(_ thumb: String?, dark: Bool) async -> Color? {
|
||||
guard let url = AppGraph.shared.plex.artUrl(thumb, px: 120) else { return nil }
|
||||
let img: UIImage? = if let c = ArtLoader.shared.cached(url) { c } else { await ArtLoader.shared.image(url) }
|
||||
guard let cg = img?.cgImage else { return nil }
|
||||
return await Task.detached { () -> Color? in
|
||||
guard let (h, s0, v0) = vibrant(cg) else { return nil }
|
||||
let s = dark ? min(s0, 0.65) : min(s0, 0.22)
|
||||
let v = dark ? min(max(v0, 0.18), 0.30) : 0.96
|
||||
return Color(hue: h, saturation: s, brightness: v)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Roughly Palette's vibrant swatch: the average hue of the most saturated, mid-bright pixels.
|
||||
private func vibrant(_ cg: CGImage) -> (CGFloat, CGFloat, CGFloat)? {
|
||||
let n = 24
|
||||
var px = [UInt8](repeating: 0, count: n * n * 4)
|
||||
guard let ctx = CGContext(data: &px, width: n, height: n, bitsPerComponent: 8, bytesPerRow: n * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
|
||||
ctx.draw(cg, in: CGRect(x: 0, y: 0, width: n, height: n))
|
||||
var all: [(CGFloat, CGFloat, CGFloat, CGFloat)] = []
|
||||
for i in 0..<(n * n) {
|
||||
let c = UIColor(red: CGFloat(px[i * 4]) / 255, green: CGFloat(px[i * 4 + 1]) / 255, blue: CGFloat(px[i * 4 + 2]) / 255, alpha: 1)
|
||||
var h: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0, a: CGFloat = 0
|
||||
c.getHue(&h, saturation: &s, brightness: &v, alpha: &a)
|
||||
// Favour colourful pixels that aren't near black or white.
|
||||
let score = s * (1 - abs(v - 0.55) * 1.4)
|
||||
all.append((h, s, v, score))
|
||||
}
|
||||
let top = all.sorted { $0.3 > $1.3 }.prefix(max(1, all.count / 6))
|
||||
// Average hue on the circle so reds either side of 0 don't cancel out.
|
||||
var x: CGFloat = 0, y: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0
|
||||
for p in top { x += cos(p.0 * 2 * .pi); y += sin(p.0 * 2 * .pi); s += p.1; v += p.2 }
|
||||
let k = CGFloat(top.count)
|
||||
var h = atan2(y, x) / (2 * .pi)
|
||||
if h < 0 { h += 1 }
|
||||
return (h, s / k, v / k)
|
||||
}
|
||||
|
||||
private extension Palette {
|
||||
func tinted(_ bg: Color) -> Palette {
|
||||
let dark = UIColor(bg).luminance < 0.5
|
||||
var c = self
|
||||
c.dark = dark
|
||||
c.bg = bg
|
||||
c.groupedBg = bg
|
||||
c.fill = dark ? .white.opacity(0.12) : .black.opacity(0.06)
|
||||
c.fillStrong = dark ? .white.opacity(0.22) : .black.opacity(0.12)
|
||||
c.label = dark ? .white : .black
|
||||
c.label2 = dark ? .white.opacity(0.62) : .black.opacity(0.55)
|
||||
c.label3 = dark ? .white.opacity(0.25) : .black.opacity(0.2)
|
||||
c.separator = dark ? .white.opacity(0.14) : .black.opacity(0.1)
|
||||
c.placeholder = c.fill
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var luminance: CGFloat {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
}
|
||||
|
||||
// ---- now playing ----------------------------------------------------------------------------
|
||||
|
||||
struct NowPlayingScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var base
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var panel = NpPanel.Art
|
||||
@State private var tint: Color?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let c = tint.map { base.tinted($0) } ?? base
|
||||
let track = pm.current?.track
|
||||
VStack(spacing: 0) {
|
||||
if let track {
|
||||
Group {
|
||||
switch panel {
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.18), value: panel)
|
||||
controls(track)
|
||||
} else {
|
||||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.environment(\.palette, c)
|
||||
.preferredColorScheme(c.dark ? .dark : .light)
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.presentationDragIndicator(.visible)
|
||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||
tint = await albumColor(thumb, dark: base.dark)
|
||||
}
|
||||
}
|
||||
|
||||
private func controls(_ track: Track) -> some View {
|
||||
let pm = g.playback
|
||||
let c = tint.map { base.tinted($0) } ?? base
|
||||
return VStack(spacing: 0) {
|
||||
if panel == .Art {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
||||
Text(track.artist).font(.title3).foregroundStyle(c.label2).lineLimit(1)
|
||||
.onTapGesture {
|
||||
if let k = track.artistKey { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: track.artist, sortTitle: track.artist))) }
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
EllipsisButton(items: ui.menu.track(track))
|
||||
}
|
||||
.padding(.top, 14)
|
||||
}
|
||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||
.padding(.top, 18)
|
||||
HStack {
|
||||
Spacer()
|
||||
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
|
||||
Spacer()
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView().controlSize(.large).tint(c.label) }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 48)).foregroundStyle(c.label) }
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Spacer()
|
||||
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 84)
|
||||
HStack {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { panel = panel == .Mixer ? .Art : .Mixer }
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { panel = panel == .Queue ? .Art : .Queue }
|
||||
}
|
||||
.frame(height: 48)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
|
||||
private func routeLabel(_ c: Palette) -> some View {
|
||||
let eq = g.eq
|
||||
let device = eq.device
|
||||
let eqLabel: String = {
|
||||
guard eq.state.enabled, let p = eq.active else { return "" }
|
||||
return p.name.caseInsensitiveCompare(device.label) == .orderedSame ? " · EQ" : " · \(p.name)"
|
||||
}()
|
||||
return HStack(spacing: 6) {
|
||||
Image(systemName: deviceIcon(device).0).font(.system(size: 14))
|
||||
Text(device.label + eqLabel).font(.footnote.weight(.medium)).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(c.label2)
|
||||
}
|
||||
|
||||
private func badge(_ track: Track) -> String? {
|
||||
let pm = g.playback
|
||||
switch pm.source {
|
||||
case .Stems:
|
||||
let q = g.settings.stemQuality
|
||||
return "\(pm.stemNames.count) STEMS · \(g.stems.effectiveQuality(q) == .Lossless ? "FLAC" : "OPUS \(q.kbps)")"
|
||||
case .Original:
|
||||
return ["ORIGINAL", track.codec?.uppercased()].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
|
||||
case .None:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades a little while pressed, like Apple Music's transport.
|
||||
struct PressFade: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label.opacity(configuration.isPressed ? 0.45 : 1).scaleEffect(configuration.isPressed ? 0.92 : 1)
|
||||
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TransportButton: View {
|
||||
let symbol: String
|
||||
let size: CGFloat
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
private struct PanelToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: 19, weight: .medium)).foregroundStyle(on ? p.bg : p.label2)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(on ? p.label : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.sensoryFeedback(.selection, trigger: on)
|
||||
}
|
||||
}
|
||||
|
||||
/// Round ••• button on a fill, opening the media menu.
|
||||
private struct EllipsisButton: View {
|
||||
let items: [SheetAction]
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button { ui.overlays.actions("", items) } label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: 15, weight: .bold)).foregroundStyle(p.label)
|
||||
.frame(width: 32, height: 32).background(p.fill, in: Circle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
|
||||
/// Big art that eases back when paused, like Apple Music.
|
||||
private struct ArtPanel: View {
|
||||
let thumb: String?
|
||||
let playing: Bool
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let side = max(min(geo.size.width, geo.size.height), 1)
|
||||
Artwork(thumb: thumb, size: side, corner: 12)
|
||||
.shadow(color: .black.opacity(playing ? 0.35 : 0.15), radius: playing ? 24 : 10, y: 10)
|
||||
.scaleEffect(playing ? 1 : 0.82)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniHeader: View {
|
||||
let track: Track
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: track.thumb, size: 64, corner: 8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).font(.headline).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
EllipsisButton(items: ui.menu.track(track))
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek / volume bar ----------------------------------------------------------------------
|
||||
|
||||
/// 0..1 loudness per waveform bucket for the current mix: stems weighted by their faders.
|
||||
func envelope(_ w: Waveform?, _ names: [String], _ mix: [String: StemSetting]) -> [Float]? {
|
||||
guard let w else { return nil }
|
||||
if let stems = w.stems, !stems.isEmpty {
|
||||
let anySolo = names.contains { mix[$0]?.solo == true }
|
||||
var gains: [String: Float] = [:]
|
||||
for n in stems.keys {
|
||||
let s = mix[n] ?? StemSetting()
|
||||
gains[n] = (anySolo && !s.solo) || s.muted ? 0 : stemTaper(s.level)
|
||||
}
|
||||
// Scaled to the full mix's range, so muting a stem still shows as quieter bars.
|
||||
return shape(mixDb(stems, gains), mixDb(stems, nil))
|
||||
}
|
||||
if let db = w.loudnessDb { return shape(db, db) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func mixDb(_ stems: [String: [Float]], _ gains: [String: Float]?) -> [Float] {
|
||||
let n = stems.values.map(\.count).max() ?? 0
|
||||
return (0..<n).map { i in
|
||||
var e: Float = 0
|
||||
for (name, arr) in stems {
|
||||
let v = (i < arr.count ? arr[i] : 0) * (gains?[name] ?? 1)
|
||||
e += v * v
|
||||
}
|
||||
return 20 * log10(e.squareRoot() + 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loudness to bar height, relative to the song itself: its loudest moments (99th percentile)
|
||||
/// fill the bar and its quiet tenth sits near the bottom, over 6 to 24 dB, with a curve that
|
||||
/// pulls the middle down. Modern masters barely move in absolute terms, so an absolute scale
|
||||
/// draws nearly every song as a solid block.
|
||||
private func shape(_ db: [Float], _ ref: [Float]) -> [Float] {
|
||||
var audible = ref.filter { $0 > -60 }
|
||||
if audible.isEmpty { audible = ref }
|
||||
audible.sort()
|
||||
if audible.isEmpty { return db.map { _ in 0.28 } }
|
||||
func pct(_ p: Float) -> Float { audible[min(Int(p * Float(audible.count)), audible.count - 1)] }
|
||||
let hi = pct(0.99)
|
||||
let span = min(max(hi - pct(0.10), 6), 24)
|
||||
let lo = hi - span
|
||||
return db.map { 0.05 + 0.95 * pow(min(max(($0 - lo) / span, 0), 1), 1.8) }
|
||||
}
|
||||
|
||||
/// Plexamp's seek bar: the track's loudness as bars, played part highlighted. The bars' height
|
||||
/// follows the master volume, so the volume is drawn over the progress.
|
||||
/// Drag sideways to seek, up/down to change volume, tap to jump.
|
||||
struct ProgressVolumeBar: View {
|
||||
let played: Color
|
||||
let unplayed: Color
|
||||
let centerLabel: String?
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@State private var scrub: Double?
|
||||
@State private var axis = 0 // 1 = seek, 2 = volume
|
||||
@State private var startVol: Float = 0
|
||||
@State private var lastStep = 0
|
||||
@State private var haptic = 0
|
||||
@State private var barWidth: Double = 1
|
||||
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let master = pm.master
|
||||
let env = envelope(pm.waveform, pm.stemNames, pm.mix)
|
||||
let dur = pm.durationMs
|
||||
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
|
||||
let adjusting = axis == 2
|
||||
TimelineView(.animation(paused: !pm.playWhenReady || scrub != nil)) { _ in
|
||||
let pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
|
||||
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
|
||||
VStack(spacing: 4) {
|
||||
Canvas { ctx, size in
|
||||
let barW = 2.7, pitch = 4.6
|
||||
let count = max(8, Int(size.width / pitch))
|
||||
let gap = (size.width - Double(count) * barW) / Double(count - 1)
|
||||
let maxH = size.height, minH = barW, cy = size.height / 2
|
||||
if adjusting {
|
||||
let gh = maxH * volScale / 2
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0, y: cy - gh)); path.addLine(to: CGPoint(x: size.width, y: cy - gh))
|
||||
path.move(to: CGPoint(x: 0, y: cy + gh)); path.addLine(to: CGPoint(x: size.width, y: cy + gh))
|
||||
ctx.stroke(path, with: .color(p.label2.opacity(0.5)), lineWidth: 1)
|
||||
}
|
||||
var playedPath = Path(), restPath = Path()
|
||||
for i in 0..<count {
|
||||
var a: Float = 0.28
|
||||
if let env, !env.isEmpty {
|
||||
let from = i * env.count / count
|
||||
let to = min(max(from + 1, (i + 1) * env.count / count), env.count)
|
||||
a = env[from..<to].max() ?? 0
|
||||
}
|
||||
let h = minH + (maxH - minH) * Double(a) * volScale
|
||||
let x = Double(i) * (barW + gap)
|
||||
let r = CGRect(x: x, y: cy - h / 2, width: barW, height: h)
|
||||
if (x + barW / 2) / size.width <= frac { playedPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
|
||||
else { restPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
|
||||
}
|
||||
ctx.fill(playedPath, with: .color(played))
|
||||
ctx.fill(restPath, with: .color(unplayed))
|
||||
}
|
||||
.frame(height: 56)
|
||||
.onGeometryChange(for: Double.self) { Double($0.size.width) } action: { barWidth = max($0, 1) }
|
||||
.animation(.easeOut(duration: 0.09), value: master)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(drag(dur: dur))
|
||||
ZStack {
|
||||
HStack {
|
||||
Text(formatDuration(pos / 1000 * 1000))
|
||||
Spacer()
|
||||
Text("-" + formatDuration(max(0, dur - pos / 1000 * 1000)))
|
||||
}
|
||||
.font(.caption.weight(.medium).monospacedDigit())
|
||||
if adjusting {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: master <= 0.001 ? "speaker.slash.fill" : "speaker.wave.2.fill").font(.system(size: 11))
|
||||
Text("\(Int(master * 100))%").font(.caption.weight(.semibold).monospacedDigit())
|
||||
}
|
||||
} else if let centerLabel {
|
||||
Text(centerLabel).font(.caption2.bold()).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(p.label2)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: haptic)
|
||||
}
|
||||
|
||||
private func drag(dur: Int64) -> some Gesture {
|
||||
let pm = g.playback
|
||||
return DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { v in
|
||||
let t = v.translation
|
||||
if axis == 0, abs(t.width) > 8 || abs(t.height) > 8 {
|
||||
axis = abs(t.width) >= abs(t.height) ? 1 : 2
|
||||
startVol = pm.master
|
||||
lastStep = Int(startVol * 20)
|
||||
haptic += 1
|
||||
}
|
||||
switch axis {
|
||||
case 1: scrub = width(v) > 0 ? min(max(v.location.x / width(v), 0), 1) : 0
|
||||
case 2:
|
||||
let nv = min(max(startVol - Float(t.height / (56 * 4.5)), 0), 1)
|
||||
let step = Int(nv * 20)
|
||||
if step != lastStep {
|
||||
lastStep = step
|
||||
if nv == 0 || nv == 1 { haptic += 1 }
|
||||
}
|
||||
pm.setMaster(nv)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
.onEnded { v in
|
||||
switch axis {
|
||||
case 0: pm.seekTo(Int64(min(max(v.location.x / width(v), 0), 1) * Double(dur)))
|
||||
case 1: if let scrub { pm.seekTo(Int64(scrub * Double(dur))) }
|
||||
default: break
|
||||
}
|
||||
scrub = nil
|
||||
axis = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func width(_ v: DragGesture.Value) -> Double { barWidth }
|
||||
}
|
||||
|
||||
// ---- stem mixer -----------------------------------------------------------------------------
|
||||
|
||||
/// Meter levels with a quick attack and ~130 ms fall, timed in seconds so they look the same
|
||||
/// at 60 and 120 Hz. A plain class, so stepping it each frame doesn't invalidate views.
|
||||
private final class Meters {
|
||||
var raw = [Float](repeating: 0, count: MeterHistory.maxStems)
|
||||
var shown: [Float] = []
|
||||
var last: Int64 = 0
|
||||
|
||||
func step(_ engine: StemEngine?, _ n: Int) -> [Float] {
|
||||
if shown.count != n { shown = Array(repeating: 0, count: n) }
|
||||
let now = monoNanos()
|
||||
let fall: Float = last == 0 ? 0 : exp(-Float(now - last) / 1e9 / 0.13)
|
||||
last = now
|
||||
let live = engine?.levelsNow(&raw) ?? false
|
||||
for i in 0..<n { shown[i] = max(live && i < raw.count ? raw[i] : 0, shown[i] * fall) }
|
||||
return shown
|
||||
}
|
||||
}
|
||||
|
||||
private struct MixerPanel: View {
|
||||
@Environment(\.palette) private var p
|
||||
@State private var meters = Meters()
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let names = pm.stemNames
|
||||
let stems = pm.source == .Stems
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if pm.source == .Original {
|
||||
Text("No stems for this song yet, so you're hearing the original file. stemd on your server separates new music on its own; once this one's done, the app picks it up within a minute and plays it with stems next time.")
|
||||
.font(.footnote).foregroundStyle(p.label2)
|
||||
.padding(14).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
let current = MixPresets.normalized(pm.mix)
|
||||
ForEach(MixPresets.all) { preset in
|
||||
if let target = preset.build(names) {
|
||||
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
TimelineView(.animation(paused: !stems || !pm.playWhenReady)) { _ in
|
||||
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.separator.frame(height: 0.5).padding(.vertical, 10)
|
||||
HStack {
|
||||
Text("Master").font(.headline).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
|
||||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
||||
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 16)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||
private struct PresetChip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
|
||||
.padding(.horizontal, 14).frame(height: 32)
|
||||
.background(selected ? p.label : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sensoryFeedback(.selection, trigger: selected)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StemFader: View {
|
||||
let name: String
|
||||
let setting: StemSetting
|
||||
let level: Float
|
||||
let enabled: Bool
|
||||
@Environment(\.palette) private var p
|
||||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||||
|
||||
var body: some View {
|
||||
let color = Color(hex: Stems.color(name))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
|
||||
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
|
||||
Spacer()
|
||||
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
|
||||
if enabled {
|
||||
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
||||
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
||||
}
|
||||
}
|
||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||
pm.setStemLevel(name, $0)
|
||||
}
|
||||
// Live level under the fader, -48..0 dBFS.
|
||||
GeometryReader { geo in
|
||||
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(p.fill)
|
||||
Capsule().fill(color).frame(width: geo.size.width * f)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleBox: View {
|
||||
let text: String
|
||||
let on: Bool
|
||||
let onColor: Color
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
|
||||
.frame(width: 30, height: 26)
|
||||
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// An iOS-style slider with a coloured fill and, optionally, a detent that catches the thumb
|
||||
/// near one value (unity gain) with a tick.
|
||||
struct FaderSlider: View {
|
||||
let value: Float
|
||||
var fill: Color
|
||||
var detent: Float?
|
||||
var enabled = true
|
||||
var thumb: CGFloat = 24
|
||||
let onChange: (Float) -> Void
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@State private var start: Float?
|
||||
@State private var caught = false
|
||||
|
||||
init(value: Float, fill: Color, detent: Float? = nil, enabled: Bool = true, thumb: CGFloat = 24, onChange: @escaping (Float) -> Void) {
|
||||
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let track = max(geo.size.width - thumb, 1)
|
||||
let x = CGFloat(min(max(value, 0), 1)) * track
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2)
|
||||
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01)
|
||||
if let detent {
|
||||
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
|
||||
}
|
||||
Circle().fill(.white).frame(width: thumb, height: thumb)
|
||||
.shadow(color: .black.opacity(0.25), radius: 3, y: 1)
|
||||
.offset(x: x)
|
||||
}
|
||||
.frame(height: geo.size.height)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0).onChanged { g in
|
||||
guard enabled else { return }
|
||||
if start == nil { start = value }
|
||||
var v = min(max((start ?? value) + Float(g.translation.width / track), 0), 1)
|
||||
if let detent {
|
||||
let near = abs(v - detent) < 0.025
|
||||
if near { v = detent }
|
||||
if near != caught { caught = near }
|
||||
}
|
||||
onChange(v)
|
||||
}.onEnded { _ in start = nil })
|
||||
}
|
||||
.frame(height: 36)
|
||||
.opacity(enabled ? 1 : 0.4)
|
||||
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- queue ----------------------------------------------------------------------------------
|
||||
|
||||
private struct QueuePanel: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
let first = pm.index + 1
|
||||
let upcoming = Array(pm.queue.dropFirst(max(first, 0)))
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Playing Next").font(.headline).foregroundStyle(p.label)
|
||||
Text(songs(upcoming.count)).font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer()
|
||||
QueueToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
QueueToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||||
if !upcoming.isEmpty {
|
||||
Button("Clear") { pm.clearUpNext() }.foregroundStyle(p.label2).padding(.leading, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24).padding(.vertical, 6)
|
||||
// Hold a row to drag it; swipe for Play Next or Remove.
|
||||
List {
|
||||
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
|
||||
HStack(spacing: 12) {
|
||||
Artwork(thumb: q.track.thumb, size: 44, corner: 5)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(q.track.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Image(systemName: "line.3.horizontal").foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 60)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { pm.skipTo(first + i) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
guard let f = from.first else { return }
|
||||
pm.move(first + f, first + (to > f ? to - 1 : to))
|
||||
}
|
||||
if upcoming.isEmpty {
|
||||
Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.")
|
||||
.font(.subheadline).foregroundStyle(p.label2)
|
||||
.listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueToggle: View {
|
||||
let symbol: String
|
||||
let on: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(on ? p.bg : p.label2)
|
||||
.frame(width: 36, height: 30)
|
||||
.background(on ? p.label : p.fill, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mini player ----------------------------------------------------------------------------
|
||||
|
||||
struct MiniPlayer: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
if let t = pm.current?.track {
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 46, corner: 6).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 24)) }
|
||||
}
|
||||
.foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
Button(action: pm.next) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 22)).foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
.padding(.leading, 16).padding(.trailing, 8)
|
||||
.frame(height: 64)
|
||||
.background(.bar)
|
||||
.overlay(alignment: .top) { p.separator.frame(height: 1 / scale) }
|
||||
.overlay(alignment: .bottom) {
|
||||
// Hairline progress along the bottom edge.
|
||||
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
|
||||
let d = pm.durationMs
|
||||
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
|
||||
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
|
||||
.actionsMenu(ui.menu.track(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import SwiftUI
|
||||
|
||||
// ---- playlists ------------------------------------------------------------------------------
|
||||
|
||||
struct PlaylistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var lists: LoadState<[Playlist]> = .loading
|
||||
@State private var reload = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Playlists") {
|
||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
||||
if list.isEmpty {
|
||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
||||
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
|
||||
}
|
||||
ForEach(list) { pl in
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($lists) { try await g.plex.playlists() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Plex can't hold an empty playlist, so new ones start from songs you have.
|
||||
private func newPlaylist() {
|
||||
let queue = g.playback.queue
|
||||
var acts: [SheetAction] = []
|
||||
if !queue.isEmpty {
|
||||
acts.append(SheetAction("New Playlist from Queue (\(queue.count))") { ui.menu.newPlaylist(queue.map(\.track)) { reload += 1 } })
|
||||
}
|
||||
if let now = g.playback.current?.track {
|
||||
acts.append(SheetAction("New Playlist with “\(now.title)”") { ui.menu.newPlaylist([now]) { reload += 1 } })
|
||||
}
|
||||
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
||||
else { ui.overlays.actions("New Playlist", acts) }
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistRow: View {
|
||||
let playlist: Playlist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "music.note.list")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(songs(playlist.trackCount)).font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if playlist.smart { Tag(text: "SMART") }
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.frame(height: 76)
|
||||
.contentShape(Rectangle())
|
||||
.listRow(inset: 94, trailing: 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaylistScreen: View {
|
||||
let playlist: Playlist
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var tracks: LoadState<[Track]> = .loading
|
||||
@State private var reload = 0
|
||||
@State private var title: String
|
||||
@State private var editing = false
|
||||
@State private var items: [Track] = []
|
||||
@State private var removed: [Track] = []
|
||||
@State private var saving = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
init(playlist: Playlist) {
|
||||
self.playlist = playlist
|
||||
_title = State(initialValue: playlist.title)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let list = tracks.value ?? []
|
||||
Group {
|
||||
if editing {
|
||||
// Native list editing: delete buttons on the left, grips on the right.
|
||||
List {
|
||||
header(list).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||
ForEach(items) { t in
|
||||
TrackRow(track: t) {}.listRowBackground(p.bg)
|
||||
}
|
||||
.onDelete { idx in
|
||||
removed += idx.map { items[$0] }
|
||||
items.remove(atOffsets: idx)
|
||||
}
|
||||
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(p.bg)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
Page(title: title, large: false) {
|
||||
header(list)
|
||||
Loaded(state: tracks, retry: { reload += 1 }) { list in
|
||||
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t, extra: removeAction(t))) { g.playback.playTracks(list, start: i) }
|
||||
.listRow(inset: MARGIN + 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if !playlist.smart && !list.isEmpty {
|
||||
if saving { ProgressView() }
|
||||
else if editing { Button("Done") { commit(list) }.bold() }
|
||||
else { Button("Edit") { items = list; removed = []; editing = true } }
|
||||
}
|
||||
if !editing { MoreMenu(items: moreItems(list)) }
|
||||
}
|
||||
}
|
||||
.task(id: reload) {
|
||||
await LoadState.load($tracks) { try await g.plex.playlistTracks(playlist.ratingKey) }
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ list: [Track]) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: playlist.thumb, size: 230, corner: 10, placeholder: "music.note.list")
|
||||
Text(title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||
.padding(.horizontal, 32).padding(.top, 14)
|
||||
if !list.isEmpty {
|
||||
Text("\(songs(list.count)) · \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))").font(.subheadline).foregroundStyle(p.label2)
|
||||
}
|
||||
if !editing {
|
||||
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) }).padding(.top, 14)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8).padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private func removeAction(_ t: Track) -> [SheetAction] {
|
||||
if playlist.smart { return [] }
|
||||
return [SheetAction("Remove from Playlist", icon: "minus.circle", destructive: true) {
|
||||
Task {
|
||||
if let id = t.playlistItemId { try? await g.plex.removeFromPlaylist(playlist.ratingKey, playlistItemId: id) }
|
||||
reload += 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
private func moreItems(_ list: [Track]) -> [SheetAction] {
|
||||
var extra: [SheetAction] = []
|
||||
let plex = g.plex
|
||||
let key = playlist.ratingKey
|
||||
if !playlist.smart {
|
||||
extra.append(SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Playlist", nil, initial: title, "Save") { name in
|
||||
Task { if (try? await plex.renamePlaylist(key, title: name)) != nil { title = name } }
|
||||
}
|
||||
})
|
||||
}
|
||||
extra.append(SheetAction("Delete Playlist", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Delete “\(title)”?", "This removes it from Plex for every app.", "Delete") {
|
||||
Task { if (try? await plex.deletePlaylist(key)) != nil { ui.pop() } }
|
||||
}
|
||||
})
|
||||
var pl = playlist
|
||||
pl.title = title
|
||||
pl.trackCount = list.count
|
||||
return ui.menu.playlist(pl, extra: extra)
|
||||
}
|
||||
|
||||
/// Sends removals, then replays the new order as "move after" calls against a simulated server list.
|
||||
private func commit(_ original: [Track]) {
|
||||
let target = items
|
||||
let gone = removed
|
||||
let plex = g.plex
|
||||
let key = playlist.ratingKey
|
||||
saving = true
|
||||
Task {
|
||||
do {
|
||||
for t in gone { if let id = t.playlistItemId { try await plex.removeFromPlaylist(key, playlistItemId: id) } }
|
||||
let goneIds = Set(gone.compactMap(\.playlistItemId))
|
||||
var sim = original.filter { !goneIds.contains($0.playlistItemId ?? -1) }
|
||||
for (i, t) in target.enumerated() {
|
||||
guard let id = t.playlistItemId, i >= sim.count || sim[i].playlistItemId != id else { continue }
|
||||
try await plex.movePlaylistItem(key, playlistItemId: id, afterItemId: i == 0 ? nil : target[i - 1].playlistItemId)
|
||||
sim.removeAll { $0.playlistItemId == id }
|
||||
sim.insert(t, at: min(i, sim.count))
|
||||
}
|
||||
} catch {
|
||||
ui.overlays.hud("Couldn't save: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
reload += 1
|
||||
saving = false
|
||||
editing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- search ---------------------------------------------------------------------------------
|
||||
|
||||
struct SearchScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var query = ""
|
||||
@State private var results: LoadState<SearchResults>?
|
||||
@State private var retry = 0
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
Page(title: "Search") {
|
||||
if let results {
|
||||
Loaded(state: results, retry: { retry += 1 }) { s in
|
||||
if s.isEmpty { EmptyState(icon: "magnifyingglass", title: "No Results", message: "Nothing matches “\(q)”.") }
|
||||
if !s.artists.isEmpty {
|
||||
SectionTitle(title: "Artists")
|
||||
ForEach(s.artists) { a in
|
||||
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
|
||||
.buttonStyle(.plain).actionsMenu(ui.menu.artist(a)).listRow(inset: MARGIN + 58, trailing: 16)
|
||||
}
|
||||
}
|
||||
if !s.albums.isEmpty {
|
||||
SectionTitle(title: "Albums")
|
||||
AlbumGrid(albums: s.albums, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
|
||||
}
|
||||
if !s.tracks.isEmpty {
|
||||
SectionTitle(title: "Songs")
|
||||
ForEach(Array(s.tracks.enumerated()), id: \.element.id) { i, t in
|
||||
TrackRow(track: t, subtitle: "\(t.artist) — \(t.album)", menu: ui.menu.track(t)) { g.playback.playTracks(s.tracks, start: i) }
|
||||
.listRow(inset: MARGIN + 60)
|
||||
}
|
||||
}
|
||||
if !s.playlists.isEmpty {
|
||||
SectionTitle(title: "Playlists")
|
||||
ForEach(s.playlists) { pl in
|
||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "magnifyingglass", title: "Search Your Library", message: "Find artists, albums, songs and playlists on your Plex server.")
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Albums, Songs")
|
||||
.autocorrectionDisabled()
|
||||
.task(id: "\(q)|\(retry)") {
|
||||
guard q.count >= 2 else { results = nil; return }
|
||||
try? await Task.sleep(nanoseconds: 280_000_000)
|
||||
if Task.isCancelled { return }
|
||||
results = .loading
|
||||
do { results = .ok(try await g.plex.search(q)) }
|
||||
catch is CancellationError {}
|
||||
catch { results = .err(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
||||
private struct NavCell: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var icon: String?
|
||||
var iconBg: UInt32 = 0x8E8E93
|
||||
let route: Route
|
||||
var body: some View {
|
||||
NavigationLink(value: route) { ValueCell(title: title, value: value, icon: icon, iconBg: iconBg) }.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleCell: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let iconBg: UInt32
|
||||
let isOn: Binding<Bool>
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Toggle(isOn: isOn) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: iconBg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row in the accent (or red), optionally centred.
|
||||
private struct ActionCell: View {
|
||||
let title: String
|
||||
var color: Color?
|
||||
var centered = false
|
||||
var enabled = true
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title).foregroundStyle(enabled ? color ?? p.accent : p.label3)
|
||||
.frame(maxWidth: .infinity, alignment: centered ? .center : .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Label on the left, an inline text field on the right.
|
||||
private struct FieldCell: View {
|
||||
let label: String
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
var secret = false
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(p.label).frame(width: 96, alignment: .leading)
|
||||
Group {
|
||||
if secret { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text).keyboardType(.URL) }
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundStyle(p.label2)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
private struct Header: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
}
|
||||
|
||||
private struct Footer: View {
|
||||
let text: String
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View { Text(text).foregroundStyle(p.label2) }
|
||||
}
|
||||
|
||||
private func clean(_ url: String) -> String {
|
||||
var u = url.trimmingCharacters(in: .whitespaces)
|
||||
while u.hasSuffix("/") { u.removeLast() }
|
||||
return u
|
||||
}
|
||||
|
||||
// ---- settings -------------------------------------------------------------------------------
|
||||
|
||||
struct SettingsScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let status = g.stems.status
|
||||
List {
|
||||
// Everything on one screen: no footers but the last, one line each.
|
||||
Section {
|
||||
NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle),
|
||||
icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount)
|
||||
NavCell(title: "Stem Server", value: {
|
||||
switch status {
|
||||
case .online(let n, _): "\(n) songs"
|
||||
case .offline: "Offline"
|
||||
case .unknown: "…"
|
||||
}
|
||||
}(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer)
|
||||
} header: { Header(text: "Servers") }
|
||||
Section {
|
||||
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
} header: { Header(text: "Storage") }
|
||||
Section {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} header: { Header(text: "Display") } footer: {
|
||||
Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plex -----------------------------------------------------------------------------------
|
||||
|
||||
struct PlexAccountScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var url = AppGraph.shared.settings.plexUrl
|
||||
@State private var manual = ""
|
||||
@State private var pinCode: String?
|
||||
@State private var pinId: Int64 = 0
|
||||
@State private var pinError: String?
|
||||
@State private var sections: [MusicSection]?
|
||||
@State private var testing = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400")
|
||||
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
||||
} header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") }
|
||||
if s.plexToken.isEmpty {
|
||||
Section {
|
||||
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
||||
if let pinCode {
|
||||
VStack(spacing: 0) {
|
||||
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
||||
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Sign In") } footer: {
|
||||
if let f = pinError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
||||
}
|
||||
Section {
|
||||
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
||||
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { useToken() }
|
||||
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
||||
} else {
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "person.fill", bg: 0xE5A00D)
|
||||
Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label)
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Header(text: "Account") }
|
||||
Section {
|
||||
if let list = sections {
|
||||
if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() }
|
||||
ForEach(list) { sec in
|
||||
Button {
|
||||
s.musicSection = sec.key
|
||||
s.musicSectionTitle = sec.title
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sec.title).foregroundStyle(p.label)
|
||||
if !sec.locations.isEmpty {
|
||||
Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
}
|
||||
} else {
|
||||
ProgressView().frame(maxWidth: .infinity).themedRow()
|
||||
}
|
||||
} header: { Header(text: "Music Library") }
|
||||
Section {
|
||||
ActionCell(title: "Sign Out", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") {
|
||||
s.plexToken = ""
|
||||
s.plexUser = ""
|
||||
s.musicSection = ""
|
||||
s.musicSectionTitle = ""
|
||||
sections = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Plex")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: s.plexToken) {
|
||||
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
|
||||
}
|
||||
// Poll plex.tv while a PIN is showing.
|
||||
.task(id: pinId) {
|
||||
guard pinId != 0 else { return }
|
||||
for _ in 0..<300 {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
if let t = (try? await g.plex.checkPin(pinId))?.authToken, !t.isEmpty {
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
await finish(t)
|
||||
ui.overlays.hud("Signed In")
|
||||
return
|
||||
}
|
||||
}
|
||||
pinCode = nil
|
||||
pinId = 0
|
||||
pinError = "The code expired. Try again."
|
||||
}
|
||||
}
|
||||
|
||||
private func test() {
|
||||
let s = g.settings
|
||||
testing = true
|
||||
Task {
|
||||
let ok = await g.plex.probe(url, token: s.plexToken)
|
||||
testing = false
|
||||
if ok {
|
||||
s.plexUrl = clean(url)
|
||||
ui.overlays.hud("Connected")
|
||||
} else {
|
||||
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func newPin() {
|
||||
pinError = nil
|
||||
Task {
|
||||
do {
|
||||
let pin = try await g.plex.createPin()
|
||||
pinCode = pin.code
|
||||
pinId = pin.id
|
||||
} catch {
|
||||
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useToken() {
|
||||
let t = manual.trimmingCharacters(in: .whitespaces)
|
||||
Task {
|
||||
if await g.plex.probe(url, token: t) { await finish(t) }
|
||||
else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ token: String) async {
|
||||
let s = g.settings
|
||||
s.plexUrl = clean(url)
|
||||
s.plexToken = token
|
||||
s.plexUser = await {
|
||||
guard let u = try? await g.plex.user(token: token) else { return "Plex user" }
|
||||
return u.title ?? u.username ?? "Plex user"
|
||||
}()
|
||||
s.plexMachineId = (try? await g.plex.machineId()) ?? ""
|
||||
sections = try? await g.plex.musicSections()
|
||||
if let only = sections, only.count == 1 {
|
||||
s.musicSection = only[0].key
|
||||
s.musicSectionTitle = only[0].title
|
||||
}
|
||||
await g.stems.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stem server ----------------------------------------------------------------------------
|
||||
|
||||
struct StemServerScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var url = AppGraph.shared.settings.stemUrl
|
||||
@State private var token = AppGraph.shared.settings.stemToken
|
||||
@State private var busy = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let status = g.stems.status
|
||||
List {
|
||||
Section {
|
||||
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:8765")
|
||||
FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true)
|
||||
ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() }
|
||||
} header: { Header(text: "stemserve") } footer: {
|
||||
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.")
|
||||
}
|
||||
Section {
|
||||
ValueCell(title: "Connection", value: {
|
||||
switch status {
|
||||
case .online: "Online"
|
||||
case .offline: "Offline"
|
||||
case .unknown: "Checking"
|
||||
}
|
||||
}()).themedRow()
|
||||
ValueCell(title: "Songs with Stems", value: {
|
||||
switch status {
|
||||
case .online(let n, _): "\(n)"
|
||||
case .offline(_, let n): "\(n) (cached)"
|
||||
case .unknown: "—"
|
||||
}
|
||||
}()).themedRow()
|
||||
ValueCell(title: "Opus Transcoding", value: {
|
||||
if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" }
|
||||
return "—"
|
||||
}()).themedRow()
|
||||
if case .offline(let reason, _) = status {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Error").foregroundStyle(p.label)
|
||||
Text(reason).font(.footnote).foregroundStyle(p.label2)
|
||||
}
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Status") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Stem Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
let s = g.settings
|
||||
s.stemUrl = clean(url)
|
||||
s.stemToken = token.trimmingCharacters(in: .whitespaces)
|
||||
busy = true
|
||||
Task {
|
||||
await g.stems.refresh(force: true)
|
||||
busy = false
|
||||
let ok = g.stems.status.isOnline
|
||||
ui.overlays.hud(ok ? "Connected" : "Couldn't connect", ok ? "checkmark" : "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- quality --------------------------------------------------------------------------------
|
||||
|
||||
/// A row with a title, grey detail and a checkmark when chosen.
|
||||
private struct CheckCell: View {
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
let checked: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).foregroundStyle(p.label)
|
||||
if let subtitle { Text(subtitle).font(.footnote).foregroundStyle(p.label2) }
|
||||
}
|
||||
Spacer()
|
||||
if checked { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityScreen: View {
|
||||
private var g: AppGraph { .shared }
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
List {
|
||||
Section {
|
||||
ForEach(StemQuality.allCases) { q in
|
||||
CheckCell(title: q.label, subtitle: q.detail, checked: s.stemQuality == q) { s.stemQuality = q }
|
||||
}
|
||||
} footer: {
|
||||
Footer(text: "For streaming and new downloads. Opus is made once on the server and kept there; 256 kbps is about an eighth of the FLAC size.")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Stem Quality")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- storage --------------------------------------------------------------------------------
|
||||
|
||||
struct StorageScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
private let limits = [2, 5, 10, 20, 50, 100]
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let usage = g.cache.usage
|
||||
List {
|
||||
Section {
|
||||
Button { ui.selectTab(.Downloads) } label: {
|
||||
HStack {
|
||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedRow()
|
||||
ValueCell(title: "Streaming Cache", value: gb(max(0, usage.cachedBytes - usage.pinnedBytes))).themedRow()
|
||||
} header: { Header(text: "On This Phone") }
|
||||
Section {
|
||||
Picker("Limit", selection: $s.cacheLimitGb) {
|
||||
ForEach(limits, id: \.self) { Text("\($0) GB").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Streaming Cache Limit") } footer: {
|
||||
Footer(text: "Streamed songs stay for replay and offline use, oldest first out. Downloads don't count.")
|
||||
}
|
||||
Section {
|
||||
ActionCell(title: "Clear Streaming Cache", color: p.red, centered: true) {
|
||||
ui.overlays.confirm("Clear Cache?", "Downloads are kept.", "Clear") { g.cache.clearUnpinned() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Storage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- appearance -----------------------------------------------------------------------------
|
||||
|
||||
struct AppearanceScreen: View {
|
||||
@Environment(\.palette) private var c
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
List {
|
||||
Section {
|
||||
HStack {
|
||||
ForEach(ThemeMode.allCases) { m in
|
||||
let p = Palette.of(m, s.accent)
|
||||
let on = m == s.theme
|
||||
Button { s.theme = m } label: {
|
||||
VStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Capsule().fill(p.label).frame(width: 40, height: 6)
|
||||
ForEach(0..<3, id: \.self) { _ in RoundedRectangle(cornerRadius: 4).fill(p.cell).frame(height: 18) }
|
||||
Capsule().fill(p.accent).frame(width: 24, height: 6)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(8)
|
||||
.frame(width: 78, height: 140)
|
||||
.background(p.groupedBg, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(on ? c.accent : c.separator, lineWidth: on ? 3 : 1))
|
||||
Text(m.label).font(.subheadline.weight(on ? .semibold : .regular)).foregroundStyle(c.label)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: { Header(text: "Theme") } footer: { Footer(text: "Black is true black for OLED. No gradients anywhere, by design.") }
|
||||
Section {
|
||||
HStack {
|
||||
ForEach(Accent.allCases) { a in
|
||||
Button { s.accent = a } label: {
|
||||
Circle().fill(Color(hex: c.dark ? a.dark : a.light)).frame(width: 32, height: 32)
|
||||
.overlay(Circle().stroke(a == s.accent ? c.label : .clear, lineWidth: 3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(a.label)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: { Header(text: "Accent") }
|
||||
Section {
|
||||
Toggle(isOn: $s.albumColorNowPlaying) { Text("Album Colour Background").foregroundStyle(c.label) }
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Appearance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, alpha: Double = 1) {
|
||||
self.init(.sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255, opacity: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
/// The three themes' colours. Black is true black for OLED; no gradients anywhere.
|
||||
struct Palette {
|
||||
var dark: Bool
|
||||
var bg: Color
|
||||
var groupedBg: Color
|
||||
var cell: Color
|
||||
var fill: Color
|
||||
var fillStrong: Color
|
||||
var label: Color
|
||||
var label2: Color
|
||||
var label3: Color
|
||||
var separator: Color
|
||||
var placeholder: Color
|
||||
var accent: Color
|
||||
var red: Color
|
||||
|
||||
static func of(_ mode: ThemeMode, _ accent: Accent) -> Palette {
|
||||
switch mode {
|
||||
case .Black:
|
||||
Palette(dark: true, bg: Color(hex: 0x000000), groupedBg: Color(hex: 0x000000), cell: Color(hex: 0x1C1C1E),
|
||||
fill: Color(hex: 0x1C1C1E), fillStrong: Color(hex: 0x3A3A3C), label: .white, label2: Color(hex: 0x8D8D93),
|
||||
label3: Color(hex: 0x48484A), separator: Color(hex: 0x2C2C2E), placeholder: Color(hex: 0x1C1C1E),
|
||||
accent: Color(hex: accent.dark), red: Color(hex: 0xFF453A))
|
||||
case .Dark:
|
||||
Palette(dark: true, bg: Color(hex: 0x1C1C1E), groupedBg: Color(hex: 0x1C1C1E), cell: Color(hex: 0x2C2C2E),
|
||||
fill: Color(hex: 0x2C2C2E), fillStrong: Color(hex: 0x48484A), label: .white, label2: Color(hex: 0x98989D),
|
||||
label3: Color(hex: 0x5A5A5E), separator: Color(hex: 0x3D3D40), placeholder: Color(hex: 0x3A3A3C),
|
||||
accent: Color(hex: accent.dark), red: Color(hex: 0xFF453A))
|
||||
case .White:
|
||||
Palette(dark: false, bg: Color(hex: 0xFFFFFF), groupedBg: Color(hex: 0xF2F2F7), cell: Color(hex: 0xFFFFFF),
|
||||
fill: Color(hex: 0xEFEFF0), fillStrong: Color(hex: 0xE3E3E8), label: .black, label2: Color(hex: 0x8A8A8E),
|
||||
label3: Color(hex: 0xC4C4C7), separator: Color(hex: 0xD8D8DC), placeholder: Color(hex: 0xE5E5EA),
|
||||
accent: Color(hex: accent.light), red: Color(hex: 0xFF3B30))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PaletteKey: EnvironmentKey {
|
||||
static let defaultValue = Palette.of(.Black, .Music)
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var palette: Palette {
|
||||
get { self[PaletteKey.self] }
|
||||
set { self[PaletteKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// A list on the theme's background instead of the system one.
|
||||
func themedList(grouped: Bool = true) -> some View { modifier(ThemedList(grouped: grouped)) }
|
||||
|
||||
/// Rows on the theme's cell colour.
|
||||
func themedRow(grouped: Bool = true) -> some View { modifier(ThemedRow(grouped: grouped)) }
|
||||
}
|
||||
|
||||
private struct ThemedList: ViewModifier {
|
||||
@Environment(\.palette) private var p
|
||||
let grouped: Bool
|
||||
func body(content: Content) -> some View {
|
||||
content.scrollContentBackground(.hidden).background(grouped ? p.groupedBg : p.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThemedRow: ViewModifier {
|
||||
@Environment(\.palette) private var p
|
||||
let grouped: Bool
|
||||
func body(content: Content) -> some View {
|
||||
content.listRowBackground(grouped ? p.cell : p.bg).listRowSeparatorTint(p.separator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Stemify
|
||||
options:
|
||||
bundleIdPrefix: com.stemify
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources: [Stemify]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.stemify.mobile
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
|
||||
entitlements:
|
||||
path: Stemify/Stemify.entitlements
|
||||
properties:
|
||||
com.apple.developer.carplay-audio: true
|
||||
com.apple.developer.siri: true
|
||||
info:
|
||||
path: Stemify/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Stemify
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
UILaunchScreen: {}
|
||||
UIBackgroundModes: [audio, fetch]
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||
CADisableMinimumFrameDurationOnPhone: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
||||
NSSiriUsageDescription: Ask Siri to play music from your Plex library.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoads: true
|
||||
NSAllowsLocalNetworking: true
|
||||
NSUserActivityTypes: [INPlayMediaIntent]
|
||||
INIntentsSupported: [INPlayMediaIntent]
|
||||
INSupportedMediaCategories: [INMediaCategoryMusic]
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: true
|
||||
UISceneConfigurations:
|
||||
UIWindowSceneSessionRoleApplication:
|
||||
- UISceneConfigurationName: Phone
|
||||
CPTemplateApplicationSceneSessionRoleApplication:
|
||||
- UISceneConfigurationName: CarPlay
|
||||
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlayScene
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Builds an unsigned Stemify.ipa on a Mac, for re-signing with Sideloadly/AltStore.
|
||||
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH=/opt/homebrew/bin:$PATH
|
||||
OUT=build; APP=$OUT/Payload/Stemify.app
|
||||
rm -rf $OUT && mkdir -p $APP
|
||||
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
|
||||
|
||||
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \
|
||||
-module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify
|
||||
|
||||
# Info.plist: xcodegen's generated one with the build settings filled in.
|
||||
xcodegen generate -q
|
||||
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/com.stemify.mobile/' \
|
||||
-e 's/$(PRODUCT_NAME)/Stemify/; s/$(MARKETING_VERSION)/1.0/; s/$(CURRENT_PROJECT_VERSION)/1/' \
|
||||
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
|
||||
P=$APP/Info.plist
|
||||
plutil -insert MinimumOSVersion -string 17.0 $P
|
||||
plutil -insert CFBundleSupportedPlatforms -json '["iPhoneOS"]' $P
|
||||
plutil -insert UIDeviceFamily -json '[1]' $P
|
||||
plutil -insert UIRequiredDeviceCapabilities -json '["arm64"]' $P
|
||||
plutil -insert DTPlatformName -string iphoneos $P
|
||||
plutil -insert CFBundleIcons -json '{"CFBundlePrimaryIcon":{"CFBundleIconFiles":["AppIcon60x60"]}}' $P
|
||||
plutil -lint $P
|
||||
printf 'APPL????' > $APP/PkgInfo
|
||||
|
||||
# Loose icon files, since actool also needs -runFirstLaunch.
|
||||
SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
|
||||
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
|
||||
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
|
||||
|
||||
(cd $OUT && zip -qry Stemify.ipa Payload)
|
||||
echo "$OUT/Stemify.ipa"
|
||||
Reference in new issue
Block a user