fix: Live Activity concurrency and state observation

- Add Sendable conformance to MusicActivityAttributes.ContentState
- Remove @preconcurrency on ActivityKit import
- Use nonisolated(unsafe) guards for Activity refs in task closures
- Add observeActivityState() to handle stale/ended/dismissed activity states
- Set staleDate (120s) instead of nil for push notification support
This commit is contained in:
Millian Lamiaux
2026-05-15 22:41:04 +02:00
parent 03f660958f
commit 71de3c0aa7
2 changed files with 36 additions and 14 deletions

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftData
import SwiftUI
import UIKit
@preconcurrency import ActivityKit
import ActivityKit
// Timer Phase
@@ -52,6 +52,7 @@ final class PlayerViewModel: ObservableObject {
private var liveSession = LiveWorkoutSession()
private var workoutActivity: Activity<WorkoutActivityAttributes>?
private var activitySyncTimer: Timer?
private var activityStateTask: Task<Void, Never>?
// Warmup phase index (step through warmup movements)
private var warmupIndex: Int = 0
@@ -388,8 +389,9 @@ final class PlayerViewModel: ObservableObject {
}
if let existing = workoutActivity {
nonisolated(unsafe) let safeExisting = existing
Task { @MainActor in
await existing.update(ActivityContent(state: state, staleDate: nil))
await safeExisting.update(ActivityContent(state: state, staleDate: Date().addingTimeInterval(120)))
}
} else {
createOrUpdateActivity(with: state)
@@ -401,29 +403,33 @@ final class PlayerViewModel: ObservableObject {
do {
workoutActivity = try Activity.request(
attributes: attrs,
content: ActivityContent(state: state, staleDate: nil)
content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(120))
)
observeActivityState()
} catch {
// Will retry on next syncActivity() call
print("❌ Failed to start Live Activity: \(error.localizedDescription)")
}
}
func endActivity() async {
guard let activity = workoutActivity else { return }
workoutActivity = nil
guard activity.activityState == .active else { return }
activityStateTask?.cancel()
activityStateTask = nil
nonisolated(unsafe) let safeActivity = activity
guard safeActivity.activityState == .active else { return }
let finalState = WorkoutActivityAttributes.ContentState(
exerciseName: activity.content.state.exerciseName,
exerciseName: safeActivity.content.state.exerciseName,
phase: "complete",
phaseEndDate: activity.content.state.phaseEndDate,
roundCurrent: activity.content.state.roundCurrent,
roundTotal: activity.content.state.roundTotal,
heartRate: activity.content.state.heartRate,
trackTitle: activity.content.state.trackTitle,
trackArtist: activity.content.state.trackArtist,
phaseEndDate: safeActivity.content.state.phaseEndDate,
roundCurrent: safeActivity.content.state.roundCurrent,
roundTotal: safeActivity.content.state.roundTotal,
heartRate: safeActivity.content.state.heartRate,
trackTitle: safeActivity.content.state.trackTitle,
trackArtist: safeActivity.content.state.trackArtist,
isPlaying: false
)
await activity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}
private func startActivitySyncTimer() {
@@ -438,5 +444,21 @@ final class PlayerViewModel: ObservableObject {
private func stopActivitySyncTimer() {
activitySyncTimer?.invalidate()
activitySyncTimer = nil
activityStateTask?.cancel()
activityStateTask = nil
}
private func observeActivityState() {
guard let activity = workoutActivity else { return }
activityStateTask?.cancel()
activityStateTask = Task { @MainActor [weak self] in
for await state in activity.activityStateUpdates {
guard let self else { return }
if state == .stale || state == .ended || state == .dismissed {
self.workoutActivity = nil
self.stopActivitySyncTimer()
}
}
}
}
}