import UIKit import WidgetKit /// Carries out widget and control taps. Audio intents run here in the app's process, starting the app in the background if it isn't running. @MainActor enum MixRemote { /// One knob step on the widget, in fader position. Ten steps from silent to full, with unity on a step. static let step: Float = 0.1 static func run(_ a: MixAction) { let g = AppGraph.shared, pm = g.playback switch a { case .cycle: if pm.source == .Stems { g.nowPlaying.cycleMix() } case let .nudge(name, steps): guard pm.source == .Stems, pm.stemNames.contains(name) else { break } let cur = pm.mix[name] ?? StemSetting() // Turning a muted knob brings it back first. if cur.muted { pm.toggleMute(name) } pm.setStemLevel(name, ((cur.level / step).rounded() + Float(steps)) * step) case let .mute(name): if pm.source == .Stems && pm.stemNames.contains(name) { pm.toggleMute(name) } case .playPause: if g.radio.active { g.radio.toggle() } else { pm.togglePlay() } } WidgetBridge.shared.publish(pm, g.settings, force: true) } } /// Keeps the widgets' copy of what's playing up to date: the snapshot in the App Group, plus a small copy of the art. final class WidgetBridge: @unchecked Sendable { static let shared = WidgetBridge() private var last: WidgetSnapshot? private var artKey: String? private var savedArt: String? private var pending: DispatchWorkItem? func start(_ g: AppGraph) { let pm = g.playback, s = g.settings, plex = g.plex pm.addListener { [weak self] in self?.loadArt(pm, plex) self?.publish(pm, s) } loadArt(pm, plex) publish(pm, s) } /// Writes the snapshot and asks WidgetKit to redraw, but only when something the widgets show has changed. /// Changes settle for a moment first: a fader drag would otherwise relaunch the widget extension every frame. func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) { let snap = make(pm, s) guard force || snap != last else { return } last = snap pending?.cancel() let push = DispatchWorkItem { WidgetStore.save(snap) WidgetCenter.shared.reloadAllTimelines() if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() } } pending = push if force { push.perform() } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: push) } } private func make(_ pm: PlaybackManager, _ s: Settings) -> WidgetSnapshot { guard let t = pm.current?.track else { return WidgetSnapshot() } var w = WidgetSnapshot(title: t.title, artist: t.artist, playing: pm.isPlaying, art: savedArt == t.thumb ? savedArt : nil) guard pm.source == .Stems else { return w } let names = pm.stemNames, mix = pm.mix w.mix = MixPresets.current(s, names, mix)?.label ?? "Custom Mix" w.next = MixPresets.next(s, names, mix).label w.stems = Stems.sort(names).map { n in let m = mix[n] ?? StemSetting() return WidgetStem(name: n, label: Stems.label(n), color: Stems.color(n), level: Double(m.level), percent: Int((stemTaper(m.level) * 100).rounded()), muted: m.muted, solo: m.solo) } return w } /// Saves a 300 px copy of the current art for the widgets, once per album. private func loadArt(_ pm: PlaybackManager, _ plex: PlexClient) { let key = pm.current?.track.thumb guard key != artKey else { return } artKey = key guard let key, let url = plex.artUrl(key, px: 300), let file = WidgetStore.artURL else { return } Task { @MainActor [weak self] in guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return } let side: CGFloat = 300 let small = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)).image { _ in img.draw(in: CGRect(x: 0, y: 0, width: side, height: side)) } guard let data = small.jpegData(compressionQuality: 0.85), (try? data.write(to: file, options: .atomic)) != nil else { return } self.savedArt = key self.publish(pm, AppGraph.shared.settings, force: true) } } }