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
136 lines
5.3 KiB
Swift
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]
|