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:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
import AppIntents
import SwiftUI
import WidgetKit
// ---- look ---------------------------------------------------------------------------------
enum Look {
static let bg = Color(light: 0xFFFFFF, dark: 0x151518)
static let panel = Color(light: 0xEFEFF3, dark: 0x26262B)
static let well = Color(light: 0xE2E2E8, dark: 0x34343A)
}
extension Color {
init(hex: UInt32) {
self.init(red: Double(hex >> 16 & 0xFF) / 255, green: Double(hex >> 8 & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
}
init(light: UInt32, dark: UInt32) {
self.init(UIColor { t in UIColor(Color(hex: t.userInterfaceStyle == .dark ? dark : light)) })
}
}
// ---- pieces -------------------------------------------------------------------------------
/// A mixer knob: a 270° arc filled to the fader position, a tick at unity and a pointer.
struct Knob: View {
let stem: WidgetStem
let silent: Bool
let size: CGFloat
private var tint: Color { silent ? .secondary.opacity(0.5) : Color(hex: stem.color) }
private var line: CGFloat { max(3, size * 0.085) }
var body: some View {
let v = min(max(stem.level, 0), 1)
ZStack {
Circle().trim(from: 0, to: 0.75)
.stroke(Look.well, style: StrokeStyle(lineWidth: line, lineCap: .round))
.rotationEffect(.degrees(135))
Circle().trim(from: 0, to: 0.75 * v)
.stroke(tint, style: StrokeStyle(lineWidth: line, lineCap: .round))
.rotationEffect(.degrees(135))
.widgetAccentable()
// Unity sits at 80% of the travel.
Capsule().fill(.secondary.opacity(0.6))
.frame(width: 1.5, height: line * 0.9)
.offset(y: -size / 2 - line * 0.95)
.rotationEffect(.degrees(270 * 0.8 - 135))
Circle().fill(Look.panel)
.frame(width: size * 0.66, height: size * 0.66)
.overlay {
Capsule().fill(tint)
.frame(width: 2.5, height: size * 0.12)
.offset(y: -size * 0.33 + size * 0.1)
.rotationEffect(.degrees(270 * v - 135))
.widgetAccentable()
}
Group {
if silent {
Image(systemName: "speaker.slash.fill").font(.system(size: size * 0.2, weight: .semibold))
} else {
Text("\(stem.percent)").font(.system(size: size * 0.2, weight: .semibold, design: .rounded)).monospacedDigit()
}
}
.foregroundStyle(silent ? .secondary : .primary)
.offset(y: size * 0.03)
}
.frame(width: size, height: size)
.padding(line / 2)
}
}
/// A knob you tap to mute, its name, and a − / + pair that turns it one step.
struct KnobCell: View {
let stem: WidgetStem
let silent: Bool
let size: CGFloat
var body: some View {
VStack(spacing: 4) {
Button(intent: MuteStemIntent(stem: stem.name)) {
Knob(stem: stem, silent: silent, size: size)
}
.buttonStyle(.plain)
Text(stem.label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(silent ? .secondary : .primary)
.lineLimit(1).minimumScaleFactor(0.7)
HStack(spacing: 0) {
step(-1, "minus")
Rectangle().fill(.secondary.opacity(0.25)).frame(width: 1, height: 10)
step(1, "plus")
}
.background(Capsule().fill(Look.panel))
}
.frame(maxWidth: .infinity)
}
private func step(_ n: Int, _ icon: String) -> some View {
Button(intent: NudgeStemIntent(stem: stem.name, steps: n)) {
Image(systemName: icon)
.font(.system(size: 10, weight: .bold))
.frame(width: 24, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.disabled(n < 0 ? stem.level <= 0.001 : stem.level >= 0.999)
}
}
struct Art: View {
let image: UIImage?
let size: CGFloat
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable().aspectRatio(contentMode: .fill)
} else {
Look.panel.overlay {
Image(systemName: "music.note").font(.system(size: size * 0.4)).foregroundStyle(.secondary)
}
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.18, style: .continuous))
}
}
/// The primary-coloured capsule that moves to the next mix.
struct MixPill: View {
let text: String
var icon = "chevron.right"
var body: some View {
Button(intent: CycleMixIntent()) {
HStack(spacing: 4) {
Text(text).lineLimit(1)
Image(systemName: icon).font(.system(size: 9, weight: .heavy))
}
.font(.system(size: 12, weight: .semibold))
.padding(.horizontal, 10).padding(.vertical, 6)
.foregroundStyle(Look.bg)
.background(Capsule().fill(.primary))
}
.buttonStyle(.plain)
}
}
struct Empty: View {
let snap: WidgetSnapshot
var body: some View {
VStack(spacing: 6) {
Image(systemName: snap.hasTrack ? "slider.vertical.3" : "music.note")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(.secondary)
Text(snap.hasTrack ? "No stems" : "Nothing playing")
.font(.system(size: 14, weight: .semibold))
Text(snap.hasTrack ? "Switch this song to Stems to mix it." : "Play a song in Stemify.")
.font(.system(size: 11)).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// ---- mixer (medium, large) ----------------------------------------------------------------
struct MixerView: View {
let entry: MixerEntry
@Environment(\.widgetFamily) private var family
private var snap: WidgetSnapshot { entry.snap }
var body: some View {
if !snap.hasTrack {
Empty(snap: snap)
} else if family == .systemLarge {
large
} else {
medium
}
}
private var medium: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
Art(image: entry.art, size: 30)
titles(13)
Spacer(minLength: 4)
if snap.hasStems { MixPill(text: snap.mix) }
}
if snap.hasStems {
knobs(size: snap.stems.count > 4 ? 34 : 42, columns: snap.stems.count)
} else {
Empty(snap: snap)
}
}
}
private var large: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
Art(image: entry.art, size: 56)
titles(16)
Spacer(minLength: 4)
Button(intent: PlayPauseIntent()) {
Image(systemName: snap.playing ? "pause.fill" : "play.fill")
.font(.system(size: 16, weight: .bold))
.frame(width: 40, height: 40)
.background(Circle().fill(Look.panel))
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
}
if snap.hasStems {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 1) {
Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary)
Text(snap.mix).font(.system(size: 24, weight: .bold, design: .rounded))
.lineLimit(1).minimumScaleFactor(0.6)
}
Spacer(minLength: 8)
MixPill(text: snap.next, icon: "forward.fill")
}
Spacer(minLength: 0)
knobs(size: 50, columns: snap.stems.count <= 4 ? snap.stems.count : 3)
Spacer(minLength: 0)
} else {
Empty(snap: snap)
}
}
}
private func titles(_ size: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(snap.title).font(.system(size: size, weight: .semibold)).lineLimit(1)
Text(snap.artist).font(.system(size: size - 2)).foregroundStyle(.secondary).lineLimit(1)
}
}
private func knobs(size: CGFloat, columns: Int) -> some View {
let grid = Array(repeating: GridItem(.flexible(), spacing: 6), count: max(columns, 1))
return LazyVGrid(columns: grid, spacing: 12) {
ForEach(snap.stems) { s in
KnobCell(stem: s, silent: snap.silent(s), size: size)
}
}
}
}
// ---- mix (small, lock screen) -------------------------------------------------------------
struct MixView: View {
let entry: MixerEntry
@Environment(\.widgetFamily) private var family
private var snap: WidgetSnapshot { entry.snap }
var body: some View {
switch family {
case .accessoryCircular: circular
case .accessoryRectangular: rectangular
default: small
}
}
private var small: some View {
Group {
if snap.hasStems {
VStack(alignment: .leading, spacing: 0) {
bars.frame(height: 34)
Spacer(minLength: 6)
Text("Mix").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary)
Text(snap.mix).font(.system(size: 20, weight: .bold, design: .rounded))
.lineLimit(2).minimumScaleFactor(0.6)
Spacer(minLength: 6)
MixPill(text: snap.next, icon: "forward.fill")
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Empty(snap: snap)
}
}
.containerBackground(for: .widget) { Look.bg }
}
/// One bar per stem, as tall as its fader.
private var bars: some View {
HStack(alignment: .bottom, spacing: 4) {
ForEach(snap.stems) { s in
let silent = snap.silent(s)
GeometryReader { g in
VStack {
Spacer(minLength: 0)
Capsule()
.fill(silent ? AnyShapeStyle(Look.well) : AnyShapeStyle(Color(hex: s.color)))
.frame(height: max(4, g.size.height * min(max(s.level, 0), 1)))
.widgetAccentable()
}
}
.frame(maxWidth: 9)
}
Spacer(minLength: 0)
}
}
private var rectangular: some View {
Button(intent: CycleMixIntent()) {
HStack(spacing: 8) {
Image(systemName: "slider.vertical.3").font(.system(size: 18, weight: .semibold))
VStack(alignment: .leading, spacing: 0) {
Text(snap.hasStems ? snap.mix : "Stemify").font(.headline).lineLimit(1).widgetAccentable()
Text(snap.hasStems ? "Tap for \(snap.next)" : snap.hasTrack ? "No stems" : "Nothing playing")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer(minLength: 0)
}
}
.buttonStyle(.plain)
.containerBackground(for: .widget) { Color.clear }
}
private var circular: some View {
Button(intent: CycleMixIntent()) {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 1) {
Image(systemName: "slider.vertical.3").font(.system(size: 15, weight: .semibold))
if snap.hasStems {
Text(snap.mix).font(.system(size: 9, weight: .semibold)).lineLimit(1).minimumScaleFactor(0.6)
.padding(.horizontal, 6)
}
}
}
}
.buttonStyle(.plain)
.containerBackground(for: .widget) { Color.clear }
}
}
+100
View File
@@ -0,0 +1,100 @@
import SwiftUI
import WidgetKit
@main
struct StemifyWidgets: WidgetBundle {
var body: some Widget {
MixerWidget()
MixWidget()
if #available(iOS 18.0, *) { MixControl() }
}
}
/// Audio intents run in the app's process, so the extension's copy never does anything.
@MainActor
enum MixRemote {
static func run(_ a: MixAction) {}
}
// ---- timeline -----------------------------------------------------------------------------
struct MixerEntry: TimelineEntry {
var date = Date()
var snap: WidgetSnapshot
var art: UIImage?
/// The app reloads the widgets whenever the song or mix changes, so one entry is enough.
static func now() -> MixerEntry {
let s = WidgetStore.load()
let art = s.art == nil ? nil : WidgetStore.artURL.flatMap { UIImage(contentsOfFile: $0.path) }
return MixerEntry(snap: s, art: art)
}
static let sample = MixerEntry(snap: WidgetSnapshot(
title: "Midnight City", artist: "M83", mix: "Karaoke", next: "A Cappella", playing: true,
stems: [
WidgetStem(name: "vocals", label: "Vocals", color: 0xFF7AB6, level: 0.8, percent: 100, muted: true),
WidgetStem(name: "drums", label: "Drums", color: 0xFFB547, level: 0.8, percent: 100),
WidgetStem(name: "bass", label: "Bass", color: 0x7C8CFF, level: 0.9, percent: 127),
WidgetStem(name: "other", label: "Other", color: 0x4CD07D, level: 0.7, percent: 77),
]))
}
struct MixerProvider: TimelineProvider {
func placeholder(in context: Context) -> MixerEntry { .sample }
func getSnapshot(in context: Context, completion: @escaping (MixerEntry) -> Void) {
let e = MixerEntry.now()
completion(context.isPreview && !e.snap.hasStems ? .sample : e)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<MixerEntry>) -> Void) {
completion(Timeline(entries: [.now()], policy: .never))
}
}
// ---- widgets ------------------------------------------------------------------------------
struct MixerWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: WidgetStore.mixerKind, provider: MixerProvider()) { e in
MixerView(entry: e).containerBackground(for: .widget) { Look.bg }
}
.configurationDisplayName("Stem Mixer")
.description("A knob for each stem. Tap the arrows to turn it, tap the knob to mute it.")
.supportedFamilies([.systemMedium, .systemLarge])
}
}
struct MixWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: WidgetStore.mixKind, provider: MixerProvider()) { e in
MixView(entry: e)
}
.configurationDisplayName("Mix")
.description("The mix that's playing, and a button for the next one.")
.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryCircular])
}
}
/// Swaps the Lock Screen's flashlight or camera for a Mix button. Also works in Control Center and on the Action button.
@available(iOS 18.0, *)
struct MixControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: WidgetStore.controlKind, provider: Provider()) { mix in
ControlWidgetButton(action: CycleMixIntent()) {
Label(mix, systemImage: "slider.vertical.3")
}
}
.displayName("Change Mix")
.description("Moves to the next stem mix while stems play.")
}
struct Provider: ControlValueProvider {
var previewValue: String { "Karaoke" }
func currentValue() async throws -> String {
let s = WidgetStore.load()
return s.hasStems ? s.mix : "Mix"
}
}
}