moodlit-native/Sources/Core/Sync/PresenceState.swift
till bb7415b7ac feat(presence): μ-8.3 iOS+macOS PresenceClient + Banner-UI
Cross-Device-Live-Sync gegen mana-presence (Port 3079).

Was geht:
- `PresenceClient` @Observable + interner Actor:
  - PUT /v1/sessions bei Player-Open/Pause/Close mit MoodlitSessionPayload
  - SSE /v1/stream/moodlit als langlebige URLSession.bytes-Connection
  - Reconnect mit exponential backoff (500ms → 30s)
  - Last-Event-ID-Catch-Up
  - Echo-Filter: eigene originDevice-ID wird in remote ignoriert
  - 401-Retry mit Refresh-Token
- AppConfig.presenceBaseURL (prod presence.mana.how, MANA_PRESENCE_URL-Override)
- MoodlitNativeApp injiziert PresenceClient als Environment
- RootView: connect on .active + signedIn, disconnect on .background + signedOut
- MoodPlayerView: publishSession on open/pause/close, endSession on close
- MoodListView: "Läuft auf einem anderen Gerät"-Banner + Tap-to-Mirror

UX-Entscheidung V1: Banner zeigt remote-Mood wenn lokaler Player zu ist.
Auto-Switch innerhalb laufender Player kommt in μ-8.5 (Bidirectional-Mirror).
Eigenes Device-Echo wird per UUID in App-Group-UserDefaults gefiltert.

Build iOS+macOS grün, 11/11 Tests grün.

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

66 lines
1.9 KiB
Swift

import Foundation
/// Wire-Format der Mood-Session in `mana-presence` für `appId="moodlit"`.
/// Schema-Ownership liegt bei Moodlit `mana-presence` validiert nur
/// Größe und broadcastet 1:1. Sollte sich das Format ändern, hier UND
/// `Code/moodlit/apps/web/src/lib/sync/presence.ts` UND `apps/api/...`
/// synchron mitziehen.
public struct MoodlitSessionPayload: Codable, Sendable, Equatable {
public let moodId: String
public let colors: [String]
public let animation: String
public let brightness: Int
public let speed: String
public init(moodId: String, colors: [String], animation: String, brightness: Int, speed: String) {
self.moodId = moodId
self.colors = colors
self.animation = animation
self.brightness = brightness
self.speed = speed
}
}
/// Server-Repräsentation einer aktiven Session wird via GET / PUT
/// REST und via SSE-Stream übertragen.
public struct PresenceSession: Codable, Sendable, Equatable {
public let userId: String
public let appId: String
public let payload: MoodlitSessionPayload
public let source: String
public let sourceRef: String?
public let originDevice: String
public let isPaused: Bool
public let startedAt: String
public let updatedAt: String
public let revision: Int64
}
/// Body für `PUT /v1/sessions`.
public struct PresenceUpsertBody: Encodable, Sendable {
public let appId: String
public let payload: MoodlitSessionPayload
public let source: String
public let sourceRef: String?
public let originDevice: String
public let isPaused: Bool?
public let startedAt: String?
public init(
appId: String = "moodlit",
payload: MoodlitSessionPayload,
source: String,
sourceRef: String? = nil,
originDevice: String,
isPaused: Bool? = nil,
startedAt: String? = nil
) {
self.appId = appId
self.payload = payload
self.source = source
self.sourceRef = sourceRef
self.originDevice = originDevice
self.isPaused = isPaused
self.startedAt = startedAt
}
}