feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2

Merged
millianlmx merged 18 commits from revamp-timer-video-layout into main 2026-05-23 12:24:34 +02:00
Owner

Summary

  • Player view redesigned as a video-first layout with compact UI — smaller timer ring, auto-hide top bar, expandable music pill, and bottom control bar
  • Dynamic Island support via WorkoutLiveActivity and MusicLiveActivity widgets — shows exercise name, countdown timer, round progress, heart rate, and music track
  • Fixes Live Activity timer drift — the Dynamic Island countdown no longer rubber-bands or shows 0 due to stale phaseEndDate recalculation

Root Cause (Timer Drift)

The previous implementation recalculated phaseEndDate = Date() + timeRemaining on every call to updateWorkoutActivity() — which fired from 5 different triggers (phase change, running state, paused state, track change, every 5 seconds). Each recalculation shifted the target date slightly, causing the Dynamic Island's Text(timerInterval:) display to jump or drift relative to the in-app timer.
A second bug caused premature Live Activity creation: updateWorkoutActivity() was called when musicVM.currentTrack loaded (before the user tapped play), storing a phaseEndDate based on the prep phase's 5-second duration. When the workout actually started seconds later, the stored stale date was reused, producing a timer at 0.

Fix

  • Stored phaseEndDate as @State — calculated once per phase via phaseEndDate = nil on phase change and resume, then locked for subsequent updates
  • Guarded updateWorkoutActivity() behind vm.isRunning — prevents Live Activity from being created or updated before the workout starts

Files Changed

File Change
PlayerView.swift Full redesign + timer drift fix
PlayerViewModel.swift New timer engine with full phase support
WorkoutLiveActivity.swift Dynamic Island widget (new)
MusicLiveActivity.swift Music now-playing widget (new)
WorkoutActivityAttributes.swift ActivityKit model (new)
MusicActivityAttributes.swift ActivityKit model (new)
Theme.swift New compact timer fonts, phase colors
MusicService.swift Vibe-based playlist loading
HomeTab.swift ZoneHighlightIcon component
ZoneHighlightIcon.swift SF Symbol per body zone (new)
TabataGoApp.swift Updated entry point
Info.plist Widget extension config
Watch/iOS Localizable.xcstrings Updated translations
## Summary - **Player view redesigned** as a video-first layout with compact UI — smaller timer ring, auto-hide top bar, expandable music pill, and bottom control bar - **Dynamic Island support** via `WorkoutLiveActivity` and `MusicLiveActivity` widgets — shows exercise name, countdown timer, round progress, heart rate, and music track - **Fixes Live Activity timer drift** — the Dynamic Island countdown no longer rubber-bands or shows 0 due to stale `phaseEndDate` recalculation ## Root Cause (Timer Drift) The previous implementation recalculated `phaseEndDate = Date() + timeRemaining` on every call to `updateWorkoutActivity()` — which fired from 5 different triggers (phase change, running state, paused state, track change, every 5 seconds). Each recalculation shifted the target date slightly, causing the Dynamic Island's `Text(timerInterval:)` display to jump or drift relative to the in-app timer. A second bug caused premature Live Activity creation: `updateWorkoutActivity()` was called when `musicVM.currentTrack` loaded (before the user tapped play), storing a `phaseEndDate` based on the prep phase's 5-second duration. When the workout actually started seconds later, the stored stale date was reused, producing a timer at 0. ## Fix - **Stored `phaseEndDate`** as `@State` — calculated once per phase via `phaseEndDate = nil` on phase change and resume, then locked for subsequent updates - **Guarded `updateWorkoutActivity()`** behind `vm.isRunning` — prevents Live Activity from being created or updated before the workout starts ## Files Changed | File | Change | |------|--------| | `PlayerView.swift` | Full redesign + timer drift fix | | `PlayerViewModel.swift` | New timer engine with full phase support | | `WorkoutLiveActivity.swift` | Dynamic Island widget (new) | | `MusicLiveActivity.swift` | Music now-playing widget (new) | | `WorkoutActivityAttributes.swift` | ActivityKit model (new) | | `MusicActivityAttributes.swift` | ActivityKit model (new) | | `Theme.swift` | New compact timer fonts, phase colors | | `MusicService.swift` | Vibe-based playlist loading | | `HomeTab.swift` | ZoneHighlightIcon component | | `ZoneHighlightIcon.swift` | SF Symbol per body zone (new) | | `TabataGoApp.swift` | Updated entry point | | `Info.plist` | Widget extension config | | Watch/iOS `Localizable.xcstrings` | Updated translations |
millianlmx added 1 commit 2026-04-25 23:54:17 +02:00
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
b0d364eca2
## 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

Test Coverage Report

Coverage summary not available.

## Test Coverage Report _Coverage summary not available._
millianlmx added 11 commits 2026-05-19 16:28:33 +02:00
**Architecture (PlayerViewModel):**
- Move ActivityKit lifecycle from SwiftUI View to ViewModel (MVVM correction)
- call syncActivity() at END of enterPhase() — after all state is set,
  eliminating the race where phase was Published before timeRemaining
- Always recalculate phaseEndDate = Date() + timeRemaining (no stale cache)
- Dedicated Timer in ViewModel for periodic heart-rate/track sync (5s)
- Start/stop activity sync timer on play/pause/resume/abandon/finish
- stale activity reference discard + recreate-on-failure fallback
- Modern iOS 16.2+ API: ActivityContent, non-throwing update()

**PlayerView:**
- Remove all ActivityKit code (import, @State workoutActivity,
  phaseEndDate, dynamicIslandAvailable, 4 methods, .onReceive timer)
- Delegate to ViewModel: onChange(musicVM.currentTrack) sets vm.trackTitle/Artist
  and calls vm.syncActivity(); onDisappear calls await vm.endActivity()
- Music/audio onChange handlers no longer contain activity logic

**Info.plist:**
- Add UIBackgroundModes → audio so music continues and app stays alive
  in background, allowing Timer-based activity updates
- Widget Info.plist: add NSSupportsLiveActivitiesFrequentUpdates

**WorkoutActivityAttributes.ContentState:**
- Add Sendable conformance for Swift 6 strict concurrency

Fixes: timer stuck at 0 on first work phase, exercise name missing,
       music stopping in background, Dynamic Island freezing in background,
       widget drift due to cached phaseEndDate
- Add Sendable conformance to MusicActivityAttributes.ContentState
- Remove @preconcurrency on ActivityKit import
- Use nonisolated(unsafe) guards for Activity refs in task closures
- Add observeActivityState() to handle stale/ended/dismissed activity states
- Set staleDate (120s) instead of nil for push notification support
- Add @Environment activityFamily, isActivityFullscreen, isLuminanceReduced
- Split into lockScreenView() and smallLockScreenView() variants
- Add supplementalActivityFamilies([.small, .medium]) support
- Add keylineTint and contentMargins to Dynamic Island
- Add accessibility labels throughout (VoiceOver support)
- Hide music bar animation when isLuminanceReduced
- Add widget target PBXBuildFile, PBXFileReference, PBXGroup, PBXNativeTarget entries
- Reorder PBXCopyFilesBuildPhase and XCBuildConfiguration sections
- Add DEVELOPMENT_TEAM to watch complication and watch app configs
When airplane mode is active, the Supabase client hung indefinitely
waiting for a network response, blocking the mock track fallback.
Now races the query against a 6-second Task.sleep so mock tracks
load immediately after timeout.
- Add isPaused to WorkoutActivityAttributes.ContentState
- Show PAUSED badge, freeze timer to static text, dim content when paused
- Prevent stale spinner on pause by extending staleDate to 1 hour
- Add 6s timer warning color, progress bar, compact heavy timer
- Pulsing compact indicator during WORK phase
- Lock Screen margins aligned to Apple's 14pt HIG spec
- Replace raw string phase model with WorkoutPhase enum (Codable, Sendable, CaseIterable)
  with built-in .capitalized display name and SwiftUI .color per phase
- Decompose WorkoutLiveActivity into reusable view structs: PhasePill, CountdownText,
  WorkoutProgressBar, MusicInfoRow, HeartRateBadge, PhaseIndicatorDot, WorkoutLockScreenView,
  WorkoutSmallView — following CraftingSwift iOS 26 architecture patterns
- Add AlertConfiguration on work/rest/complete phase transitions so Dynamic Island
  expands and lights up at key moments
- Add 13 #Preview blocks across both widgets covering all presentation types:
  lock screen, expanded, compact, minimal — for instant Xcode Canvas feedback
- Add stale state handling (context.isStale shows 'Last updated' indicator)
- MusicLiveActivity: 5 new #Preview blocks for playing/paused/expanded/compact/minimal
Redesign workout live activity with circular timer ring, phase icons, and smoother updates
Some checks failed
CI / TypeScript (pull_request) Failing after 19s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 7s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m9s
CI / Deploy Edge Functions (pull_request) Has been skipped
67e2bdc8c3
- Add CountdownRing with real-time arc progress on lock screen
- Replace generic dots with phase-specific SF Symbols (flame, snowflake, etc.)
- Remove horizontal progress bar in favor of round counter text
- Increase Dynamic Island expanded font sizes for better visibility
- Increase live activity sync frequency from 5s to 1s for smoother arc updates
- Add pause/resume button via TogglePauseIntent AppIntent
- Remove AlertConfiguration to silence notification sounds on updates

Test Coverage Report

Coverage summary not available.

## Test Coverage Report _Coverage summary not available._
millianlmx added 3 commits 2026-05-21 10:57:29 +02:00
ci: add App Store submission pipeline via GitHub Actions
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 12s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
cd6fea9b53

Test Coverage Report

Coverage summary not available.

## Test Coverage Report _Coverage summary not available._
millianlmx added 2 commits 2026-05-23 00:43:34 +02:00
Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.

Fixes (all in PlayerViewModel.swift):

1. observeActivityState(): Split the monolithic stale/ended/dismissed
   handler. .stale now only stops the sync timer but keeps the
   workoutActivity reference so endActivity() can still call .end()
   to properly dismiss the stale Live Activity.

2. syncActivity() nil guard: Changed from != .active to explicit
   == .ended || == .dismissed so stale activities are not prematurely
   discarded when tick() re-enters syncActivity().

3. endActivity(): Added stopActivitySyncTimer() defensive call at top
   to prevent orphaned timer from racing in and recreating the activity
   during .end(). Also relaxed the guard from == .active to
   != .ended && != .dismissed so stale activities can be ended.

4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
   before cleanup to prevent accidental Live Activity recreation.
chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
df9fd48964
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.

Test Coverage Report

Coverage summary not available.

## Test Coverage Report _Coverage summary not available._
millianlmx added 1 commit 2026-05-23 12:22:38 +02:00
ci: replace dead Expo CI with linux-only monorepo pipeline
Some checks failed
CI / Admin Web CI (pull_request) Failing after 34s
CI / YouTube Worker (pull_request) Failing after 5s
CI / Deploy (pull_request) Has been skipped
CI / Detect Changes (pull_request) Successful in 6s
38576fd528
Remove: root Expo typecheck/lint/test/build-check (project deleted Apr 19)
Remove: swift-build-test + app-store.yml (no macOS runner on Gitea)
Add: path-filtered conditional jobs with dorny/paths-filter@v3
Add: youtube-worker deploy to deploy-functions job
Fix: deploy-functions always() to handle skipped upstream jobs
Fix: SPM cache key includes Package.resolved (before removal)
Fix: remove continue-on-error from vitest/playwright steps
millianlmx merged commit f71ba55e8b into main 2026-05-23 12:24:34 +02:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: millianlmx/tabatago#2