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 } } }