Echtes 50-Hz-Streaming auf Hue-Lampen via DTLS-UDP (Spotify+Hue-
Pfad). Beats kommen damit tight statt 200ms-perceptual-versetzt.
Was:
- Sources/Core/SmartHome/HueEntertainmentClient.swift @MainActor:
- DTLS-PSK-Verbindung via Network.framework (iOS 13+):
NWProtocolTLS.Options + sec_protocol_options_add_pre_shared_key +
append_tls_ciphersuite(TLS_PSK_WITH_AES_128_GCM_SHA256). PSK
identity=username (Bridge-AppKey), PSK key=hex-decoded clientkey
- Frame-Format Hue Entertainment v2: 16-byte Header ("HueStream"
+ version 2.0 + sequence) + 36-byte UUID + N×7-byte Channel-
Records (channel_id + 3×uint16-big-endian RGB)
- pulseBeat(baselineHex, peakHex): peak-Frame sofort, baseline-
Frame nach 80ms via Task.sleep
- ResumeBox @unchecked Sendable als NSLock-Wrapper für nw-callbacks
aus beliebigen Queues (Swift 6 strict-concurrency-compatible)
- HueClient erweitert:
- PairResult struct mit username + clientKey (Bridge schickt clientkey
nur bei generateclientkey:true im Request)
- EntertainmentConfiguration-Listing via GET /clip/v2/resource/
entertainment_configuration
- setEntertainmentActive(configId, on) PUT mit action:"start"|"stop"
- HueController:
- clientKey im Keychain (separater Slot, kSecAttrAccessibleAfter-
FirstUnlock analog appKey)
- selectedEntertainmentId in App-Group-UserDefaults
- startEntertainment(): aktiviert Bridge-Stream + öffnet DTLS,
init-Channels = alle schwarz
- stopEntertainment(): close + deactivate Bridge-Stream
- pulseBeatViaEntertainmentOrClip: routet DTLS oder CLIP-Fallback
- HueSettingsView: neue Section "Entertainment-Area (50 Hz Beat-Sync)"
mit Auswahl-Liste + footer-Erklärung
- MoodPlayerView: onAppear startet Entertainment-Stream (no-op wenn
keine Config gewählt), BeatSubscriber-Callback routet durch
pulseBeatViaEntertainmentOrClip, onDisappear stop
User-Setup-Pfad: Hue-App → Sync → Entertainment-Area erstellen →
in Moodlit-Settings die Area auswählen → beim nächsten Mood-Play
gehen alle Lampen auf DTLS-Stream statt CLIP-Single-Calls.
Fallback: ohne ausgewählte Entertainment-Config bleibt der CLIP-API-
Pulse-Pfad aktiv (alter μ-10.3-Code), kein Bruch in der UX.
Build iOS+macOS BUILD SUCCEEDED, 18/18 Tests grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
9 KiB
Swift
373 lines
9 KiB
Swift
import ManaTokens
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
/// Quelle, aus der ein Player gestartet wurde. Wird im
|
|
/// presence-Payload als `source` Feld mit übertragen.
|
|
public enum MoodPlayerSource: String, Sendable {
|
|
case manual
|
|
case sequence
|
|
case schedule
|
|
case mukke
|
|
case remoteMirror = "remote-mirror"
|
|
}
|
|
|
|
/// Vollbild-Player für ein Mood. Pendant zu
|
|
/// `MoodFullscreen.svelte` aus dem Web — mit dem nativ-only-Plus von
|
|
/// `UIApplication.isIdleTimerDisabled = true` (Bildschirm geht im
|
|
/// Player nicht in den Lock).
|
|
public struct MoodPlayerView: View {
|
|
let mood: Mood
|
|
let isFavorite: Bool
|
|
let brightness: Double
|
|
let speedMultiplier: Double
|
|
let source: MoodPlayerSource
|
|
let onClose: () -> Void
|
|
let onFavoriteToggle: (() -> Void)?
|
|
|
|
@Environment(PresenceClient.self) private var presence
|
|
@Environment(HueController.self) private var hue
|
|
@Environment(LIFXController.self) private var lifx
|
|
@Environment(BeatSubscriber.self) private var beatSubscriber
|
|
#if os(iOS)
|
|
@Environment(HomeKitController.self) private var homeKit
|
|
#endif
|
|
@State private var isPaused = false
|
|
@State private var showControls = true
|
|
@State private var controlsTask: Task<Void, Never>?
|
|
@State private var timerMinutes = 5
|
|
@State private var timerRemaining: Int = 0
|
|
@State private var timerActive = false
|
|
@State private var timerTask: Task<Void, Never>?
|
|
@State private var sessionStartedAt: Date?
|
|
@AppStorage("moodlit.defaultTimerMinutes", store: UserDefaults(suiteName: AppConfig.appGroup))
|
|
private var defaultTimerMinutes: Int = 5
|
|
|
|
public init(
|
|
mood: Mood,
|
|
isFavorite: Bool = false,
|
|
brightness: Double = 1.0,
|
|
speedMultiplier: Double = 1.0,
|
|
source: MoodPlayerSource = .manual,
|
|
onClose: @escaping () -> Void,
|
|
onFavoriteToggle: (() -> Void)? = nil
|
|
) {
|
|
self.mood = mood
|
|
self.isFavorite = isFavorite
|
|
self.brightness = brightness
|
|
self.speedMultiplier = speedMultiplier
|
|
self.source = source
|
|
self.onClose = onClose
|
|
self.onFavoriteToggle = onFavoriteToggle
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack(alignment: .top) {
|
|
AnimatedMoodView(mood: mood, isPaused: isPaused, speedMultiplier: speedMultiplier)
|
|
.brightness(brightness - 1.0) // 1.0 = neutral; <1 dimt, >1 ist out of range
|
|
|
|
if showControls {
|
|
controlsOverlay
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { toggleControls() }
|
|
.onAppear {
|
|
timerMinutes = max(1, defaultTimerMinutes == 0 ? 5 : defaultTimerMinutes)
|
|
revealControls()
|
|
disableIdleTimer(true)
|
|
sessionStartedAt = Date()
|
|
publishSession()
|
|
pushToHue()
|
|
startBeatSubscriber()
|
|
}
|
|
.onDisappear {
|
|
disableIdleTimer(false)
|
|
controlsTask?.cancel()
|
|
timerTask?.cancel()
|
|
Task { await presence.endSession() }
|
|
turnOffHomeLights()
|
|
beatSubscriber.stop()
|
|
Task { await hue.stopEntertainment() }
|
|
}
|
|
.onChange(of: isPaused) { _, _ in
|
|
publishSession()
|
|
if isPaused {
|
|
turnOffHomeLights()
|
|
} else {
|
|
pushToHue()
|
|
}
|
|
}
|
|
.onChange(of: mood.id) { _, _ in
|
|
// Falls SwiftUI die View bei Mood-Wechsel weiterverwendet
|
|
// (statt zu dismissen+neu zu mounten), publishen wir den
|
|
// neuen State trotzdem — sonst sieht der Server uns als
|
|
// "spielt noch alten Mood".
|
|
sessionStartedAt = Date()
|
|
publishSession()
|
|
pushToHue()
|
|
}
|
|
#if os(iOS)
|
|
.statusBarHidden(true)
|
|
.persistentSystemOverlays(.hidden)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var controlsOverlay: some View {
|
|
VStack(spacing: 0) {
|
|
topBar
|
|
Spacer()
|
|
playPauseButton
|
|
Spacer()
|
|
bottomBar
|
|
}
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
private var topBar: some View {
|
|
HStack {
|
|
Button(action: handleClose) {
|
|
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(mood.name)
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.shadow(radius: 4)
|
|
Text(mood.animation.displayName)
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.75))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if timerActive {
|
|
Text(formatTime(timerRemaining))
|
|
.font(.system(.body, design: .monospaced).weight(.medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(.white.opacity(0.20), in: Capsule())
|
|
}
|
|
|
|
if let onFavoriteToggle {
|
|
Button(action: onFavoriteToggle) {
|
|
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
|
.font(.title3)
|
|
.foregroundStyle(isFavorite ? .red : .white)
|
|
.padding(10)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
private var playPauseButton: some View {
|
|
Button {
|
|
isPaused.toggle()
|
|
revealControls()
|
|
} label: {
|
|
Image(systemName: isPaused ? "play.fill" : "pause.fill")
|
|
.font(.system(size: 48, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(32)
|
|
.background(.white.opacity(0.20), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var bottomBar: some View {
|
|
HStack(spacing: 12) {
|
|
Spacer()
|
|
if !timerActive {
|
|
timerStartControl
|
|
} else {
|
|
Button("Stop") { stopTimer() }
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(.white.opacity(0.20), in: Capsule())
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var timerStartControl: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "timer")
|
|
.foregroundStyle(.white)
|
|
Picker("Timer", selection: $timerMinutes) {
|
|
Text("1 min").tag(1)
|
|
Text("5 min").tag(5)
|
|
Text("10 min").tag(10)
|
|
Text("15 min").tag(15)
|
|
Text("30 min").tag(30)
|
|
Text("60 min").tag(60)
|
|
}
|
|
.pickerStyle(.menu)
|
|
.tint(.white)
|
|
|
|
Button("Start") { startTimer() }
|
|
.font(.callout.weight(.medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(.white.opacity(0.20), in: Capsule())
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(.white.opacity(0.12), in: Capsule())
|
|
}
|
|
|
|
// MARK: - Behavior
|
|
|
|
private func handleClose() {
|
|
stopTimer()
|
|
onClose()
|
|
}
|
|
|
|
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 toggleControls() {
|
|
if showControls {
|
|
controlsTask?.cancel()
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
showControls = false
|
|
}
|
|
} else {
|
|
revealControls()
|
|
}
|
|
}
|
|
|
|
private func startTimer() {
|
|
timerActive = true
|
|
timerRemaining = timerMinutes * 60
|
|
timerTask?.cancel()
|
|
timerTask = Task { @MainActor in
|
|
while timerRemaining > 0 && !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
if Task.isCancelled { return }
|
|
timerRemaining -= 1
|
|
}
|
|
if timerActive {
|
|
handleClose()
|
|
}
|
|
}
|
|
revealControls()
|
|
}
|
|
|
|
private func stopTimer() {
|
|
timerActive = false
|
|
timerTask?.cancel()
|
|
timerTask = nil
|
|
}
|
|
|
|
private func formatTime(_ seconds: Int) -> String {
|
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
|
}
|
|
|
|
private func disableIdleTimer(_ disabled: Bool) {
|
|
#if os(iOS)
|
|
UIApplication.shared.isIdleTimerDisabled = disabled
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Presence-Sync
|
|
|
|
private func publishSession() {
|
|
let payload = MoodlitSessionPayload(
|
|
moodId: mood.id,
|
|
colors: mood.colors,
|
|
animation: mood.animation.rawValue,
|
|
brightness: Int((brightness * 100).rounded()),
|
|
speed: speedLabel(speedMultiplier)
|
|
)
|
|
let started = sessionStartedAt
|
|
let paused = isPaused
|
|
Task {
|
|
do {
|
|
_ = try await presence.publish(
|
|
payload: payload,
|
|
source: source.rawValue,
|
|
isPaused: paused,
|
|
startedAt: started
|
|
)
|
|
} catch {
|
|
// Silent — Presence-Verlust soll Player nicht stören
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pushToHue() {
|
|
let brightnessPct = Int((brightness * 100).rounded())
|
|
hue.applyMood(colors: mood.colors, brightnessPct: brightnessPct)
|
|
lifx.applyMood(colors: mood.colors, brightnessPct: brightnessPct)
|
|
pushToHomeKit(brightnessPct: brightnessPct)
|
|
}
|
|
|
|
private func pushToHomeKit(brightnessPct: Int) {
|
|
#if os(iOS)
|
|
homeKit.applyMood(colors: mood.colors, brightnessPct: brightnessPct)
|
|
#endif
|
|
}
|
|
|
|
private func startBeatSubscriber() {
|
|
let baseline = Int((brightness * 100).rounded())
|
|
let baselineHex = mood.colors.first ?? "#ffffff"
|
|
// DTLS-Entertainment-Stream öffnen (no-op wenn keine Config gewählt)
|
|
Task { await hue.startEntertainment() }
|
|
beatSubscriber.start { [hue] _ in
|
|
hue.pulseBeatViaEntertainmentOrClip(
|
|
baselineHex: baselineHex,
|
|
baselineBrightnessPct: baseline
|
|
)
|
|
}
|
|
}
|
|
|
|
private func turnOffHomeLights() {
|
|
hue.turnOffSelected()
|
|
lifx.turnOffSelected()
|
|
#if os(iOS)
|
|
homeKit.turnOffSelected()
|
|
#endif
|
|
}
|
|
|
|
private func speedLabel(_ multiplier: Double) -> String {
|
|
switch multiplier {
|
|
case ..<0.85: return "slow"
|
|
case 0.85...1.15: return "normal"
|
|
default: return "fast"
|
|
}
|
|
}
|
|
}
|