diff --git a/.gitignore b/.gitignore index 762fb91..1f73f98 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 9e55105..2102342 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/ios/Stemify/App/AppGraph.swift b/ios/Stemify/App/AppGraph.swift new file mode 100644 index 0000000..9c4c18f --- /dev/null +++ b/ios/Stemify/App/AppGraph.swift @@ -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) + } +} diff --git a/ios/Stemify/App/SiriIntents.swift b/ios/Stemify/App/SiriIntents.swift new file mode 100644 index 0000000..9d839ac --- /dev/null +++ b/ios/Stemify/App/SiriIntents.swift @@ -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 + } + } +} diff --git a/ios/Stemify/App/StemifyApp.swift b/ios/Stemify/App/StemifyApp.swift new file mode 100644 index 0000000..65ec8f0 --- /dev/null +++ b/ios/Stemify/App/StemifyApp.swift @@ -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 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() } + } + } +} diff --git a/ios/Stemify/Cache/ArtLoader.swift b/ios/Stemify/Cache/ArtLoader.swift new file mode 100644 index 0000000..5101562 --- /dev/null +++ b/ios/Stemify/Cache/ArtLoader.swift @@ -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() + private let session: URLSession + private let lock = NSLock() + private var inflight: [URL: Task] = [:] + + 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 { [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) } + } +} diff --git a/ios/Stemify/Cache/Downloads.swift b/ios/Stemify/Cache/Downloads.swift new file mode 100644 index 0000000..a1fc474 --- /dev/null +++ b/ios/Stemify/Cache/Downloads.swift @@ -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() + @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].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 + } +} diff --git a/ios/Stemify/Cache/MediaCache.swift b/ios/Stemify/Cache/MediaCache.swift new file mode 100644 index 0000000..9e5e3ea --- /dev/null +++ b/ios/Stemify/Cache/MediaCache.swift @@ -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? + 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? + 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 { + 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) 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() + } +} diff --git a/ios/Stemify/CarPlay/CarLibrary.swift b/ios/Stemify/CarPlay/CarLibrary.swift new file mode 100644 index 0000000..d2387e7 --- /dev/null +++ b/ios/Stemify/CarPlay/CarLibrary.swift @@ -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() + 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() + 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)) + } +} diff --git a/ios/Stemify/CarPlay/CarPlayScene.swift b/ios/Stemify/CarPlay/CarPlayScene.swift new file mode 100644 index 0000000..c9f378b --- /dev/null +++ b/ios/Stemify/CarPlay/CarPlayScene.swift @@ -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? + + 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 + } +} diff --git a/ios/Stemify/CarPlay/CarTree.swift b/ios/Stemify/CarPlay/CarTree.swift new file mode 100644 index 0000000..a6ace3d --- /dev/null +++ b/ios/Stemify/CarPlay/CarTree.swift @@ -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[.. (String, String)? { + guard let c = id.firstIndex(of: ":"), c != id.startIndex else { return nil } + return (String(id[.. 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(_ 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)) + } +} diff --git a/ios/Stemify/CarPlay/HomeReach.swift b/ios/Stemify/CarPlay/HomeReach.swift new file mode 100644 index 0000000..9c38121 --- /dev/null +++ b/ios/Stemify/CarPlay/HomeReach.swift @@ -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 + } +} diff --git a/ios/Stemify/Core/Net.swift b/ios/Stemify/Core/Net.swift new file mode 100644 index 0000000..171bf5c --- /dev/null +++ b/ios/Stemify/Core/Net.swift @@ -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) } +} diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift new file mode 100644 index 0000000..7149713 --- /dev/null +++ b/ios/Stemify/Core/Settings.swift @@ -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(_ kp: KeyPath, _ key: String, _ def: T) -> T { + access(keyPath: kp) + lock.lock() + defer { lock.unlock() } + return store[key] as? T ?? def + } + + private func write(_ kp: KeyPath, _ 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(_ kp: KeyPath, _ 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(_ kp: KeyPath, _ 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 +} diff --git a/ios/Stemify/EQ/ApoConfig.swift b/ios/Stemify/EQ/ApoConfig.swift new file mode 100644 index 0000000..b98757a --- /dev/null +++ b/ios/Stemify/EQ/ApoConfig.swift @@ -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[.. 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 + } +} diff --git a/ios/Stemify/EQ/EqController.swift b/ios/Stemify/EQ/EqController.swift new file mode 100644 index 0000000..875eabf --- /dev/null +++ b/ios/Stemify/EQ/EqController.swift @@ -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 + } +} diff --git a/ios/Stemify/Engine/Dsp.swift b/ios/Stemify/Engine/Dsp.swift new file mode 100644 index 0000000..ee42c58 --- /dev/null +++ b/ios/Stemify/Engine/Dsp.swift @@ -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, _ frames: Int, left: Bool = true, right: Bool = true) { + var l1 = zl1, l2 = zl2, r1 = zr1, r2 = zr2 + for i in 0.. [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.. 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, _ 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, _ frames: Int) { + var g = gain + for i in 0.. 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) } diff --git a/ios/Stemify/Engine/StemDecoder.swift b/ios/Stemify/Engine/StemDecoder.swift new file mode 100644 index 0000000..d782cfe --- /dev/null +++ b/ios/Stemify/Engine/StemDecoder.swift @@ -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 + 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(_ 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 + 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, _ 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, _ 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.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.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.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.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.size) + if ExtAudioFileGetProperty(ext, kExtAudioFileProperty_AudioConverter, &sz, &conv) == noErr, let conv { + var map: [Int32] = [0, 1] + AudioConverterSetProperty(conv, kAudioConverterChannelMap, UInt32(MemoryLayout.size * 2), &map) + var nul: Unmanaged? + ExtAudioFileSetProperty(ext, kExtAudioFileProperty_ConverterConfig, UInt32(MemoryLayout?>.size), &nul) + } + } + state.with { + _sampleRate = rate + _durationUs = frames > 0 ? frames * 1_000_000 / Int64(rate) : 0 + } + markPrepared() + + let chunk = 4096 + let scratch = UnsafeMutablePointer.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 } + } + } +} diff --git a/ios/Stemify/Engine/StemEngine.swift b/ios/Stemify/Engine/StemEngine.swift new file mode 100644 index 0000000..5b50ca6 --- /dev/null +++ b/ios/Stemify/Engine/StemEngine.swift @@ -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, _ stems: Int) { + lock.with { + tokens[head] = token + starts[head] = start + let base = head * Self.maxStems + for k in 0.. 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.. + 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, _ ts: UnsafePointer, _ frames: AVAudioFrameCount, _ abl: UnsafeMutablePointer) -> 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.. 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.allocate(capacity: chunk * 2) + private let tmp = UnsafeMutablePointer.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.., _ 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.. 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() } } + } +} diff --git a/ios/Stemify/Playback/MixPresets.swift b/ios/Stemify/Playback/MixPresets.swift new file mode 100644 index 0000000..f06020c --- /dev/null +++ b/ios/Stemify/Playback/MixPresets.swift @@ -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 + } +} diff --git a/ios/Stemify/Playback/NowPlaying.swift b/ios/Stemify/Playback/NowPlaying.swift new file mode 100644 index 0000000..578688c --- /dev/null +++ b/ios/Stemify/Playback/NowPlaying.swift @@ -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 + } +} diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift new file mode 100644 index 0000000..de970e2 --- /dev/null +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -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? + @ObservationIgnored private var nextTask: Task? + @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? + + 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 + } +} diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift new file mode 100644 index 0000000..5a174be --- /dev/null +++ b/ios/Stemify/Plex/PlexClient.swift @@ -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//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) + } +} diff --git a/ios/Stemify/Plex/PlexModels.swift b/ios/Stemify/Plex/PlexModels.swift new file mode 100644 index 0000000..ae386fc --- /dev/null +++ b/ios/Stemify/Plex/PlexModels.swift @@ -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(_ k: String) -> [T] { + (try? decodeIfPresent([Lenient].self, forKey: AnyKey(k)))?.compactMap(\.value) ?? [] + } +} + +/// Drops a single bad array element instead of the array. +struct Lenient: 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) + } +} diff --git a/ios/Stemify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Stemify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..88940a2 --- /dev/null +++ b/ios/Stemify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1 @@ +{"colors":[{"idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7c85906 --- /dev/null +++ b/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}} diff --git a/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png b/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000..1e4ce50 Binary files /dev/null and b/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/ios/Stemify/Resources/Assets.xcassets/Contents.json b/ios/Stemify/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/ios/Stemify/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/ios/Stemify/Stems/StemCoverage.swift b/ios/Stemify/Stems/StemCoverage.swift new file mode 100644 index 0000000..4a3e5b9 --- /dev/null +++ b/ios/Stemify/Stems/StemCoverage.swift @@ -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 + } + } + } +} diff --git a/ios/Stemify/Stems/StemIndex.swift b/ios/Stemify/Stems/StemIndex.swift new file mode 100644 index 0000000..39d0d5f --- /dev/null +++ b/ios/Stemify/Stems/StemIndex.swift @@ -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[.. 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..= 2 + if deep && durationMatches(hit, durationMs) { + let root = parts[0.. 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[.. 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() + 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] = [] + + func run(_ 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() + } +} diff --git a/ios/Stemify/UI/Components/Chrome.swift b/ios/Stemify/UI/Components/Chrome.swift new file mode 100644 index 0000000..28fb4bf --- /dev/null +++ b/ios/Stemify/UI/Components/Chrome.swift @@ -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 { + 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>, _ 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: View { + let state: LoadState + 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: 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) } + } + } +} diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift new file mode 100644 index 0000000..7bd2df0 --- /dev/null +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -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) } +} diff --git a/ios/Stemify/UI/Components/Overlays.swift b/ios/Stemify/UI/Components/Overlays.swift new file mode 100644 index 0000000..d4fb348 --- /dev/null +++ b/ios/Stemify/UI/Components/Overlays.swift @@ -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? + + 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(_ kp: ReferenceWritableKeyPath) -> Binding { + 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()) + } + } +} diff --git a/ios/Stemify/UI/Components/Rows.swift b/ios/Stemify/UI/Components/Rows.swift new file mode 100644 index 0000000..f5369d7 --- /dev/null +++ b/ios/Stemify/UI/Components/Rows.swift @@ -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: 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: 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) + } +} diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift new file mode 100644 index 0000000..b70ea12 --- /dev/null +++ b/ios/Stemify/UI/Nav.swift @@ -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) } + } +} diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift new file mode 100644 index 0000000..ab73598 --- /dev/null +++ b/ios/Stemify/UI/RootView.swift @@ -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) + } + } +} diff --git a/ios/Stemify/UI/Screens/EqScreens.swift b/ios/Stemify/UI/Screens/EqScreens.swift new file mode 100644 index 0000000..7834520 --- /dev/null +++ b/ios/Stemify/UI/Screens/EqScreens.swift @@ -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) -> 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(_ 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() + } +} diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift new file mode 100644 index 0000000..37bffb7 --- /dev/null +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -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.. 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 } + } + } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift new file mode 100644 index 0000000..3688a91 --- /dev/null +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -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.. [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.. 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.. 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)) + } + } +} diff --git a/ios/Stemify/UI/Screens/PlaylistScreens.swift b/ios/Stemify/UI/Screens/PlaylistScreens.swift new file mode 100644 index 0000000..86a2c9b --- /dev/null +++ b/ios/Stemify/UI/Screens/PlaylistScreens.swift @@ -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? + @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) } + } + } +} diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift new file mode 100644 index 0000000..459bc9d --- /dev/null +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -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 + @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) + } +} diff --git a/ios/Stemify/UI/Theme.swift b/ios/Stemify/UI/Theme.swift new file mode 100644 index 0000000..e9a6f7c --- /dev/null +++ b/ios/Stemify/UI/Theme.swift @@ -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) + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..e156d24 --- /dev/null +++ b/ios/project.yml @@ -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 diff --git a/ios/scripts/build-ipa.sh b/ios/scripts/build-ipa.sh new file mode 100755 index 0000000..b95a110 --- /dev/null +++ b/ios/scripts/build-ipa.sh @@ -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"