feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2
@@ -1,7 +1,7 @@
|
||||
import ActivityKit
|
||||
|
||||
struct MusicActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
var title: String
|
||||
var artist: String
|
||||
var isPlaying: Bool
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user