Add iOS app with CarPlay and Siri

SwiftUI port of the Android app under ios/, with CarPlay browse and
Now Playing, Siri play-media intents, and a build-ipa.sh script that
makes an unsigned IPA for sideloading. README gets an iOS section and
the vibecoded disclaimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+791
View File
@@ -0,0 +1,791 @@
import AVFoundation
import Foundation
/// Display order and colours for stems, matching the desktop app.
enum Stems {
static let order = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
private static let colors: [String: UInt32] = [
"vocals": 0xFF7AB6, "drums": 0xFFB547, "bass": 0x7C8CFF, "other": 0x4CD07D,
"guitar": 0xFF8A5C, "piano": 0xB48CFF, "instrumental": 0x3DD6C6, "mix": 0x8E8E93,
]
static func color(_ name: String) -> UInt32 { colors[name] ?? 0x8E8E93 }
static func label(_ name: String) -> String { name == "mix" ? "Original Mix" : name.prefix(1).uppercased() + name.dropFirst() }
static func sort(_ names: [String]) -> [String] {
names.enumerated().sorted { a, b in
let ia = order.firstIndex(of: a.element) ?? 99, ib = order.firstIndex(of: b.element) ?? 99
return ia != ib ? ia < ib : a.offset < b.offset
}.map(\.element)
}
}
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
struct StemSetting: Codable, Hashable {
static let unity: Float = 0.8
var level: Float = StemSetting.unity
var muted = false
var solo = false
init(level: Float = StemSetting.unity, muted: Bool = false, solo: Bool = false) {
self.level = level; self.muted = muted; self.solo = solo
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
level = c.double("level").map(Float.init) ?? Self.unity
muted = c.bool("muted") ?? false
solo = c.bool("solo") ?? false
}
}
enum RepeatMode: String, Codable, CaseIterable { case Off, All, One }
enum SourceKind { case None, Stems, Original }
struct QueueItem: Codable, Hashable, Identifiable {
var uid: Int64
var track: Track
var id: Int64 { uid }
}
/// Waveform data for the seek bar: per-stem envelopes from stemserve, or Plex loudness in dB.
struct Waveform {
var stems: [String: [Float]]?
var loudnessDb: [Float]?
}
private struct SavedQueue: Codable {
var items: [QueueItem] = []
var unshuffled: [QueueItem]?
var index = -1
var positionMs: Int64 = 0
var repeatMode: RepeatMode = .Off
var mix: [String: StemSetting] = [:]
init(items: [QueueItem], unshuffled: [QueueItem]?, index: Int, positionMs: Int64, repeatMode: RepeatMode, mix: [String: StemSetting]) {
self.items = items; self.unshuffled = unshuffled; self.index = index
self.positionMs = positionMs; self.repeatMode = repeatMode; self.mix = mix
}
enum CodingKeys: String, CodingKey { case items, unshuffled, index, positionMs, repeatMode = "repeat", mix }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
items = (try? c.decode([QueueItem].self, forKey: .items)) ?? []
unshuffled = try? c.decode([QueueItem].self, forKey: .unshuffled)
index = (try? c.decode(Int.self, forKey: .index)) ?? -1
positionMs = (try? c.decode(Int64.self, forKey: .positionMs)) ?? 0
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off
mix = (try? c.decode([String: StemSetting].self, forKey: .mix)) ?? [:]
}
}
/// A toast for the UI; [id] changes even when the text repeats.
struct AppMessage: Equatable {
let id = UUID()
let text: String
}
/// Owns the queue and the [StemEngine]. Everything public is main-thread only; the UI reads the
/// observable state, and NowPlaying mirrors it into the lock screen and CarPlay.
@Observable
final class PlaybackManager: StemEngineListener, @unchecked Sendable {
static let ringFrames = StemEngine.ringSeconds * 48_000
static let waveBuckets = 256
static func stemKey(_ entry: StemEntry, _ file: String, _ size: Int64, _ q: StemQuality) -> String {
"stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)"
}
static func plexKey(_ track: Track) -> String {
"plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
}
@ObservationIgnored private let settings: Settings
@ObservationIgnored private let plex: PlexClient
@ObservationIgnored private let stems: StemServer
@ObservationIgnored private let cache: MediaCache
@ObservationIgnored private let eq: EqController
@ObservationIgnored private(set) var engine: StemEngine!
@ObservationIgnored private let queueFile: URL
private(set) var queue: [QueueItem] = []
private(set) var index = -1
private(set) var current: QueueItem?
private(set) var playWhenReady = false
private(set) var buffering = false
private(set) var shuffle = false
private(set) var repeatMode = RepeatMode.Off
private(set) var source = SourceKind.None
private(set) var stemNames: [String] = []
private(set) var mix: [String: StemSetting] = [:]
private(set) var master: Float
private(set) var waveform: Waveform?
private(set) var durationMs: Int64 = 0
private(set) var message: AppMessage?
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
@ObservationIgnored private var unshuffled: [QueueItem]?
@ObservationIgnored private var nextUid: Int64 = 1
@ObservationIgnored private let tokenLock = NSLock()
@ObservationIgnored private var tokens: Int64 = 0
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var nextTask: Task<Void, Never>?
@ObservationIgnored private var currentToken: Int64 = -1
@ObservationIgnored private var nextToken: Int64 = -1
@ObservationIgnored private var nextIndex = -1
@ObservationIgnored private var nextEntry: StemEntry?
@ObservationIgnored private var currentEntry: StemEntry?
@ObservationIgnored private var pendingPositionMs: Int64 = 0
@ObservationIgnored private var failures = 0
@ObservationIgnored private var scrobbled: Int64 = -1
@ObservationIgnored private var resumeAfterInterruption = false
@ObservationIgnored private var saveTask: Task<Void, Never>?
init(settings: Settings, plex: PlexClient, stems: StemServer, cache: MediaCache, eq: EqController, dir: URL) {
self.settings = settings
self.plex = plex
self.stems = stems
self.cache = cache
self.eq = eq
queueFile = dir.appendingPathComponent("queue.json")
master = settings.masterVolume
engine = StemEngine(listener: self)
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
engine.eqParams = eq.currentParams()
engine.masterGain = masterTaper(master)
restore()
observeSession()
Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard let self else { return }
if self.playWhenReady { self.reportTimeline("playing") }
}
}
}
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
func report(_ text: String) {
message = AppMessage(text: text)
messageListeners.values.forEach { $0(text) }
}
@discardableResult func addListener(_ f: @escaping () -> Void) -> UUID {
let id = UUID()
listeners[id] = f
return id
}
func removeListener(_ id: UUID) { listeners[id] = nil; messageListeners[id] = nil }
@discardableResult func addMessageListener(_ f: @escaping (String) -> Void) -> UUID {
let id = UUID()
messageListeners[id] = f
return id
}
private func changed() { listeners.values.forEach { $0() } }
func positionMs() -> Int64 {
let snap = engine.snapshot
let pos = currentToken >= 0 && snap.token == currentToken ? snap.positionMs() : pendingPositionMs
let d = durationMs
return d > 0 ? min(max(pos, 0), d) : max(pos, 0)
}
var isPlaying: Bool { playWhenReady }
// ---- queue --------------------------------------------------------------------------
private func items(_ tracks: [Track]) -> [QueueItem] {
tracks.map { t in
defer { nextUid += 1 }
return QueueItem(uid: nextUid, track: t)
}
}
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
if tracks.isEmpty { return }
let items = items(tracks)
if doShuffle {
unshuffled = items
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
shuffle = true
load(0, 0, true)
} else {
unshuffled = nil
shuffle = false
queue = items
load(min(max(start, 0), items.count - 1), 0, true)
}
}
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
private func insert(_ at: Int, _ tracks: [Track]) {
if tracks.isEmpty { return }
let items = items(tracks)
var q = queue
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
queue = q
unshuffled = unshuffled.map { $0 + items }
if index < 0 {
load(0, 0, true)
} else {
requeueNext()
changed()
save()
}
}
func removeAt(_ i: Int) {
var q = queue
guard q.indices.contains(i) else { return }
let removed = q.remove(at: i)
unshuffled = unshuffled?.filter { $0.uid != removed.uid }
queue = q
let idx = index
if q.isEmpty {
stop()
} else if i == idx {
load(min(i, q.count - 1), 0, playWhenReady)
} else if i < idx {
index = idx - 1
}
requeueNext()
changed()
save()
}
func move(_ from: Int, _ to: Int) {
var q = queue
guard q.indices.contains(from), q.indices.contains(to), from != to else { return }
q.insert(q.remove(at: from), at: to)
queue = q
let idx = index
if from == idx { index = to }
else if from < idx && to >= idx { index = idx - 1 }
else if from > idx && to <= idx { index = idx + 1 }
requeueNext()
changed()
save()
}
func clearUpNext() {
guard index >= 0 else { return }
queue = Array(queue.prefix(index + 1))
unshuffled = nil
shuffle = false
requeueNext()
changed()
save()
}
func toggleShuffle() {
let q = queue
let idx = index
let cur = q.indices.contains(idx) ? q[idx] : nil
if !shuffle {
unshuffled = q
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
shuffle = true
} else {
let orig = unshuffled ?? q
queue = orig
index = max(orig.firstIndex { $0.uid == cur?.uid } ?? 0, 0)
unshuffled = nil
shuffle = false
}
requeueNext()
changed()
save()
}
func cycleRepeat() {
let all = RepeatMode.allCases
repeatMode = all[(all.firstIndex(of: repeatMode)! + 1) % all.count]
requeueNext()
changed()
save()
}
func setRepeat(_ mode: RepeatMode) {
repeatMode = mode
requeueNext()
changed()
}
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
// ---- transport ----------------------------------------------------------------------
func play() {
if queue.isEmpty { return }
if !activateSession() { return }
playWhenReady = true
if currentToken < 0 && loadTask == nil {
load(max(index, 0), pendingPositionMs, true)
return
}
engine.setPlaying(true)
reportTimeline("playing")
changed()
}
func pause() {
playWhenReady = false
engine.setPlaying(false)
pendingPositionMs = positionMs()
reportTimeline("paused")
changed()
save()
}
func togglePlay() { if playWhenReady { pause() } else { play() } }
func next() {
if queue.isEmpty { return }
let i = index + 1
if i < queue.count { load(i, 0, playWhenReady) }
else if repeatMode != .Off { load(0, 0, playWhenReady) }
else { pause(); seekTo(0) }
}
func previous() {
if positionMs() > 3000 || index <= 0 {
seekTo(0)
return
}
load(index - 1, 0, playWhenReady)
}
func skipTo(_ i: Int, positionMs: Int64 = 0) {
if queue.indices.contains(i) { load(i, positionMs, true) }
}
func seekTo(_ ms: Int64) {
pendingPositionMs = max(ms, 0)
if currentToken >= 0 { engine.seekTo(pendingPositionMs) }
changed()
}
func stop() {
loadTask?.cancel()
loadTask = nil
nextTask?.cancel()
engine.stop()
reportTimeline("stopped")
currentToken = -1
nextToken = -1
queue = []
index = -1
current = nil
playWhenReady = false
source = .None
stemNames = []
waveform = nil
deactivateSession()
changed()
save()
}
// ---- mixer --------------------------------------------------------------------------
func setStemLevel(_ name: String, _ level: Float) { editMix(name) { $0.level = min(max(level, 0), 1) } }
func toggleMute(_ name: String) { editMix(name) { $0.muted.toggle() } }
func toggleSolo(_ name: String) { editMix(name) { $0.solo.toggle() } }
func resetMix() {
mix = [:]
pushMix()
}
/// Karaoke and friends: set every stem at once.
func applyMix(_ m: [String: StemSetting]) {
mix = m
pushMix()
}
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
var s = mix[name] ?? StemSetting()
f(&s)
mix[name] = s
pushMix()
}
private func pushMix() {
let m = mix
let anySolo = stemNames.contains { m[$0]?.solo == true }
var gains: [String: Float] = [:]
for n in stemNames {
let s = m[n] ?? StemSetting()
gains[n] = anySolo && !s.solo ? 0 : s.muted ? 0 : stemTaper(s.level)
}
engine.setMix(gains)
// Now Playing shows the mix (artist line, Vocals/Mix buttons), so it needs to hear about it.
changed()
save()
}
/// True when [track] would play without the home network: its stems (or, lacking stems, its file) are fully cached.
func playableOffline(_ track: Track) -> Bool {
if let entry = stems.find(track) {
return StemQuality.allCases.contains { q in entry.stems.allSatisfy { cache.isComplete(Self.stemKey(entry, $0.file, $0.size, q)) } }
}
return track.partKey != nil && cache.isComplete(Self.plexKey(track))
}
func hasStems(_ track: Track) -> Bool { stems.find(track) != nil }
func setMaster(_ v: Float) {
master = min(max(v, 0), 1)
engine.masterGain = masterTaper(master)
settings.masterVolume = master
}
// ---- loading ------------------------------------------------------------------------
private func nextTokenValue() -> Int64 { tokenLock.withLock { tokens += 1; return tokens } }
private func load(_ i: Int, _ positionMs: Int64, _ play: Bool) {
guard queue.indices.contains(i) else { return }
let item = queue[i]
loadTask?.cancel()
nextTask?.cancel()
nextToken = -1
engine.queueNext(nil)
index = i
current = item
pendingPositionMs = positionMs
durationMs = item.track.durationMs
waveform = nil
currentToken = -1
if play && !activateSession() { return }
playWhenReady = play
buffering = true
if !settings.keepStemMix { mix = [:] }
changed()
save()
loadTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
let (s, entry) = try await self.openDetached(item.track, positionMs)
if Task.isCancelled {
s.release()
return
}
self.loadTask = nil
self.currentToken = s.token
self.currentEntry = entry
self.applySession(s, entry)
self.engine.load(s, play: self.playWhenReady)
self.failures = 0
self.scrobbled = -1
self.reportTimeline(self.playWhenReady ? "playing" : "paused")
self.loadWaveform(item.track, entry)
self.prepareNext()
} catch {
if Task.isCancelled { return }
self.loadTask = nil
self.buffering = false
self.report("Couldn't play “\(item.track.title)”: \(error.localizedDescription)")
self.failures += 1
if self.failures < 3 && i + 1 < self.queue.count {
self.load(i + 1, 0, play)
} else {
self.failures = 0
self.playWhenReady = false
}
self.changed()
}
}
}
private func applySession(_ s: TrackSession, _ entry: StemEntry?) {
source = entry != nil ? .Stems : .Original
stemNames = s.names
let frames = s.durationFrames
if frames > 0 && s.sampleRate > 0 { durationMs = frames * 1000 / Int64(s.sampleRate) }
pushMix()
changed()
}
private func openDetached(_ track: Track, _ startMs: Int64) async throws -> (TrackSession, StemEntry?) {
try await withCheckedThrowingContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async { [self] in
cont.resume(with: Result { try open(track, startMs) })
}
}
}
/// Opens every stem (or the original file) and waits for the decoders to know their format.
private func open(_ track: Track, _ startMs: Int64) throws -> (TrackSession, StemEntry?) {
let entry = stems.find(track)
var sources: [(name: String, key: String, url: URL?, hint: String)] = []
if let entry {
let q = qualityFor(entry)
stems.prepare(entry, q)
for name in Stems.sort(entry.stems.map(\.name)) {
guard let f = entry.stems.first(where: { $0.name == name }) else { continue }
let hint = q.fmt == "opus" ? "opus" : (f.file as NSString).pathExtension
sources.append((name, Self.stemKey(entry, f.file, f.size, q), stems.fileUrl(entry, f, q), hint))
}
} else {
guard let part = track.partKey else { throw PlainError("Plex has no file for this track") }
let hint = track.partFile.map { ($0 as NSString).pathExtension } ?? track.codec ?? ""
sources.append(("mix", Self.plexKey(track), plex.partUrl(part), hint))
}
if sources.isEmpty { throw PlainError("No stems listed for this track") }
let decoders = sources.map { src -> StemDecoder in
let res = cache.acquire(src.key) { src.url }
return StemDecoder(name: src.name, source: ResourceDataSource(res) { [cache] in cache.release(res) },
ringFrames: Self.ringFrames, hint: src.hint)
}
do {
decoders.forEach { $0.start(gen: 0, frame: 0) }
for d in decoders where !d.awaitPrepared(timeoutMs: 45_000) {
throw PlainError(d.error?.localizedDescription ?? "Timed out opening \(d.name)")
}
let rate = decoders[0].sampleRate
if decoders.contains(where: { $0.sampleRate != rate }) { throw PlainError("Stems have mixed sample rates") }
let s = TrackSession(token: nextTokenValue(), names: decoders.map(\.name), decoders: decoders)
if startMs > 0 { s.startAt(startMs * Int64(rate) / 1000) }
return (s, entry)
} catch {
decoders.forEach { $0.release() }
throw error
}
}
/// Plays a downloaded copy if there is one, whatever quality it was saved at.
private func qualityFor(_ entry: StemEntry) -> StemQuality {
let want = stems.effectiveQuality(settings.stemQuality)
guard let f = entry.stems.first else { return want }
if cache.isComplete(Self.stemKey(entry, f.file, f.size, want)) { return want }
return StemQuality.allCases.first { cache.isComplete(Self.stemKey(entry, f.file, f.size, $0)) } ?? want
}
/// Builds the next track's session now so the switch can be gapless.
private func prepareNext() {
nextTask?.cancel()
nextToken = -1
engine.queueNext(nil)
guard let i = upcomingIndex() else { return }
let item = queue[i]
nextTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 4_000_000_000) // let the current track's stems get a head start
guard let self, !Task.isCancelled else { return }
// The normal load path reports failures.
guard let (s, entry) = try? await self.openDetached(item.track, 0) else { return }
if Task.isCancelled {
s.release()
return
}
self.nextToken = s.token
self.nextIndex = i
self.nextEntry = entry
self.engine.queueNext(s)
}
}
private func requeueNext() {
if currentToken >= 0 { prepareNext() }
}
private func upcomingIndex() -> Int? {
let q = queue
let i = index
if q.isEmpty || i < 0 { return nil }
if repeatMode == .One { return i }
if i + 1 < q.count { return i + 1 }
if repeatMode == .All { return 0 }
return nil
}
private func loadWaveform(_ track: Track, _ entry: StemEntry?) {
let uid = current?.uid
Task { @MainActor [weak self] in
guard let self else { return }
var w: Waveform?
if let entry {
w = await self.stems.peaks(entry, buckets: Self.waveBuckets).map { Waveform(stems: $0, loudnessDb: nil) }
} else if let l = try? await self.plex.loudness(track.ratingKey, samples: Self.waveBuckets) {
w = Waveform(stems: nil, loudnessDb: l)
}
if self.current?.uid == uid { self.waveform = w }
}
}
// ---- engine callbacks (main thread) ---------------------------------------------------
func onEngineState() {
buffering = engine.buffering || loadTask != nil
changed()
}
func onTransition(_ token: Int64) {
if token == currentToken || token != nextToken { return }
scrobble()
currentToken = token
nextToken = -1
index = nextIndex
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
currentEntry = nextEntry
pendingPositionMs = 0
scrobbled = -1
guard let item = current else { return }
durationMs = item.track.durationMs
waveform = nil
if !settings.keepStemMix { mix = [:] }
source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix()
loadWaveform(item.track, currentEntry)
reportTimeline("playing")
prepareNext()
changed()
save()
}
func onEnded(_ token: Int64) {
if token != currentToken { return }
scrobble()
currentToken = -1
// Normally the next song was queued for a gapless switch; if preparing it failed, try again now.
if let i = upcomingIndex() {
load(i, 0, true)
return
}
playWhenReady = false
pendingPositionMs = 0
reportTimeline("stopped")
changed()
save()
}
func onError(_ token: Int64, _ msg: String) {
if token != currentToken { return }
report("Playback stopped: \(msg)")
currentToken = -1
let i = index
failures += 1
if i + 1 < queue.count && failures < 3 {
load(i + 1, 0, playWhenReady)
} else {
playWhenReady = false
changed()
}
}
private func scrobble() {
guard let item = current, scrobbled != item.uid else { return }
let d = durationMs
if d > 0 && Double(positionMs()) < Double(d) * 0.5 && engine.snapshot.token == currentToken { return }
scrobbled = item.uid
Task { [plex] in await plex.scrobble(item.track.ratingKey) }
}
private func reportTimeline(_ state: String) {
guard let t = current?.track else { return }
let pos = positionMs()
if !plex.signedIn { return }
Task { [plex] in await plex.timeline(t, state: state, positionMs: pos) }
if state == "playing" && durationMs > 0 && Double(pos) > Double(durationMs) * 0.9 { scrobble() }
}
// ---- audio session --------------------------------------------------------------------
private func activateSession() -> Bool {
let s = AVAudioSession.sharedInstance()
do {
try s.setCategory(.playback, mode: .default, policy: .longFormAudio)
try s.setActive(true)
return true
} catch {
report("Couldn't start audio: \(error.localizedDescription)")
return false
}
}
private func deactivateSession() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func observeSession() {
let nc = NotificationCenter.default
nc.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] n in
guard let self, let info = n.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
switch type {
case .began:
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
if (info[AVAudioSessionInterruptionReasonKey] as? UInt) == 1 /* appWasSuspended, deprecated but still sent */ { return }
self.resumeAfterInterruption = self.playWhenReady
if self.playWhenReady { self.pause() }
case .ended:
let opts = AVAudioSession.InterruptionOptions(rawValue: info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0)
if self.resumeAfterInterruption && opts.contains(.shouldResume) { self.play() }
self.resumeAfterInterruption = false
@unknown default: break
}
}
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
self.eq.refreshRoute()
}
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
guard let self else { return }
let was = self.playWhenReady
let pos = self.positionMs()
self.currentToken = -1
self.engine.release()
self.engine = StemEngine(listener: self)
self.engine.eqParams = self.eq.currentParams()
self.engine.masterGain = masterTaper(self.master)
self.pendingPositionMs = pos
if self.index >= 0 { self.load(self.index, pos, was) }
}
}
// ---- persistence ------------------------------------------------------------------------
private func save() {
saveTask?.cancel()
saveTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
guard let self, !Task.isCancelled else { return }
let snap = SavedQueue(items: self.queue, unshuffled: self.unshuffled, index: self.index,
positionMs: self.positionMs(), repeatMode: self.repeatMode, mix: self.mix)
let file = self.queueFile
DispatchQueue.global(qos: .utility).async {
guard let data = try? JSONEncoder().encode(snap) else { return }
try? data.write(to: file, options: .atomic)
}
}
}
/// Saves right away, for when the app is about to be suspended.
func flushSave() {
saveTask?.cancel()
let snap = SavedQueue(items: queue, unshuffled: unshuffled, index: index, positionMs: positionMs(), repeatMode: repeatMode, mix: mix)
if let data = try? JSONEncoder().encode(snap) { try? data.write(to: queueFile, options: .atomic) }
}
private func restore() {
guard let data = try? Data(contentsOf: queueFile), let saved = try? JSONDecoder().decode(SavedQueue.self, from: data) else { return }
queue = saved.items
unshuffled = saved.unshuffled
shuffle = saved.unshuffled != nil
repeatMode = saved.repeatMode
mix = saved.mix
nextUid = (saved.items.map(\.uid).max() ?? 0) + 1
let i = min(max(saved.index, -1), saved.items.count - 1)
index = i
current = saved.items.indices.contains(i) ? saved.items[i] : nil
durationMs = current?.track.durationMs ?? 0
pendingPositionMs = saved.positionMs
}
}