moodlit-native/Sources/Features/Player/AnimatedMoodView.swift
till bbfdff7e3c μ-7.0: Initial moodlit-native Skelett (Pure-Native iOS+macOS)
Pure-Native SwiftUI-App für Moodlit. Pendant zur SvelteKit-Web-App
auf moodlit.mana.how; konsumiert ManaCore + ManaTokens + ManaAuthUI
aus den Schwester-Repos.

Stack:
- SwiftUI Universal (iOS 18 / macOS 15), Swift 6 strict concurrency
- mana-swift-core + mana-swift-ui (lokale SPM-Pakete via XcodeGen)
- Bundle ev.mana.moodlit, Team QP3GLU8PH3, App-Group group.ev.mana.moodlit

Features:
- 24 Mood-Presets als Swift-Konstanten (Port von default-moods.ts)
- Custom-Moods + Sequenzen via MoodlitAPI (Actor mit JWT-Bearer-Calls
  über AuthenticatedTransport, automatischer 401-Retry)
- MoodPlayerView mit Idle-Timer-Off, Status-Bar-Hidden, Timer-Auto-
  Close, Favorite-Toggle, Play/Pause, Auto-Hide-Controls
- SequencePlayerView mit Crossfade-Rotation durch alle Sequence-Moods
  (Net new ggü. Web — dort ist Sequence-Playback nicht verkabelt)
- AnimatedMoodView rendert alle 21 AnimationTypes als 30-fps Timeline-
  View mit sin/cos-modulierten Filter-Effekten
- Cards-Pattern Auth-Gate: Presets ohne Login sichtbar, Custom-
  Creation triggert ManaAuthGate.require → Login-Sheet
- Theme: ManaTheme.twilight Forward (Violett #7c3aed)

Build verified:
- xcodebuild iOS Simulator (iPhone 17) → BUILD SUCCEEDED
- xcodebuild macOS → BUILD SUCCEEDED

Offen (μ-7.1+): Apple-Dev-Portal-Setup (Bundle, Capabilities), TestFlight,
Widget, Settings-UI (Brightness/Speed), Hex-Color-Picker mit Text-Input,
Visual-Polish der per-Animation Effekte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:01:04 +02:00

179 lines
5.2 KiB
Swift

import SwiftUI
/// Vollbild-Renderer für ein Mood. Rendert die Hex-Farben als
/// `LinearGradient` (135°, wie das Web-`linear-gradient(135deg, ...)`)
/// und überlagert je `AnimationType` einen TimelineView-getriebenen
/// Effekt.
///
/// Pendant zu `MoodFullscreen.svelte` + den CSS-Keyframes in
/// `Code/moodlit/apps/web/src/app.css`. Effekte sind in einer
/// generischen `effectModifier(...)`-Funktion gebündelt, damit das
/// File übersichtlich bleibt neue Animation-Typen ergänzen ohne
/// die ganze View umzuschreiben.
public struct AnimatedMoodView: View {
let mood: Mood
let isPaused: Bool
let speedMultiplier: Double
public init(mood: Mood, isPaused: Bool = false, speedMultiplier: Double = 1.0) {
self.mood = mood
self.isPaused = isPaused
self.speedMultiplier = speedMultiplier
}
public var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: isPaused)) { timeline in
let elapsed = timeline.date.timeIntervalSinceReferenceDate * speedMultiplier
ZStack {
baseGradient
.modifier(
MoodEffectModifier(animation: mood.animation, elapsed: elapsed)
)
// Bottom-fade-Overlay für bessere Text-Lesbarkeit im
// Player (matchend zum Web-Overlay).
LinearGradient(
colors: [.black.opacity(0.55), .clear],
startPoint: .bottom,
endPoint: .center
)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
}
@ViewBuilder
private var baseGradient: some View {
if mood.colors.count == 1 {
Color(hex: mood.colors[0])
} else {
LinearGradient(
colors: mood.swiftUIColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
/// SOS-Morse-Pattern (... --- ...). Index ungerade = aus, gerade = an.
/// 2s Pause am Ende des Zyklus.
private func sosOpacity(at elapsed: TimeInterval) -> Double {
let pattern: [TimeInterval] = [
0.2, 0.2, 0.2, 0.2, 0.2, 0.2, // 3 kurze Blitze + Pausen
0.6, 0.2, 0.6, 0.2, 0.6, 0.2, // 3 lange Blitze + Pausen
0.2, 0.2, 0.2, 0.2, 0.2, 0.2, // 3 kurze Blitze + Pausen
2.0 // Pause vor Loop
]
let total = pattern.reduce(0, +)
let t = elapsed.truncatingRemainder(dividingBy: total)
var acc: TimeInterval = 0
for (i, dur) in pattern.enumerated() {
if t < acc + dur { return i.isMultiple(of: 2) ? 1.0 : 0.05 }
acc += dur
}
return 1.0
}
/// Time-basierter Effekt-Modifier, dispatched auf AnimationType.
/// `elapsed` ist Sekunden seit Referenz modulo-Cycle pro Effekt.
private struct MoodEffectModifier: ViewModifier {
let animation: AnimationType
let elapsed: TimeInterval
func body(content: Content) -> some View {
switch animation {
case .breath, .pulse, .forest:
// 4s Atem-Cycle: scale 0.951.0, opacity 0.851.0
let t = (sin(elapsed * .pi * 2 / 4) + 1) / 2
content
.scaleEffect(0.95 + 0.05 * t)
.opacity(0.85 + 0.15 * t)
case .wave, .ocean, .aurora:
// 6s wave horizontal shift via mask offset (simuliert via brightness wave)
let t = (sin(elapsed * .pi * 2 / 6) + 1) / 2
content
.brightness(-0.05 + 0.10 * t)
.saturation(0.85 + 0.30 * t)
case .candle, .fire:
// 0.8s flicker via brightness jitter
let t = sin(elapsed * .pi * 2 / 0.8) * 0.5 + sin(elapsed * .pi * 2 / 0.3) * 0.3
content
.brightness(t * 0.10)
.opacity(0.92 + abs(t) * 0.08)
case .disco, .rave:
// 2s hue rotation
let degrees = (elapsed.truncatingRemainder(dividingBy: 2.0)) / 2.0 * 360
content
.hueRotation(.degrees(degrees))
.saturation(1.2)
case .thunder:
// 6s cycle: dark, dark, FLASH, dark...
let phase = elapsed.truncatingRemainder(dividingBy: 6.0)
let brightness = (phase > 3.0 && phase < 3.1) ? 0.6 : -0.3
content.brightness(brightness)
case .police:
// 0.6s redblue color shift via hueRotation
let t = sin(elapsed * .pi * 2 / 0.6)
content
.hueRotation(.degrees(t * 60))
.brightness(t * 0.15)
case .warning:
// 1s yellow/orange pulse
let t = (sin(elapsed * .pi * 2 / 1.0) + 1) / 2
content
.brightness(-0.1 + 0.2 * t)
case .flash:
// 0.5s strobe
let t = elapsed.truncatingRemainder(dividingBy: 0.5)
content.opacity(t < 0.05 ? 0.0 : 1.0)
case .sos:
// Morse-Pattern: ... --- ... (kurz-kurz-kurz lang-lang-lang kurz-kurz-kurz)
content.opacity(sosOpacity(at: elapsed))
case .scanner:
// 2s horizontal sweep via brightness gradient (vereinfacht: Pulse)
let t = (sin(elapsed * .pi * 2 / 2) + 1) / 2
content.brightness(-0.3 + 0.6 * t)
case .matrix:
// 1.2s steps brightness
let phase = Int(elapsed * 5) % 2
content.brightness(phase == 0 ? -0.3 : 0.3)
case .sunrise:
// 12s ease: brightness 0.31.01.2 + saturate 0.51.2
let t = (sin(elapsed * .pi * 2 / 12 - .pi / 2) + 1) / 2
content
.brightness(-0.7 + t * 0.9)
.saturation(0.5 + t * 0.7)
case .sunset:
// Inversion von sunrise
let t = (cos(elapsed * .pi * 2 / 12 - .pi / 2) + 1) / 2
content
.brightness(-0.7 + t * 0.9)
.saturation(0.5 + t * 0.7)
case .sparkle:
// 1.4s brightness puls
let t = (sin(elapsed * .pi * 2 / 1.4) + 1) / 2
content
.brightness(-0.05 + 0.45 * t)
.saturation(1.0 + 0.3 * t)
case .gradient:
// 8s subtle hue-rotation
let degrees = (elapsed.truncatingRemainder(dividingBy: 8.0)) / 8.0 * 30
content.hueRotation(.degrees(degrees))
}
}
}