fix: move Live Activity ownership to ViewModel, fix timer-at-0 and background freeze

**Architecture (PlayerViewModel):**
- Move ActivityKit lifecycle from SwiftUI View to ViewModel (MVVM correction)
- call syncActivity() at END of enterPhase() — after all state is set,
  eliminating the race where phase was Published before timeRemaining
- Always recalculate phaseEndDate = Date() + timeRemaining (no stale cache)
- Dedicated Timer in ViewModel for periodic heart-rate/track sync (5s)
- Start/stop activity sync timer on play/pause/resume/abandon/finish
- stale activity reference discard + recreate-on-failure fallback
- Modern iOS 16.2+ API: ActivityContent, non-throwing update()

**PlayerView:**
- Remove all ActivityKit code (import, @State workoutActivity,
  phaseEndDate, dynamicIslandAvailable, 4 methods, .onReceive timer)
- Delegate to ViewModel: onChange(musicVM.currentTrack) sets vm.trackTitle/Artist
  and calls vm.syncActivity(); onDisappear calls await vm.endActivity()
- Music/audio onChange handlers no longer contain activity logic

**Info.plist:**
- Add UIBackgroundModes → audio so music continues and app stays alive
  in background, allowing Timer-based activity updates
- Widget Info.plist: add NSSupportsLiveActivitiesFrequentUpdates

**WorkoutActivityAttributes.ContentState:**
- Add Sendable conformance for Swift 6 strict concurrency

Fixes: timer stuck at 0 on first work phase, exercise name missing,
       music stopping in background, Dynamic Island freezing in background,
       widget drift due to cached phaseEndDate
This commit is contained in:
Millian Lamiaux
2026-05-03 15:40:36 +02:00
parent b0d364eca2
commit c715c797f9
5 changed files with 115 additions and 82 deletions

View File

@@ -1,5 +1,4 @@
import SwiftUI
@preconcurrency import ActivityKit
/// Full-screen Tabata workout player video-first layout with overlay timer.
struct PlayerView: View {
@@ -12,8 +11,6 @@ struct PlayerView: View {
@State private var topBarVisible = true
@State private var nowPlayingExpanded = false
@State private var autoHideTask: Task<Void, Never>?
@State private var workoutActivity: Activity<WorkoutActivityAttributes>?
@State private var phaseEndDate: Date?
init(program: WorkoutProgram) {
self.program = program
@@ -142,36 +139,29 @@ struct PlayerView: View {
autoHideTask?.cancel()
UIApplication.shared.isIdleTimerDisabled = false
musicVM.stop()
endWorkoutActivity()
Task { await vm.endActivity() }
}
.onChange(of: vm.isRunning) { _, running in
let musicPhase = vm.phase == .work || vm.phase == .rest
let shouldPlay = running && !vm.isPaused && musicPhase
musicVM.setPlaying(shouldPlay)
if running { phaseEndDate = nil }
updateWorkoutActivity()
}
.onChange(of: vm.isPaused) { _, paused in
let musicPhase = vm.phase == .work || vm.phase == .rest
let shouldPlay = vm.isRunning && !paused && musicPhase
musicVM.setPlaying(shouldPlay)
if !paused { phaseEndDate = nil }
updateWorkoutActivity()
if paused { showTopBar() }
}
.onChange(of: vm.phase) { _, phase in
let musicPhase = phase == .work || phase == .rest
let shouldPlay = vm.isRunning && !vm.isPaused && musicPhase
musicVM.setPlaying(shouldPlay)
phaseEndDate = nil
updateWorkoutActivity()
showTopBar()
}
.onChange(of: musicVM.currentTrack) { _, _ in
updateWorkoutActivity()
}
.onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in
updateWorkoutActivity()
.onChange(of: musicVM.currentTrack) { _, track in
vm.currentTrackTitle = track?.title ?? ""
vm.currentTrackArtist = track?.artist ?? ""
vm.syncActivity()
}
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
musicVM.skipTrack()
@@ -204,70 +194,6 @@ struct PlayerView: View {
}
}
// Dynamic Island
private var dynamicIslandAvailable: Bool {
#if targetEnvironment(simulator)
true
#else
ActivityAuthorizationInfo().areActivitiesEnabled
#endif
}
@MainActor
private func updateWorkoutActivity() {
guard dynamicIslandAvailable else { return }
guard vm.isRunning else { return }
let phaseEnd: Date
if let stored = phaseEndDate {
phaseEnd = stored
} else {
let calculated = Date().addingTimeInterval(Double(vm.timeRemaining))
phaseEndDate = calculated
phaseEnd = calculated
}
let isPlaying = (vm.phase == .work || vm.phase == .rest) && vm.isRunning && !vm.isPaused
let track = musicVM.currentTrack
let state = WorkoutActivityAttributes.ContentState(
exerciseName: vm.currentExercise?.nameEn ?? "",
phase: vm.phase.rawValue,
phaseEndDate: phaseEnd,
roundCurrent: vm.currentRound,
roundTotal: vm.totalRoundsInBlock,
heartRate: vm.heartRate,
trackTitle: track?.title ?? "",
trackArtist: track?.artist ?? "",
isPlaying: isPlaying
)
if let existing = workoutActivity {
Task { await existing.update(using: state) }
} else {
let attrs = WorkoutActivityAttributes()
workoutActivity = try? Activity.request(attributes: attrs, contentState: state, pushType: nil)
}
}
@MainActor
private func endWorkoutActivity() {
guard let activity = workoutActivity else { return }
let state = WorkoutActivityAttributes.ContentState(
exerciseName: activity.contentState.exerciseName,
phase: activity.contentState.phase,
phaseEndDate: activity.contentState.phaseEndDate,
roundCurrent: activity.contentState.roundCurrent,
roundTotal: activity.contentState.roundTotal,
heartRate: activity.contentState.heartRate,
trackTitle: activity.contentState.trackTitle,
trackArtist: activity.contentState.trackArtist,
isPlaying: false
)
Task {
await activity.end(using: state, dismissalPolicy: .immediate)
workoutActivity = nil
}
}
}
// Sub-components