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
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:
31
tabatago-swift/TabataGoWidget/Info.plist
Normal file
31
tabatago-swift/TabataGoWidget/Info.plist
Normal 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>
|
||||
135
tabatago-swift/TabataGoWidget/MusicLiveActivity.swift
Normal file
135
tabatago-swift/TabataGoWidget/MusicLiveActivity.swift
Normal 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]
|
||||
10
tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift
Normal file
10
tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TabataGoWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
WorkoutLiveActivity()
|
||||
MusicLiveActivity()
|
||||
}
|
||||
}
|
||||
155
tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift
Normal file
155
tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user