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) } } enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off } /// 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 /// Set when the song came from a stem playlist; it plays with this mix instead of its saved one. var mix: [String: StemSetting]? 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 @ObservationIgnored private let songMixFile: 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 speed = 1.0 private(set) var waveform: Waveform? private(set) var durationMs: Int64 = 0 private(set) var message: AppMessage? /// Mixes saved for single songs (by rating key), put back whenever that song starts. private(set) var songMixes: [String: [String: StemSetting]] = [:] /// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing. private(set) var karaoke = false @ObservationIgnored private var preKaraokeMix: [String: StemSetting]? @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") songMixFile = dir.appendingPathComponent("song-mixes.json") songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:] 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) speed = settings.playbackSpeed engine.setSpeed(speed) 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) { Log.shared.error("report", text) 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], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] { tracks.enumerated().map { i, t in defer { nextUid += 1 } return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil }) } } /// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists). func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) { if tracks.isEmpty { return } let items = items(tracks, mixes) 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], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) } func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) } private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) { if tracks.isEmpty { return } let items = items(tracks, mixes) 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() } } static let speeds: [Double] = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2] func setSpeed(_ v: Double) { speed = min(max(v, 0.5), 2) engine.setSpeed(speed) settings.playbackSpeed = speed changed() } // ---- 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() } /// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix, /// else cleared (or kept, per settings). private func startMix(_ item: QueueItem) -> [String: StemSetting] { if karaoke { return karaokeMix } return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:]) } /// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say. private var karaokeMix: [String: StemSetting] { switch karaokeVocals { case .On: return [:] case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))] case .Off: return ["vocals": StemSetting(muted: true)] } } var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off } func setKaraokeVocals(_ v: KaraokeVocals) { settings.karaokeVocals = v.rawValue if karaoke { applyMix(karaokeMix) } } func setKaraokeSoftDb(_ db: Int) { settings.karaokeSoftDb = db if karaoke && karaokeVocals == .Soft { applyMix(karaokeMix) } } /// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile. func setKaraoke(_ on: Bool) { guard on != karaoke else { return } karaoke = on if on { preKaraokeMix = mix applyMix(karaokeMix) } else { if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) } preKaraokeMix = nil changed() } } var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } } func saveSongMix() { setSongMix(MixPresets.normalized(mix)) } func forgetSongMix() { setSongMix(nil) } private func setSongMix(_ m: [String: StemSetting]?) { guard let key = current?.track.ratingKey else { return } songMixes[key] = m let all = songMixes, file = songMixFile DispatchQueue.global(qos: .utility).async { if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } } } 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 mix = startMix(item) 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 mix = startMix(item) 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 } Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")") 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 } Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())") // 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 } Log.shared.error("session", "media services reset") 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 } }