moodlit-native/Sources/Features/Sequences/SequencePlayerView.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

176 lines
4.4 KiB
Swift

import SwiftUI
#if os(iOS)
import UIKit
#endif
/// Spielt eine Sequence im Vollbild: rotiert durch alle `moods`, jedes
/// für `sequence.durationSec` Sekunden, mit `sequence.transitionSec`-
/// Sekunden Crossfade. Loop, bis User schließt.
///
/// **Net new gegenüber Web** die SvelteKit-App hat keinen Sequence-
/// Player. Hier ist's der Killer-Use-Case für die Native-App.
public struct SequencePlayerView: View {
let sequence: MoodSequence
let moods: [Mood]
let onClose: () -> Void
@State private var currentIndex: Int = 0
@State private var rotationTask: Task<Void, Never>?
@State private var showControls = true
@State private var controlsTask: Task<Void, Never>?
@State private var isPaused = false
public init(sequence: MoodSequence, moods: [Mood], onClose: @escaping () -> Void) {
self.sequence = sequence
self.moods = moods
self.onClose = onClose
}
public var body: some View {
ZStack(alignment: .top) {
// Crossfade durch ZStack mit opacity-Transition zwischen
// zwei Layers.
ForEach(Array(moods.enumerated()), id: \.element.id) { idx, mood in
AnimatedMoodView(mood: mood, isPaused: isPaused)
.opacity(idx == currentIndex ? 1 : 0)
.animation(
.easeInOut(duration: Double(sequence.transitionSec)),
value: currentIndex
)
}
if showControls {
controlsOverlay
.transition(.opacity)
}
}
.contentShape(Rectangle())
.onTapGesture { revealControls() }
.onAppear {
revealControls()
disableIdleTimer(true)
startRotation()
}
.onDisappear {
rotationTask?.cancel()
controlsTask?.cancel()
disableIdleTimer(false)
}
.onChange(of: isPaused) { _, paused in
if paused {
rotationTask?.cancel()
} else {
startRotation()
}
}
#if os(iOS)
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
#endif
}
private var controlsOverlay: some View {
VStack {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.padding(10)
.background(.white.opacity(0.20), in: Circle())
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
Text(sequence.name)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.shadow(radius: 4)
if moods.indices.contains(currentIndex) {
Text("\(currentIndex + 1) / \(moods.count) · \(moods[currentIndex].name)")
.font(.caption)
.foregroundStyle(.white.opacity(0.75))
}
}
Spacer()
}
.padding(16)
Spacer()
HStack(spacing: 24) {
Button { advance(-1) } label: {
Image(systemName: "backward.fill")
.font(.title)
.foregroundStyle(.white)
.padding(16)
.background(.white.opacity(0.20), in: Circle())
}
.buttonStyle(.plain)
Button { isPaused.toggle(); revealControls() } label: {
Image(systemName: isPaused ? "play.fill" : "pause.fill")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.white)
.padding(24)
.background(.white.opacity(0.20), in: Circle())
}
.buttonStyle(.plain)
Button { advance(1) } label: {
Image(systemName: "forward.fill")
.font(.title)
.foregroundStyle(.white)
.padding(16)
.background(.white.opacity(0.20), in: Circle())
}
.buttonStyle(.plain)
}
.padding(.bottom, 32)
}
}
private func startRotation() {
rotationTask?.cancel()
guard moods.count > 1, !isPaused else { return }
rotationTask = Task { @MainActor in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(sequence.durationSec))
if Task.isCancelled { return }
currentIndex = (currentIndex + 1) % moods.count
}
}
}
private func advance(_ delta: Int) {
guard !moods.isEmpty else { return }
currentIndex = (currentIndex + delta + moods.count) % moods.count
startRotation() // Timer-Reset
revealControls()
}
private func revealControls() {
withAnimation(.easeInOut(duration: 0.2)) {
showControls = true
}
controlsTask?.cancel()
controlsTask = Task {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
await MainActor.run {
if !isPaused {
withAnimation(.easeInOut(duration: 0.4)) {
showControls = false
}
}
}
}
}
private func disableIdleTimer(_ disabled: Bool) {
#if os(iOS)
UIApplication.shared.isIdleTimerDisabled = disabled
#endif
}
}