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:
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]
|
||||
Reference in New Issue
Block a user