Files
stemify/ios/Widgets/MixerViews.swift
T

343 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}
}