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:
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user