98 lines
4.4 KiB
Swift
98 lines
4.4 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|