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
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user