feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 3s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m5s
CI / Deploy Edge Functions (pull_request) Has been skipped

## What changed

### Player Redesign (video-first layout)
- New compact timer ring (110pt) with phase label, replaces 240pt ring
- Auto-hide top bar with block progress dots (3s auto-dismiss)
- Expandable now-playing music pill with skip control
- Bottom control bar with heart rate, play/pause, and skip
- Exercise caption with 'Next' preview during rest phases
- Compact round counter (capsule dots)

### Dynamic Island & Live Activities
- WorkoutLiveActivity widget: expanded, compact, and minimal views
- Phase-colored timers with Text(timerInterval:) countdown
- Shows exercise name, round progress, heart rate, music track
- MusicLiveActivity: standalone music now-playing widget
- LiveActivityMusicBars animated component
- Deep link from Dynamic Island back to app

### Timer Drift Fix (critical)
- Store a stable phaseEndDate once per phase instead of
  recalculating Date() + timeRemaining on every update
- Prevents dynamic island countdown from rubber-banding
  due to 5-second periodic update recalculation drift
- Reset phaseEndDate on phase change and resume from pause
- Guard Live Activity updates behind vm.isRunning to prevent
  premature creation when music track loads before workout start
- Fixes timer showing 0 in Dynamic Island when expanding
  from home screen

### New PlayerViewModel timer engine
- Full phase support: prep, warmup, work, rest,
  interBlockRest, cooldown, complete
- 1-second countdown with audio cues at 3-2-1
- Phase transitions with spring animation and haptics
- HealthKit live session integration
- Workout session recording with completion

### Music Service
- New MusicPlayerViewModel with vibe-based playlist loading
- Track info exposed for Dynamic Island display
- Skip track support from Dynamic Island notification action
- Automatic play/pause based on phase and running state

### Additional
- ZoneHighlightIcon component for HomeTab zone cards
- Updated watchOS localizations with complication strings
- Info.plist updated for widget extension
This commit is contained in:
Millian Lamiaux
2026-04-25 23:51:46 +02:00
parent 7f5ea9c6e9
commit b0d364eca2
17 changed files with 1797 additions and 377 deletions

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TabataGoWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,135 @@
import ActivityKit
import WidgetKit
import SwiftUI
import AppIntents
struct SkipTrackIntent: AppIntent {
static let title: LocalizedStringResource = "Skip Track"
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = URL(string: "tabatago://skipTrack")!
return .result(opensIntent: OpenURLIntent(url))
}
}
struct MusicLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MusicActivityAttributes.self) { context in
// Lock Screen / Banner
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "music.note")
.font(.title3.weight(.semibold))
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text(context.state.title)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(1)
Text(context.state.artist)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if context.state.isPlaying {
LiveActivityMusicBars()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.activityBackgroundTint(.black.opacity(0.9))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 6) {
Image(systemName: "music.note")
.foregroundStyle(.green)
Text(context.state.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
if context.state.isPlaying {
LiveActivityMusicBars()
}
}
.padding(.leading, 8)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.artist)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.trailing) {
Button(intent: SkipTrackIntent()) {
HStack(spacing: 3) {
Image(systemName: "forward.fill")
.font(.system(size: 10, weight: .semibold))
Text("Skip")
.font(.caption2.weight(.medium))
}
.foregroundStyle(.white.opacity(0.6))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.white.opacity(0.15))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.padding(.trailing, 8)
}
} compactLeading: {
// Compact Leading
Image(systemName: "music.note")
.foregroundStyle(.green)
.font(.system(size: 14))
} compactTrailing: {
// Compact Trailing
HStack(spacing: 3) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
Text(context.state.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
}
} minimal: {
// Minimal
Image(systemName: "music.note")
.foregroundStyle(.green)
.font(.system(size: 13))
}
.widgetURL(URL(string: "tabatago://music")!)
}
}
}
// MARK: - LiveActivityMusicBars
struct LiveActivityMusicBars: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 0.45 / Double(barSpeeds.count))) { context in
HStack(spacing: 2) {
ForEach(0..<4, id: \.self) { i in
let t = context.date.timeIntervalSinceReferenceDate
let phase = t * barSpeeds[i] * 2 * .pi
let h = barMin + (barMaxHeights[i] - barMin) * abs(sin(phase))
Capsule()
.fill(.green)
.frame(width: 2, height: h)
}
}
}
}
}
private let barMaxHeights: [CGFloat] = [8, 14, 6, 12]
private let barMin: CGFloat = 3
private let barSpeeds: [Double] = [2.2, 1.8, 2.8, 1.5]

View File

@@ -0,0 +1,10 @@
import WidgetKit
import SwiftUI
@main
struct TabataGoWidgetBundle: WidgetBundle {
var body: some Widget {
WorkoutLiveActivity()
MusicLiveActivity()
}
}

View File

@@ -0,0 +1,155 @@
import ActivityKit
import WidgetKit
import SwiftUI
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in
let phaseColor = Self.colorForPhase(context.state.phase)
let phaseLabel = context.state.phase.capitalized
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 12) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(phaseColor)
.frame(width: 4, height: 38)
VStack(alignment: .leading, spacing: 2) {
Text(context.state.exerciseName)
.font(.headline)
.foregroundStyle(.primary)
.minimumScaleFactor(0.7)
Text(phaseLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(phaseColor)
}
.layoutPriority(1)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.title2.weight(.bold).monospacedDigit())
.foregroundStyle(.primary)
.contentTransition(.numericText(countsDown: true))
Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 12)
Divider()
.padding(.horizontal, 16)
.padding(.vertical, 6)
HStack(spacing: 10) {
Image(systemName: "music.note")
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
Text(context.state.trackTitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if context.state.heartRate > 0 {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.font(.system(size: 10))
.foregroundStyle(.red)
Text("\(Int(context.state.heartRate))")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if context.state.isPlaying {
LiveActivityMusicBars()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.activityBackgroundTint(.black.opacity(0.9))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
let phaseColor = Self.colorForPhase(context.state.phase)
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(phaseColor)
.frame(width: 6, height: 6)
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption2.weight(.semibold))
}
if context.state.heartRate > 0 {
Text("\(Int(context.state.heartRate)) bpm")
.font(.system(size: 9))
.foregroundStyle(.red)
}
}
.padding(.leading, 8)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.exerciseName)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
DynamicIslandExpandedRegion(.trailing) {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 20, weight: .bold).monospacedDigit())
.contentTransition(.numericText(countsDown: true))
.padding(.trailing, 8)
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 6) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
Text(context.state.trackTitle)
.font(.caption2)
.lineLimit(1)
.foregroundStyle(.secondary)
if context.state.isPlaying {
LiveActivityMusicBars()
}
}
.padding(.horizontal, 8)
.padding(.bottom, 4)
}
} compactLeading: {
Circle()
.fill(phaseColor)
.frame(width: 8, height: 8)
} compactTrailing: {
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
.font(.system(size: 12, weight: .medium).monospacedDigit())
.contentTransition(.numericText(countsDown: true))
} minimal: {
Circle()
.fill(phaseColor)
.frame(width: 6, height: 6)
}
.widgetURL(URL(string: "tabatago://workout")!)
}
}
static func colorForPhase(_ phase: String) -> Color {
switch phase {
case "prep": return Color(red: 1.0, green: 0.58, blue: 0.0)
case "work": return Color(red: 1.0, green: 0.42, blue: 0.21)
case "rest", "interBlockRest": return Color(red: 0.35, green: 0.78, blue: 0.98)
case "cooldown": return Color(red: 0.35, green: 0.78, blue: 0.98)
case "complete": return Color(red: 0.19, green: 0.82, blue: 0.35)
default: return .gray
}
}
}