Files
stemify/ios/Stemify/App/WidgetBridge.swift
T

91 lines
4.0 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?
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.
func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) {
let snap = make(pm, s)
guard force || snap != last else { return }
last = snap
WidgetStore.save(snap)
WidgetCenter.shared.reloadAllTimelines()
if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() }
}
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)
}
}
}