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:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+6
View File
@@ -16,3 +16,9 @@ android/captures/
# Built and third-party APKs (publish builds as GitHub Releases instead)
*.apk
*.aab
# iOS
ios/build/
ios/Stemify.xcodeproj/
ios/Stemify/Info.plist
ios/Stemify/Stemify.entitlements
+18
View File
@@ -1,3 +1,5 @@
### ⚠️ THIS PROJECT WAS ENTIRELY VIBECODED AND IS SLOP. DO NOT PROCEED IF YOU DO NOT ALIGN WITH THESE VALUES. DO NOT COMPLAIN ABOUT IT IN PULL RELEASES, ISSUES, ETC. UNLESS THERE ARE SEVERE SECURITY RISKS. THEY WILL BE REMOVED. PROCEED AT YOUR OWN RISK! ⚠️
# Stemify
**Your whole music library, taken apart and put back together however you like.**
@@ -25,6 +27,7 @@ pressing the "mute vocals" button at exactly the wrong moment.
- [Stem Now: skipping the line](#stem-now-skipping-the-line)
- [The Equalizer](#the-equalizer)
- [Android Auto](#android-auto)
- [iOS and CarPlay](#ios-and-carplay)
- [The desktop apps](#the-desktop-apps)
- [Separation models](#separation-models)
- [How the pieces find each other](#how-the-pieces-find-each-other)
@@ -356,6 +359,21 @@ adb forward tcp:5277 tcp:5277
---
## iOS and CarPlay
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
```
cd ios
scripts/build-ipa.sh # writes build/Stemify.ipa
```
Sideload `build/Stemify.ipa` with Sideloadly or AltStore, which sign it with your Apple ID. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
CarPlay and Siri need the `carplay-audio` and `siri` entitlements. A free Apple ID can't sign those, so on a free sideload the app works on the phone but won't show up in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team.
## The desktop apps
### Stemify (the separator)
+68
View File
@@ -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)
}
}
+33
View File
@@ -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
}
}
}
+42
View File
@@ -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() }
}
}
}
+48
View File
@@ -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) }
}
}
+230
View File
@@ -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
}
}
+572
View File
@@ -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()
}
}
+286
View File
@@ -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))
}
}
+259
View File
@@ -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
}
}
+88
View File
@@ -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))
}
}
+73
View File
@@ -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
}
}
+121
View File
@@ -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) }
}
+195
View File
@@ -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
}
+126
View File
@@ -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
}
}
+316
View File
@@ -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
}
}
+291
View File
@@ -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) }
+379
View File
@@ -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 }
}
}
}
+670
View File
@@ -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() } }
}
}
+50
View File
@@ -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
}
}
+133
View File
@@ -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
}
}
+791
View File
@@ -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
}
}
+284
View File
@@ -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)
}
}
+411
View File
@@ -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}}
+98
View File
@@ -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
}
}
}
}
+133
View File
@@ -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()
}
}
+195
View File
@@ -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()
}
}
+332
View File
@@ -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) }
}
}
}
+181
View File
@@ -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) }
}
+164
View File
@@ -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())
}
}
}
+140
View File
@@ -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)
}
}
+65
View File
@@ -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) }
}
}
+70
View File
@@ -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)
}
}
}
+670
View File
@@ -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()
}
}
+510
View File
@@ -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)
}
}
+80
View File
@@ -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)
}
}
+53
View File
@@ -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
+35
View File
@@ -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"