Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -24,6 +24,7 @@ final class AppGraph {
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
let radio: WebRadio
|
||||
let car: CarLibrary
|
||||
|
||||
private init() {
|
||||
@@ -64,7 +65,16 @@ final class AppGraph {
|
||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex)
|
||||
radio = WebRadio(pm: playback)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex, radio: radio, settings: settings)
|
||||
radio.nowPlaying = nowPlaying
|
||||
car = CarLibrary(plex: carPlex, reach: reach, downloads: downloads, stems: stems, pm: playback)
|
||||
// Mix names and the rotation show on the lock screen and in the widgets, so redraw those when the Stem Editor or Appearance change them.
|
||||
let np = nowPlaying, pm = playback
|
||||
settings.onChange("acapella", "presets", "presetOrder", "hiddenPresets") {
|
||||
np.refresh()
|
||||
WidgetBridge.shared.publish(pm, settings)
|
||||
}
|
||||
WidgetBridge.shared.start(self)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ struct StemifyApp: App {
|
||||
RootView()
|
||||
}
|
||||
.onChange(of: phase) { _, p in
|
||||
if p == .active { AppGraph.shared.eq.refreshRoute() }
|
||||
guard p == .active else { return }
|
||||
AppGraph.shared.eq.refreshRoute()
|
||||
// Keep downloaded playlists in step with the server, like Plexamp.
|
||||
Task { await AppGraph.shared.downloads.refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user