Files
tabatago/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift
Millian Lamiaux b0d364eca2
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
feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift
## 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
2026-04-25 23:51:46 +02:00

136 lines
5.3 KiB
Swift

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]