/** * TabataFit Player Screen * Thin orchestrator — all UI extracted to src/features/player/ * FORCE DARK — always uses darkColors regardless of system theme */ import React, { useRef, useEffect, useCallback, useState } from 'react' import { View, Text, StyleSheet, Pressable, Animated, StatusBar, useWindowDimensions, } from 'react-native' import { useRouter, useLocalSearchParams } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useKeepAwake } from 'expo-keep-awake' import { Icon } from '@/src/shared/components/Icon' import { useTranslation } from 'react-i18next' import { useTimer } from '@/src/shared/hooks/useTimer' import { isTabataSession, getTabataSessionById } from '@/src/shared/data/tabata' import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms' import { TabataPlayerScreen } from '@/src/features/player/TabataPlayerScreen' import type { TabataSession } from '@/src/shared/types/program' import { useHaptics } from '@/src/shared/hooks/useHaptics' import { useAudio } from '@/src/shared/hooks/useAudio' import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer' import { useActivityStore } from '@/src/shared/stores' import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data' import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData' import { useWatchSync } from '@/src/features/watch' import { track } from '@/src/shared/services/analytics' import { VideoPlayer } from '@/src/shared/components/VideoPlayer' import { PHASE_COLORS, darkColors } from '@/src/shared/theme' import { TYPOGRAPHY } from '@/src/shared/constants/typography' import { SPACING } from '@/src/shared/constants/spacing' import { RADIUS } from '@/src/shared/constants/borderRadius' import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors' import { TimerRing, PhaseIndicator, ExerciseDisplay, RoundIndicator, PlayerControls, BurnBar, StatsOverlay, CoachEncouragement, NowPlaying, } from '@/src/features/player' // ─── Helpers ───────────────────────────────────────────────────────────────── function formatTime(seconds: number) { const mins = Math.floor(seconds / 60) const secs = seconds % 60 return `${mins}:${secs.toString().padStart(2, '0')}` } // ─── Main Screen ───────────────────────────────────────────────────────────── export default function PlayerScreen() { useKeepAwake() const router = useRouter() const { id } = useLocalSearchParams<{ id: string }>() // ─── Dispatch: Workout Program → Tabata session → Legacy workout ─ const sessionId = id ?? '1' if (isWorkoutProgramId(sessionId)) { return } if (isTabataSession(sessionId)) { const session = getTabataSessionById(sessionId) if (session) { return } // Fallback to legacy if session not found } return } /** * Workout Program player — async-loads a workout program from Supabase, * converts to TabataSession (3 tabata blocks), and renders TabataPlayerScreen. */ function WorkoutProgramPlayerScreen({ compositeId }: { compositeId: string }) { const [session, setSession] = React.useState(undefined) React.useEffect(() => { let cancelled = false async function load() { const parsed = parseWorkoutProgramId(compositeId) if (!parsed) { if (!cancelled) setSession(null); return } const program = await fetchProgramById(parsed.programId) if (cancelled) return if (!program) { setSession(null); return } setSession(workoutProgramToTabataSession(program)) } load() return () => { cancelled = true } }, [compositeId]) if (session === undefined) { return ( Chargement... ) } if (!session) { return ( Programme non trouvé ) } return } /** * Legacy player for original workout format */ function LegacyPlayerScreen({ id }: { id: string }) { const router = useRouter() const insets = useSafeAreaInsets() const haptics = useHaptics() const { t } = useTranslation() const { width: SCREEN_WIDTH } = useWindowDimensions() const addWorkoutResult = useActivityStore((s) => s.addWorkoutResult) const colors = darkColors const rawWorkout = getWorkoutById(id ?? '1') const workout = useTranslatedWorkout(rawWorkout) const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined const trainerColor = getWorkoutAccentColor(id ?? '1') const timer = useTimer(rawWorkout ?? null) const audio = useAudio() // Music player — synced with workout timer const music = useMusicPlayer({ vibe: workout?.musicVibe ?? 'electronic', isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'PREP', }) const [showControls, setShowControls] = useState(true) const [heartRate, setHeartRate] = useState(null) // Watch sync integration const { isAvailable: isWatchAvailable, sendWorkoutState } = useWatchSync({ onPlay: () => { timer.resume() track('watch_control_play', { workout_id: workout?.id ?? id }) }, onPause: () => { timer.pause() track('watch_control_pause', { workout_id: workout?.id ?? id }) }, onSkip: () => { timer.skip() haptics.selection() track('watch_control_skip', { workout_id: workout?.id ?? id }) }, onStop: () => { haptics.phaseChange() timer.stop() router.back() track('watch_control_stop', { workout_id: workout?.id ?? id }) }, onHeartRateUpdate: (hr: number) => setHeartRate(hr), }) // Animation refs const timerScaleAnim = useRef(new Animated.Value(0.8)).current const phaseColor = PHASE_COLORS[timer.phase].fill // ─── Actions ───────────────────────────────────────────────────────────── const startTimer = useCallback(() => { timer.start() haptics.buttonTap() if (workout) { track('workout_started', { workout_id: workout.id, workout_title: workout.title, duration: workout.duration, level: workout.level, }) } }, [timer, haptics, workout]) const togglePause = useCallback(() => { const workoutId = workout?.id ?? id ?? '' if (timer.isPaused) { timer.resume() track('workout_resumed', { workout_id: workoutId }) } else { timer.pause() track('workout_paused', { workout_id: workoutId }) } haptics.selection() }, [timer, haptics, workout, id]) const stopWorkout = useCallback(() => { haptics.phaseChange() timer.stop() router.back() }, [router, timer, haptics]) const completeWorkout = useCallback(() => { haptics.workoutComplete() if (workout) { track('workout_completed', { workout_id: workout.id, workout_title: workout.title, calories: timer.calories, duration: workout.duration, rounds: workout.rounds, }) addWorkoutResult({ id: Date.now().toString(), workoutId: workout.id, completedAt: Date.now(), calories: timer.calories, durationMinutes: workout.duration, rounds: workout.rounds, completionRate: 1, }) } router.replace(`/complete/${workout?.id ?? '1'}`) }, [router, workout, timer.calories, haptics, addWorkoutResult]) const handleSkip = useCallback(() => { timer.skip() haptics.selection() }, [timer, haptics]) const toggleControls = useCallback(() => { setShowControls((s) => !s) }, []) // ─── Animations & side-effects ─────────────────────────────────────────── // Entrance animation useEffect(() => { Animated.spring(timerScaleAnim, { toValue: 1, friction: 6, tension: 100, useNativeDriver: true, }).start() }, []) // Phase change animation + audio useEffect(() => { timerScaleAnim.setValue(0.9) Animated.spring(timerScaleAnim, { toValue: 1, friction: 4, tension: 150, useNativeDriver: true, }).start() haptics.phaseChange() if (timer.phase === 'COMPLETE') { audio.workoutComplete() } else if (timer.isRunning) { audio.phaseStart() } }, [timer.phase]) // Countdown beep + haptic for last 3 seconds useEffect(() => { if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) { audio.countdownBeep() haptics.countdownTick() } }, [timer.timeRemaining]) // Sync workout state with Apple Watch useEffect(() => { if (!isWatchAvailable || !timer.isRunning) return sendWorkoutState({ phase: timer.phase, timeRemaining: timer.timeRemaining, currentRound: timer.currentRound, totalRounds: timer.totalRounds, currentExercise: timer.currentExercise, nextExercise: timer.nextExercise, calories: timer.calories, isPaused: timer.isPaused, isPlaying: timer.isRunning && !timer.isPaused, }) }, [ timer.phase, timer.timeRemaining, timer.currentRound, timer.totalRounds, timer.currentExercise, timer.nextExercise, timer.calories, timer.isPaused, timer.isRunning, isWatchAvailable, ]) // ─── Render ────────────────────────────────────────────────────────────── return (