| 1 | import SwiftUI |
| 2 | #if os(iOS) |
| 3 | import CoreHaptics |
| 4 | import UIKit |
| 5 | #elseif os(macOS) |
| 6 | import AppKit |
| 7 | #endif |
| 8 | |
| 9 | // π€ The native "goes to eleven" create-a-space instrument β a SwiftUI twin of |
| 10 | // home's web/static/fader.js, shared by iOS (full-screen cover) and Mac (sheet). |
| 11 | // Same choreography: drag the cap to 10, keep pulling past the gap to build |
| 12 | // pressure for ~900ms, and the snap to 11 fires the real create; a 950ms wild |
| 13 | // window overlaps the network wait, then the "Building your space" pane holds |
| 14 | // (shake + dust cranked) until the provisioner settles. Success rains confetti |
| 15 | // and asks for the space's name; failure resets the fader with inline copy. |
| 16 | // Pure math lives in FaderMath (SpaceCreate.swift) so tests pin it to the web. |
| 17 | // |
| 18 | // On iOS the drag is a real gesture and the instrument is haptic: a detent tick |
| 19 | // per notch (rising with the volume), a pressure ramp through the hold, a thud |
| 20 | // at the snap, and irregular "sputtering" transients for as long as the space |
| 21 | // is building β the phone strains along with the meter. |
| 22 | |
| 23 | @MainActor |
| 24 | public final class CreateSpaceModel: ObservableObject, Identifiable { |
| 25 | // Identifiable so presentation is ITEM-based (sheet(item:)/cover(item:)): |
| 26 | // isPresented + an optional model raced β the sheet could present a beat |
| 27 | // before the model state landed, showing an empty white square. |
| 28 | public let id = UUID() |
| 29 | public enum Stage: Equatable { |
| 30 | case slide // the instrument (also hosts snap/build) |
| 31 | case ready(host: String) // claimed β the confetti beat, then straight in |
| 32 | } |
| 33 | |
| 34 | @Published public private(set) var stage: Stage = .slide |
| 35 | @Published public var status: String? // inline error line, never a toast |
| 36 | // From the snap until the app has switched into the new space, the sheet |
| 37 | // must not be escapable (published so interactiveDismissDisabled tracks it). |
| 38 | @Published private var creating = false |
| 39 | // The render heartbeat: bumped ~60Hz by the driver timer. Any @Published |
| 40 | // change invalidates the view, so this alone re-renders everything the sim |
| 41 | // moves. (TimelineView(.animation) silently stopped ticking on device β |
| 42 | // frozen smoke, a slider that ignored drags β hence an explicit driver.) |
| 43 | @Published private var tick = 0 |
| 44 | |
| 45 | // The full pipeline run at the snap: provision + claim (SavedInstance ready). |
| 46 | let create: () async throws -> SavedInstance |
| 47 | // Name + open: platform persists/selects the instance and dismisses. |
| 48 | let finish: (SavedInstance, String) async -> Void |
| 49 | let cancel: () -> Void |
| 50 | |
| 51 | public init(create: @escaping () async throws -> SavedInstance, |
| 52 | finish: @escaping (SavedInstance, String) async -> Void, |
| 53 | cancel: @escaping () -> Void) { |
| 54 | self.create = create |
| 55 | self.finish = finish |
| 56 | self.cancel = cancel |
| 57 | } |
| 58 | |
| 59 | // ---- fader simulation (non-published: stepped by the driver, read by render) |
| 60 | |
| 61 | var phase: FaderPhase = .slide |
| 62 | var value = 1.0 |
| 63 | var pressure = 0.0 |
| 64 | var overpull = false |
| 65 | var dragging = false |
| 66 | var lastT: TimeInterval? |
| 67 | var phaseT0: TimeInterval = 0 |
| 68 | var shakeX = 0.0, shakeY = 0.0, shakeRot = 0.0 |
| 69 | var dust = DustField() |
| 70 | var confetti = ConfettiField() |
| 71 | var ambient = AmbientField() |
| 72 | let haptics = CreateHaptics() |
| 73 | var stageSize: CGSize = .zero // the screen, reported by the view |
| 74 | var reduceMotion = false // env flag, reported by the view |
| 75 | var renderTick: Int { tick } // forces the Canvases to redraw by value |
| 76 | private var driver: Timer? |
| 77 | private var lastDetent = 1 |
| 78 | private var winddownT0: TimeInterval? // failure ramp-down start |
| 79 | // Assistive overpull is armed per-increment with a short deadline: holding |
| 80 | // the increment (repeated calls) sustains it toward the snap, a single |
| 81 | // flick lapses harmlessly β the web's key-held semantics (fader.js clears |
| 82 | // overpull on keyup), not a one-tap space creation. |
| 83 | private var a11yOverpullUntil: TimeInterval? |
| 84 | private var settled: Result<SavedInstance, Error>? |
| 85 | private(set) var claimed: SavedInstance? |
| 86 | |
| 87 | public var busy: Bool { creating } |
| 88 | |
| 89 | // Backing out is only offered before the snap β once the create has fired |
| 90 | // the space is real, and it opens on its own moments later. The ONE gate |
| 91 | // shared by the Cancel button and the Mac Esc key, so they can never |
| 92 | // disagree about when leaving is safe. |
| 93 | public var canCancel: Bool { phase == .slide && stage == .slide } |
| 94 | |
| 95 | // Cancel with the gate folded in β what a host's Esc handler calls (the |
| 96 | // raw `cancel` closure stays internal, reachable only past the gate). |
| 97 | public func requestCancel() { |
| 98 | if canCancel { cancel() } |
| 99 | } |
| 100 | |
| 101 | /// Start the 60Hz sim driver. Timer fires on the main run loop (.common, |
| 102 | /// so it keeps ticking through gesture tracking), OUTSIDE view evaluation β |
| 103 | /// stepping here may touch @Published state freely. |
| 104 | func start() { |
| 105 | guard driver == nil else { return } |
| 106 | let t = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in |
| 107 | MainActor.assumeIsolated { |
| 108 | guard let self else { return } |
| 109 | self.step(now: Date().timeIntervalSinceReferenceDate, |
| 110 | size: self.stageSize, reduceMotion: self.reduceMotion) |
| 111 | self.tick &+= 1 |
| 112 | } |
| 113 | } |
| 114 | RunLoop.main.add(t, forMode: .common) |
| 115 | driver = t |
| 116 | } |
| 117 | |
| 118 | func stop() { |
| 119 | driver?.invalidate() |
| 120 | driver = nil |
| 121 | } |
| 122 | |
| 123 | func dragChanged(x: CGFloat, trackWidth: CGFloat) { |
| 124 | guard phase == .slide, winddownT0 == nil, trackWidth > 0 else { return } |
| 125 | dragging = true |
| 126 | value = FaderMath.value(fraction: x / trackWidth) |
| 127 | // Arm only when the value has bottomed out at 10 AND the pointer has |
| 128 | // physically crossed into the dead gap past the run (fader.js onMove). |
| 129 | overpull = value >= 9.99 && x > trackWidth * FaderMath.runEnd + 14 |
| 130 | let notch = Int(value.rounded()) |
| 131 | if notch != lastDetent { |
| 132 | haptics.detent(notch) |
| 133 | lastDetent = notch |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func dragEnded() { |
| 138 | dragging = false |
| 139 | overpull = false |
| 140 | a11yOverpullUntil = nil |
| 141 | } |
| 142 | |
| 143 | /// The assistive increment: step the dial; at the top, arm the hold with a |
| 144 | /// deadline so only a SUSTAINED series of increments reaches eleven. |
| 145 | func a11yIncrement(now: TimeInterval = Date().timeIntervalSinceReferenceDate) { |
| 146 | guard phase == .slide, winddownT0 == nil else { return } |
| 147 | if value >= 9.99 { |
| 148 | overpull = true |
| 149 | a11yOverpullUntil = now + 0.4 |
| 150 | } else { |
| 151 | value = min(10, value.rounded() + 1) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func a11yDecrement() { |
| 156 | guard phase == .slide, winddownT0 == nil else { return } |
| 157 | value = max(1, value.rounded() - 1) |
| 158 | dragEnded() |
| 159 | } |
| 160 | |
| 161 | /// One simulation step, fired by the driver timer at display rate β a |
| 162 | /// plain event context, so @Published transitions are safe inline. |
| 163 | func step(now: TimeInterval, size: CGSize, reduceMotion: Bool) { |
| 164 | let dt = min(0.1, max(0, now - (lastT ?? now))) |
| 165 | lastT = now |
| 166 | |
| 167 | if phase == .slide { |
| 168 | if let t0 = winddownT0 { |
| 169 | // A failed create is the power dying: the dial falls from |
| 170 | // eleven on a slow exponential curve β meter, shake, dust and |
| 171 | // the haptic mess all following it down. The error line is |
| 172 | // already up (shown the moment the wind-down began). |
| 173 | value = 1 + 9 * exp(-(now - t0) / 0.55) |
| 174 | let notch = Int(value.rounded()) |
| 175 | if notch != lastDetent { |
| 176 | haptics.detent(notch) |
| 177 | lastDetent = notch |
| 178 | } |
| 179 | haptics.chaos(drive: (value - 1) / 9, now: now) |
| 180 | if value <= 1.06 { |
| 181 | value = 1 |
| 182 | winddownT0 = nil |
| 183 | } |
| 184 | } else { |
| 185 | // A lapsed assistive hold disarms (the finger path clears via |
| 186 | // dragEnded instead β a live drag never carries a deadline). |
| 187 | if let until = a11yOverpullUntil, now > until { |
| 188 | overpull = false |
| 189 | a11yOverpullUntil = nil |
| 190 | } |
| 191 | if overpull && value >= 9.99 { |
| 192 | pressure = min(1, pressure + dt * 1000 / FaderMath.holdMS) |
| 193 | haptics.holdPulse(pressure: pressure, now: now) |
| 194 | } else { |
| 195 | pressure = max(0, pressure - dt * 1000 / 300) |
| 196 | } |
| 197 | // The volume in your hand: a random mess of taps β busier and |
| 198 | // harder as the slider climbs, straining through the hold β |
| 199 | // with the notch detents punching through. |
| 200 | haptics.chaos(drive: min(1, pow(max(0, value - 1) / 9, 1.4) * 0.9 |
| 201 | + pressure * 0.35), now: now) |
| 202 | if pressure >= 1 { snapToEleven(now: now) } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | if phase == .snap { |
| 207 | haptics.sputterTick(now: now) |
| 208 | if (now - phaseT0) * 1000 >= FaderMath.snapMS { phase = .build } |
| 209 | } |
| 210 | if phase == .build { |
| 211 | haptics.sputterTick(now: now) |
| 212 | if let settled { |
| 213 | self.settled = nil |
| 214 | switch settled { |
| 215 | case .success(let inst): |
| 216 | claimed = inst |
| 217 | phase = .done |
| 218 | haptics.success() |
| 219 | if !reduceMotion { confetti.spawn(in: size) } |
| 220 | stage = .ready(host: URL(string: inst.origin)?.host ?? inst.origin) |
| 221 | // The confetti beat, then straight into the space β no |
| 222 | // naming step (the admin console renames any time). |
| 223 | Task { @MainActor in |
| 224 | try? await Task.sleep(nanoseconds: 1_400_000_000) |
| 225 | self.open() |
| 226 | } |
| 227 | case .failure(let error): |
| 228 | resetToSlide() |
| 229 | // Start the wind-down at eleven with the error line up |
| 230 | // immediately β the ramp is the mood, not a delay on |
| 231 | // finding out what happened. |
| 232 | value = 10 |
| 233 | lastDetent = 10 |
| 234 | winddownT0 = now |
| 235 | status = (error as? LocalizedError)?.errorDescription |
| 236 | ?? "Couldn't create a space." |
| 237 | creating = false |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // Loudness drives the theatre. Past the snap it's CRANKED (fader.js). |
| 243 | let level = FaderMath.level(phase: phase, value: value, pressure: pressure) |
| 244 | if reduceMotion || level <= 0 { |
| 245 | shakeX = 0; shakeY = 0; shakeRot = 0 |
| 246 | } else { |
| 247 | let mult = (phase == .snap || phase == .build) ? FaderMath.cranked : 1 |
| 248 | let a = FaderMath.shake * level * mult |
| 249 | shakeX = (Double.random(in: -1...1)) * a |
| 250 | shakeY = (Double.random(in: -1...1)) * a |
| 251 | shakeRot = (Double.random(in: -0.5...0.5)) * 1.6 * level * mult |
| 252 | } |
| 253 | if !reduceMotion { |
| 254 | ambient.step(dt: dt, size: size) |
| 255 | dust.step(dt: dt, level: level, size: size, fading: phase == .done) |
| 256 | confetti.step(dt: dt, size: size) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | private func snapToEleven(now: TimeInterval) { |
| 261 | phase = .snap |
| 262 | phaseT0 = now |
| 263 | value = 10 |
| 264 | pressure = 0 |
| 265 | haptics.snap() |
| 266 | // Fire the real create NOW β the wild window overlaps the network time. |
| 267 | Task { @MainActor in |
| 268 | self.creating = true |
| 269 | self.status = nil |
| 270 | do { self.settled = .success(try await self.create()) } |
| 271 | catch { self.settled = .failure(error) } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | private func resetToSlide() { |
| 276 | phase = .slide |
| 277 | value = 1 |
| 278 | pressure = 0 |
| 279 | overpull = false |
| 280 | lastDetent = 1 |
| 281 | haptics.stopSputter() |
| 282 | } |
| 283 | |
| 284 | private var opening = false |
| 285 | |
| 286 | func open() { |
| 287 | guard let inst = claimed, !opening else { return } |
| 288 | opening = true |
| 289 | Task { @MainActor in |
| 290 | await finish(inst, "") |
| 291 | creating = false |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | /// The cap's position along the track (fraction of width): pinned past the |
| 296 | /// gap at eleven; a small pressure "give" creeps into the gap while held. |
| 297 | var capFraction: Double { |
| 298 | if phase == .snap || phase == .build || phase == .done { return 1 } |
| 299 | return ((value - 1) / 9) * FaderMath.runEnd |
| 300 | + (pressure > 0 ? pressure * (1 - FaderMath.runEnd) * 0.35 : 0) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | /// The sheet/cover wrapper BOTH platforms present: it observes the model, so |
| 305 | /// the escape gate actually re-evaluates when the create starts (a plain |
| 306 | /// .interactiveDismissDisabled at an unobserving call site froze at its |
| 307 | /// presentation-time value β Escape could kill the sheet mid-create). |
| 308 | public struct CreateSpaceSheet: View { |
| 309 | @ObservedObject var model: CreateSpaceModel |
| 310 | |
| 311 | public init(model: CreateSpaceModel) { self.model = model } |
| 312 | |
| 313 | public var body: some View { |
| 314 | CreateSpaceView(model: model) |
| 315 | .interactiveDismissDisabled(model.busy) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | public struct CreateSpaceView: View { |
| 320 | @ObservedObject var model: CreateSpaceModel |
| 321 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 322 | |
| 323 | public init(model: CreateSpaceModel) { self.model = model } |
| 324 | |
| 325 | public var body: some View { |
| 326 | // Rendering is pure: the model's driver timer steps the sim at 60Hz |
| 327 | // and bumps a published tick, which re-evaluates this body. |
| 328 | content(now: Date().timeIntervalSinceReferenceDate) |
| 329 | .onAppear { |
| 330 | model.reduceMotion = reduceMotion |
| 331 | model.haptics.prepare() |
| 332 | model.start() |
| 333 | } |
| 334 | .onChange(of: reduceMotion) { _, r in model.reduceMotion = r } |
| 335 | .onDisappear { |
| 336 | model.stop() |
| 337 | model.haptics.stopAll() |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // Solid, not material: the card should sit WHITE on the hazy room in |
| 342 | // light mode (grey-on-grey read muddy), and properly elevated in dark. |
| 343 | private var cardColor: Color { |
| 344 | #if os(iOS) |
| 345 | Color(.systemBackground) |
| 346 | #else |
| 347 | Color(nsColor: .textBackgroundColor) |
| 348 | #endif |
| 349 | } |
| 350 | |
| 351 | private func content(now: TimeInterval) -> some View { |
| 352 | ZStack { |
| 353 | // Home's ambient haze β drifting smoke puffs + rising motes β |
| 354 | // behind everything, theme-aware (bootscene.js's quiet sibling). |
| 355 | // Every canvas carries the tick: the field is a class (identical |
| 356 | // reference frame-to-frame), so without a changing value SwiftUI |
| 357 | // would skip the redraw and the scene freezes. The whole particle |
| 358 | // stage is FULL-BLEED (ignoresSafeArea) and the sim runs on that |
| 359 | // size, so dust genuinely enters from below the physical screen |
| 360 | // edge β not from the safe-area line above the home indicator. |
| 361 | if !reduceMotion { |
| 362 | ZStack { |
| 363 | AmbientCanvas(field: model.ambient, tick: model.renderTick) |
| 364 | DustCanvas(field: model.dust, tick: model.renderTick) |
| 365 | // Measured INSIDE the safe-area-ignoring container so the |
| 366 | // sim's floor is the physical screen bottom. (Measuring |
| 367 | // via .overlay outside reported the safe-area size β the |
| 368 | // dust's "below the edge" rest line floated ~70pt up, |
| 369 | // visible as a straight row of dots.) |
| 370 | GeometryReader { geo in |
| 371 | Color.clear |
| 372 | .onAppear { model.stageSize = geo.size } |
| 373 | .onChange(of: geo.size) { _, s in model.stageSize = s } |
| 374 | } |
| 375 | } |
| 376 | .allowsHitTesting(false) |
| 377 | .ignoresSafeArea() |
| 378 | } |
| 379 | // The instrument sits on a card, like home's main.card β the card |
| 380 | // is what shakes. |
| 381 | card(now: now) |
| 382 | .background(cardColor, |
| 383 | in: RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| 384 | .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) |
| 385 | .strokeBorder(Color.primary.opacity(0.08))) |
| 386 | .shadow(color: .black.opacity(0.12), radius: 18, y: 8) |
| 387 | .padding(.horizontal, 16) |
| 388 | .offset(x: model.shakeX, y: model.shakeY) |
| 389 | .rotationEffect(.degrees(model.shakeRot)) |
| 390 | if !reduceMotion { |
| 391 | ConfettiCanvas(field: model.confetti, tick: model.renderTick) |
| 392 | .allowsHitTesting(false) |
| 393 | .ignoresSafeArea() |
| 394 | } |
| 395 | } |
| 396 | // Backing out is only offered before the snap (canCancel β the same |
| 397 | // gate the Mac Esc key uses). |
| 398 | .overlay(alignment: .topLeading) { |
| 399 | if model.canCancel { |
| 400 | Button("Cancel") { model.cancel() } |
| 401 | .buttonStyle(.plain) |
| 402 | .foregroundStyle(.secondary) |
| 403 | .padding(20) |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | @ViewBuilder private func card(now: TimeInterval) -> some View { |
| 409 | VStack(spacing: 22) { |
| 410 | switch model.stage { |
| 411 | case .slide: |
| 412 | if model.phase == .build { |
| 413 | buildingPane |
| 414 | } else { |
| 415 | VStack(spacing: 6) { |
| 416 | Text("Slide to create") |
| 417 | .font(.title2.weight(.semibold)) |
| 418 | Text("Your new space will be created immediately.") |
| 419 | .font(.callout) |
| 420 | .foregroundStyle(.secondary) |
| 421 | } |
| 422 | } |
| 423 | meter(now: now) |
| 424 | fader |
| 425 | if let status = model.status { |
| 426 | Text(status) |
| 427 | .font(.footnote) |
| 428 | .foregroundStyle(.red) |
| 429 | .multilineTextAlignment(.center) |
| 430 | } |
| 431 | case .ready(let host): |
| 432 | readyPane(host: host) |
| 433 | } |
| 434 | } |
| 435 | .padding(28) |
| 436 | .frame(maxWidth: 440) |
| 437 | } |
| 438 | |
| 439 | private var buildingPane: some View { |
| 440 | VStack(spacing: 10) { |
| 441 | ProgressView() |
| 442 | .controlSize(.large) |
| 443 | Text("Hold onβ¦") |
| 444 | .font(.title3.weight(.semibold)) |
| 445 | Text("Building your space") |
| 446 | .font(.callout) |
| 447 | .foregroundStyle(.secondary) |
| 448 | } |
| 449 | .padding(.bottom, 6) |
| 450 | } |
| 451 | |
| 452 | // The confetti beat: no forms, no buttons β the space opens on its own a |
| 453 | // moment later (naming lives in the admin console whenever they want it). |
| 454 | private func readyPane(host: String) -> some View { |
| 455 | VStack(spacing: 14) { |
| 456 | Image(systemName: "checkmark.circle.fill") |
| 457 | .font(.system(size: 44)) |
| 458 | .foregroundStyle(.green) |
| 459 | Text("Your space is ready") |
| 460 | .font(.title2.weight(.semibold)) |
| 461 | Text(host) |
| 462 | .font(.callout.monospaced()) |
| 463 | .foregroundStyle(.secondary) |
| 464 | ProgressView() |
| 465 | .controlSize(.small) |
| 466 | .padding(.top, 6) |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // ---- the instrument ------------------------------------------------------ |
| 471 | |
| 472 | /// The 11-segment LED meter: green 1β7, amber 8β9, red 10β11 (desk |
| 473 | /// vernacular); every LED lights through the snap/build, with #11 in a |
| 474 | /// 140ms "fury" flicker for the whole window. |
| 475 | private func meter(now: TimeInterval) -> some View { |
| 476 | let hot = model.phase == .snap || model.phase == .build |
| 477 | let lit = hot ? 11 : Int(model.value.rounded()) |
| 478 | return HStack(spacing: 3) { |
| 479 | ForEach(1...11, id: \.self) { n in |
| 480 | let color: Color = n <= 7 ? .green : n <= 9 ? .orange : .red |
| 481 | let on = n <= lit |
| 482 | let fury = hot && n == 11 && Int(now / 0.14) % 2 == 1 |
| 483 | RoundedRectangle(cornerRadius: 1.5) |
| 484 | .fill(on ? color : Color.secondary.opacity(0.12)) |
| 485 | .frame(height: 7) |
| 486 | .opacity(fury ? 0.25 : 1) |
| 487 | .shadow(color: on ? color.opacity(0.55) : .clear, radius: on ? 2.5 : 0) |
| 488 | } |
| 489 | } |
| 490 | .padding(.bottom, 2) |
| 491 | } |
| 492 | |
| 493 | /// A scale mark's fractional x: 1..10 across the run, 11 pinned past the gap. |
| 494 | private func markX(_ n: Int) -> Double { |
| 495 | n == 11 ? 1.0 : (Double(n - 1) / 9) * FaderMath.runEnd |
| 496 | } |
| 497 | |
| 498 | private var fader: some View { |
| 499 | VStack(spacing: 8) { |
| 500 | // The instrument: slot + ticks + cap. The drag lives on the WHOLE |
| 501 | // track (local coordinates β grab the cap or land anywhere), which |
| 502 | // is also what fixed dragging: a named coordinate space under the |
| 503 | // per-frame TimelineView rebuild resolved wrong on device. |
| 504 | GeometryReader { geo in |
| 505 | let w = geo.size.width |
| 506 | let midY = geo.size.height / 2 |
| 507 | ZStack(alignment: .leading) { |
| 508 | RoundedRectangle(cornerRadius: 3) |
| 509 | .fill(Color.secondary.opacity(0.22)) |
| 510 | .frame(height: 6) |
| 511 | .overlay(RoundedRectangle(cornerRadius: 3) |
| 512 | .strokeBorder(Color.black.opacity(0.12), lineWidth: 0.5)) |
| 513 | .frame(maxHeight: .infinity, alignment: .center) |
| 514 | ForEach(1...11, id: \.self) { n in |
| 515 | Rectangle() |
| 516 | .fill(Color.secondary.opacity(0.55)) |
| 517 | .frame(width: 1.5, height: n == 1 || n == 11 ? 14 : 9) |
| 518 | .position(x: markX(n) * w, y: midY) |
| 519 | } |
| 520 | cap |
| 521 | .position(x: min(max(model.capFraction * w, 14), w), y: midY) |
| 522 | } |
| 523 | .contentShape(Rectangle()) |
| 524 | .gesture( |
| 525 | DragGesture(minimumDistance: 0) |
| 526 | .onChanged { v in model.dragChanged(x: v.location.x, trackWidth: w) } |
| 527 | .onEnded { _ in model.dragEnded() } |
| 528 | ) |
| 529 | } |
| 530 | .frame(height: 48) |
| 531 | // The scale, on its own row so the cap never sits on the numbers. |
| 532 | GeometryReader { geo in |
| 533 | let hot = model.phase == .snap || model.phase == .build |
| 534 | ForEach(1...11, id: \.self) { n in |
| 535 | let arming = n == 11 && (model.overpull || hot) |
| 536 | Text("\(n)") |
| 537 | .font(.system(size: 11, weight: n == 11 ? .heavy : .semibold)) |
| 538 | .foregroundStyle(arming ? Color.red |
| 539 | : (Int(model.value.rounded()) >= n ? Color.primary : Color.secondary)) |
| 540 | .scaleEffect(hot && n == 11 ? 1.4 : 1) |
| 541 | .position(x: markX(n) * geo.size.width, y: 8) |
| 542 | } |
| 543 | } |
| 544 | .frame(height: 16) |
| 545 | } |
| 546 | .accessibilityElement() |
| 547 | .accessibilityLabel("Volume slider β slide to eleven to create your space") |
| 548 | .accessibilityAdjustableAction { direction in |
| 549 | // The assistive path: step to 10, then KEEP incrementing to hold |
| 550 | // toward eleven (each arm lapses in 0.4s β fader.js's key-held |
| 551 | // overpull, never a one-tap create). |
| 552 | switch direction { |
| 553 | case .increment: model.a11yIncrement() |
| 554 | case .decrement: model.a11yDecrement() |
| 555 | @unknown default: break |
| 556 | } |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | /// The mixing-console cap: gradient body, two grooves, a quiet accent stripe. |
| 561 | private var cap: some View { |
| 562 | ZStack { |
| 563 | RoundedRectangle(cornerRadius: 7, style: .continuous) |
| 564 | .fill(LinearGradient(colors: [Color(white: 0.62), Color(white: 0.36)], |
| 565 | startPoint: .top, endPoint: .bottom)) |
| 566 | .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous) |
| 567 | .strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5)) |
| 568 | .frame(width: 28, height: 44) |
| 569 | .shadow(color: .black.opacity(0.3), radius: 3, y: 2) |
| 570 | VStack { |
| 571 | Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1) |
| 572 | Spacer() |
| 573 | Rectangle().fill(Color.black.opacity(0.3)).frame(width: 18, height: 1) |
| 574 | } |
| 575 | .frame(width: 28, height: 24) |
| 576 | Rectangle() |
| 577 | .fill(Color.accentColor.opacity(0.9)) |
| 578 | .frame(width: 2, height: 24) |
| 579 | } |
| 580 | .allowsHitTesting(false) // the track owns the gesture |
| 581 | } |
| 582 | } |
| 583 | |
| 584 | // ---- dust + confetti (fader.js's stage-dust canvas, card-scoped) ------------- |
| 585 | |
| 586 | struct DustParticle { |
| 587 | var x: Double, y: Double, vx: Double, vy: Double, r: Double, accent: Bool |
| 588 | } |
| 589 | |
| 590 | /// Stage dust that lives OFFSTAGE, just below the bottom edge β the screen |
| 591 | /// starts clean, and turning the volume up kicks motes into view from below |
| 592 | /// (fader.js initDust, floor-shifted): gravity 0.62/frame, kick odds |
| 593 | /// 0.02 + levelΒ·0.2, bigger jumps past eleven. A settled mote rests below the |
| 594 | /// edge again, so silence always empties the stage. |
| 595 | final class DustField { |
| 596 | var particles: [DustParticle] = [] |
| 597 | var fade = 1.0 |
| 598 | /// How far below the visible edge a resting mote sits β deep enough that |
| 599 | /// the launch reads as coming from somewhere, not from the edge itself. |
| 600 | static let restDepth = 26.0 |
| 601 | private var seeded = false |
| 602 | |
| 603 | func step(dt: TimeInterval, level: Double, size: CGSize, fading: Bool) { |
| 604 | guard size.width > 0, size.height > 0 else { return } |
| 605 | let f = dt * 60 // web steps in ~frame units |
| 606 | let floor = size.height + Self.restDepth |
| 607 | if !seeded { |
| 608 | seeded = true |
| 609 | particles = (0..<70).map { _ in |
| 610 | DustParticle(x: .random(in: 0...size.width), y: floor, |
| 611 | vx: 0, vy: 0, |
| 612 | r: .random(in: 0.8...2.2), accent: Double.random(in: 0...1) < 0.18) |
| 613 | } |
| 614 | } |
| 615 | if fading { fade = max(0, fade - dt / 0.6) } |
| 616 | let kickOdds = 0.02 + level * 0.2 |
| 617 | for i in particles.indices { |
| 618 | var p = particles[i] |
| 619 | // Only the volume lifts a resting mote into view. Jump height |
| 620 | // scales hard with the dial: waist-high mid-scale, ~2/3 screen at |
| 621 | // 10, and past eleven (level 1.2) they can clear the whole thing. |
| 622 | if p.vy == 0, p.y >= floor, level > 0.02, Double.random(in: 0...1) < kickOdds * f { |
| 623 | let peak = level > 1 ? Double.random(in: 0.65...1.05) * size.height |
| 624 | : level * level * size.height * 0.85 |
| 625 | p.vy = -sqrt(max(0.1, 2 * 0.62 * (peak + Self.restDepth))) * .random(in: 0.6...1) |
| 626 | p.vx = .random(in: -0.8...0.8) |
| 627 | } |
| 628 | p.vy += 0.62 * f |
| 629 | p.x += p.vx * f |
| 630 | p.y += p.vy * f |
| 631 | if p.y >= floor { |
| 632 | p.y = floor |
| 633 | // Bounce while it still has energy; otherwise settle offstage. |
| 634 | if p.vy > 1.2 { p.vy *= -0.42 } else { p.vy = 0; p.vx = 0 } |
| 635 | } |
| 636 | if p.x < 0 { p.x += size.width } |
| 637 | if p.x > size.width { p.x -= size.width } |
| 638 | particles[i] = p |
| 639 | } |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | struct DustCanvas: View { |
| 644 | let field: DustField |
| 645 | let tick: Int // changes every frame so the redraw is never skipped |
| 646 | |
| 647 | var body: some View { |
| 648 | Canvas { ctx, size in |
| 649 | for p in field.particles where p.y < size.height { |
| 650 | let rect = CGRect(x: p.x - p.r, y: p.y - p.r, width: p.r * 2, height: p.r * 2) |
| 651 | // Solid and dark on purpose β the kicked stage dust must read |
| 652 | // as a different thing from the pale ambient motes behind it. |
| 653 | let color: Color = p.accent ? .accentColor : .primary |
| 654 | ctx.fill(Path(ellipseIn: rect), |
| 655 | with: .color(color.opacity(0.7 * field.fade))) |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | // ---- ambient haze (home's bootscene, the quiet sibling) ---------------------- |
| 662 | |
| 663 | struct AmbientPuff { |
| 664 | var x: Double, y: Double, vx: Double, r: Double, phase: Double, alpha: Double |
| 665 | } |
| 666 | |
| 667 | struct AmbientMote { |
| 668 | var x: Double, y: Double, vy: Double, sway: Double, r: Double, alpha: Double |
| 669 | } |
| 670 | |
| 671 | /// Home's ambient background, ported small: soft smoke puffs drifting sideways |
| 672 | /// and dust motes rising through them. Runs the whole time the create screen |
| 673 | /// is up β the room the instrument lives in, not a volume effect. |
| 674 | final class AmbientField { |
| 675 | var puffs: [AmbientPuff] = [] |
| 676 | var motes: [AmbientMote] = [] |
| 677 | var t = 0.0 |
| 678 | private var seeded = false |
| 679 | |
| 680 | func step(dt: TimeInterval, size: CGSize) { |
| 681 | guard size.width > 0, size.height > 0 else { return } |
| 682 | t += dt |
| 683 | if !seeded { |
| 684 | seeded = true |
| 685 | puffs = (0..<18).map { _ in |
| 686 | AmbientPuff(x: .random(in: -80...size.width + 80), |
| 687 | y: .random(in: size.height * 0.2...size.height * 1.05), |
| 688 | vx: .random(in: -6...6).clampedAwayFromZero(min: 2), |
| 689 | r: .random(in: 80...170), |
| 690 | phase: .random(in: 0...(2 * .pi)), |
| 691 | alpha: .random(in: 0.07...0.14)) |
| 692 | } |
| 693 | motes = (0..<36).map { _ in |
| 694 | AmbientMote(x: .random(in: 0...size.width), |
| 695 | y: .random(in: 0...size.height), |
| 696 | vy: .random(in: 8...22), |
| 697 | sway: .random(in: 0...(2 * .pi)), |
| 698 | r: .random(in: 1...2.4), |
| 699 | alpha: .random(in: 0.1...0.22)) |
| 700 | } |
| 701 | } |
| 702 | for i in puffs.indices { |
| 703 | puffs[i].x += puffs[i].vx * dt |
| 704 | if puffs[i].x < -160 { puffs[i].x = size.width + 160 } |
| 705 | if puffs[i].x > size.width + 160 { puffs[i].x = -160 } |
| 706 | } |
| 707 | for i in motes.indices { |
| 708 | motes[i].y -= motes[i].vy * dt |
| 709 | motes[i].x += sin(t * 0.6 + motes[i].sway) * 6 * dt |
| 710 | if motes[i].y < -4 { |
| 711 | motes[i].y = size.height + 4 |
| 712 | motes[i].x = .random(in: 0...size.width) |
| 713 | } |
| 714 | } |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | struct AmbientCanvas: View { |
| 719 | let field: AmbientField |
| 720 | let tick: Int // changes every frame so the redraw is never skipped |
| 721 | @Environment(\.colorScheme) private var scheme |
| 722 | |
| 723 | var body: some View { |
| 724 | // Dark room: white smoke, light motes. Light room: grey smoke, soft |
| 725 | // grey motes β bootscene.js's two-theme rule. Motes stay a smidge |
| 726 | // lighter than the stage dust so the two read as different things. |
| 727 | let smoke: Color = scheme == .dark ? .white : Color(white: 0.3) |
| 728 | let mote: Color = scheme == .dark ? Color(white: 0.85) : Color(white: 0.45) |
| 729 | ZStack { |
| 730 | Canvas { ctx, _ in |
| 731 | for p in field.puffs { |
| 732 | let bob = sin(field.t * 0.4 + p.phase) * 10 |
| 733 | let rect = CGRect(x: p.x - p.r, y: p.y + bob - p.r * 0.6, |
| 734 | width: p.r * 2, height: p.r * 1.2) |
| 735 | ctx.fill(Path(ellipseIn: rect), with: .color(smoke.opacity(p.alpha))) |
| 736 | } |
| 737 | } |
| 738 | .blur(radius: 28) |
| 739 | Canvas { ctx, _ in |
| 740 | for m in field.motes { |
| 741 | let rect = CGRect(x: m.x - m.r, y: m.y - m.r, width: m.r * 2, height: m.r * 2) |
| 742 | ctx.fill(Path(ellipseIn: rect), with: .color(mote.opacity(m.alpha))) |
| 743 | } |
| 744 | } |
| 745 | } |
| 746 | .ignoresSafeArea() |
| 747 | } |
| 748 | } |
| 749 | |
| 750 | private extension Double { |
| 751 | /// Keep a random drift speed from being visually dead-stopped. |
| 752 | func clampedAwayFromZero(min m: Double) -> Double { |
| 753 | abs(self) < m ? (self < 0 ? -m : m) : self |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | struct ConfettiPiece { |
| 758 | var x: Double, y: Double, vx: Double, vy: Double |
| 759 | var w: Double, h: Double, spin: Double, angle: Double |
| 760 | var hue: Double, delay: Double, life: Double |
| 761 | } |
| 762 | |
| 763 | /// Success only: 75 corner-cannon pieces + 170 sky rain (fader.js spawnConfetti). |
| 764 | final class ConfettiField { |
| 765 | var pieces: [ConfettiPiece] = [] |
| 766 | |
| 767 | func spawn(in size: CGSize) { |
| 768 | guard size.width > 0 else { return } |
| 769 | var out: [ConfettiPiece] = [] |
| 770 | for i in 0..<75 { // corner cannons |
| 771 | let left = i % 2 == 0 |
| 772 | out.append(ConfettiPiece( |
| 773 | x: left ? -4 : size.width + 4, y: size.height * 0.9, |
| 774 | vx: (left ? 1 : -1) * .random(in: 3...8), vy: -.random(in: 11...18), |
| 775 | w: .random(in: 5...11), h: .random(in: 3...8), |
| 776 | spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)), |
| 777 | hue: .random(in: 0...1), delay: 0, life: 4.6)) |
| 778 | } |
| 779 | for _ in 0..<170 { // sky-wide rain |
| 780 | out.append(ConfettiPiece( |
| 781 | x: .random(in: 0...size.width), y: -.random(in: 0...30), |
| 782 | vx: .random(in: -0.6...0.6), vy: .random(in: 1...3), |
| 783 | w: .random(in: 5...11), h: .random(in: 3...8), |
| 784 | spin: .random(in: -0.2...0.2), angle: .random(in: 0...(2 * .pi)), |
| 785 | hue: .random(in: 0...1), delay: .random(in: 0...1.8), life: 4.6)) |
| 786 | } |
| 787 | pieces = out |
| 788 | } |
| 789 | |
| 790 | func step(dt: TimeInterval, size: CGSize) { |
| 791 | guard !pieces.isEmpty else { return } |
| 792 | let f = dt * 60 |
| 793 | for i in pieces.indices { |
| 794 | var p = pieces[i] |
| 795 | if p.delay > 0 { p.delay -= dt; pieces[i] = p; continue } |
| 796 | p.life -= dt |
| 797 | p.vy += 0.18 * f |
| 798 | p.x += p.vx * f |
| 799 | p.y += p.vy * f |
| 800 | p.angle += p.spin * f |
| 801 | pieces[i] = p |
| 802 | } |
| 803 | pieces.removeAll { $0.life <= 0 || $0.y > size.height + 20 } |
| 804 | } |
| 805 | } |
| 806 | |
| 807 | struct ConfettiCanvas: View { |
| 808 | let field: ConfettiField |
| 809 | let tick: Int // changes every frame so the redraw is never skipped |
| 810 | |
| 811 | var body: some View { |
| 812 | Canvas { ctx, _ in |
| 813 | for p in field.pieces where p.delay <= 0 { |
| 814 | var c = ctx |
| 815 | c.translateBy(x: p.x, y: p.y) |
| 816 | c.rotate(by: .radians(p.angle)) |
| 817 | let rect = CGRect(x: -p.w / 2, y: -p.h / 2, width: p.w, height: p.h) |
| 818 | let color = Color(hue: p.hue, saturation: 0.92, brightness: 0.85) |
| 819 | c.fill(Path(rect), with: .color(color.opacity(min(1, p.life / 1.2)))) |
| 820 | } |
| 821 | } |
| 822 | } |
| 823 | } |
| 824 | |
| 825 | // ---- haptics ------------------------------------------------------------------ |
| 826 | |
| 827 | /// The instrument's feel. iOS: CoreHaptics transients β a detent per notch |
| 828 | /// (rising with the volume), a pressure ramp through the hold, a thud at the |
| 829 | /// snap, and irregular "sputtering" ticks for the whole build wait. Falls back |
| 830 | /// to UIImpactFeedbackGenerator when the engine is unavailable. Mac: the |
| 831 | /// trackpad's alignment tick per notch (NSHapticFeedbackManager). |
| 832 | public final class CreateHaptics { |
| 833 | #if os(iOS) |
| 834 | private var engine: CHHapticEngine? |
| 835 | private let impact = UIImpactFeedbackGenerator(style: .rigid) |
| 836 | private let notify = UINotificationFeedbackGenerator() |
| 837 | #endif |
| 838 | private var nextHold: TimeInterval = 0 |
| 839 | private var nextSputter: TimeInterval = 0 |
| 840 | private var nextChaos: TimeInterval = 0 |
| 841 | private var sputtering = false |
| 842 | |
| 843 | public init() {} |
| 844 | |
| 845 | func prepare() { |
| 846 | #if os(iOS) |
| 847 | guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } |
| 848 | engine = try? CHHapticEngine() |
| 849 | engine?.resetHandler = { [weak self] in try? self?.engine?.start() } |
| 850 | try? engine?.start() |
| 851 | impact.prepare() |
| 852 | #endif |
| 853 | } |
| 854 | |
| 855 | /// One transient tap. Intensity/sharpness 0β¦1. |
| 856 | private func tap(intensity: Double, sharpness: Double) { |
| 857 | #if os(iOS) |
| 858 | if let engine, |
| 859 | let pattern = try? CHHapticPattern(events: [CHHapticEvent( |
| 860 | eventType: .hapticTransient, |
| 861 | parameters: [ |
| 862 | CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(intensity)), |
| 863 | CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(sharpness)), |
| 864 | ], relativeTime: 0)], parameters: []), |
| 865 | let player = try? engine.makePlayer(with: pattern) { |
| 866 | try? player.start(atTime: 0) |
| 867 | } else { |
| 868 | impact.impactOccurred(intensity: intensity) |
| 869 | } |
| 870 | #endif |
| 871 | } |
| 872 | |
| 873 | /// A notch crossed: a tick that firms up as the volume climbs. |
| 874 | func detent(_ n: Int) { |
| 875 | tap(intensity: 0.25 + 0.75 * Double(n) / 11, sharpness: 0.5) |
| 876 | #if os(macOS) |
| 877 | NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) |
| 878 | #endif |
| 879 | } |
| 880 | |
| 881 | /// The hold: pulses every ~70ms, harder as pressure builds toward the snap. |
| 882 | func holdPulse(pressure: Double, now: TimeInterval) { |
| 883 | guard now >= nextHold else { return } |
| 884 | nextHold = now + 0.07 |
| 885 | tap(intensity: 0.2 + 0.8 * pressure, sharpness: 0.7) |
| 886 | } |
| 887 | |
| 888 | /// The volume itself, in the hand: a RANDOM MESS of taps β never a steady |
| 889 | /// rhythm. `drive` (0β¦1) sets how busy and how hard: sparse light ticks |
| 890 | /// low on the scale, a dense violent clatter by 10 and through the hold. |
| 891 | func chaos(drive: Double, now: TimeInterval) { |
| 892 | guard drive > 0.03, now >= nextChaos else { return } |
| 893 | let gapLo = max(0.028, 0.16 - 0.12 * drive) |
| 894 | let gapHi = max(gapLo + 0.02, 0.34 - 0.26 * drive) |
| 895 | nextChaos = now + .random(in: gapLo...gapHi) |
| 896 | tap(intensity: min(1, drive * .random(in: 0.35...1.25)), |
| 897 | sharpness: .random(in: 0.15...1)) |
| 898 | } |
| 899 | |
| 900 | /// Eleven. A full-strength thud; the sputter starts on the next tick. |
| 901 | func snap() { |
| 902 | tap(intensity: 1, sharpness: 1) |
| 903 | sputtering = true |
| 904 | #if os(macOS) |
| 905 | NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .now) |
| 906 | #endif |
| 907 | } |
| 908 | |
| 909 | /// The build wait: irregular "spitty" transients β random strength and |
| 910 | /// spacing, like a machine straining β until success/failure stops it. |
| 911 | func sputterTick(now: TimeInterval) { |
| 912 | guard sputtering, now >= nextSputter else { return } |
| 913 | nextSputter = now + .random(in: 0.05...0.19) |
| 914 | tap(intensity: .random(in: 0.25...1), sharpness: .random(in: 0.3...1)) |
| 915 | } |
| 916 | |
| 917 | func stopSputter() { sputtering = false } |
| 918 | |
| 919 | func stopAll() { |
| 920 | stopSputter() |
| 921 | #if os(iOS) |
| 922 | // The engine keeps a hardware session alive until stopped β dismissal |
| 923 | // must actually shut it down, not just quiet the pattern flags. |
| 924 | engine?.stop() |
| 925 | engine = nil |
| 926 | #endif |
| 927 | } |
| 928 | |
| 929 | func success() { |
| 930 | stopSputter() |
| 931 | #if os(iOS) |
| 932 | notify.notificationOccurred(.success) |
| 933 | #endif |
| 934 | } |
| 935 | } |
| 936 | |