Files
tabatago/app/player/[id].tsx
Millian Lamiaux 791f432334 refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
2026-04-17 18:56:24 +02:00

602 lines
19 KiB
TypeScript

/**
* 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 <WorkoutProgramPlayerScreen compositeId={sessionId} />
}
if (isTabataSession(sessionId)) {
const session = getTabataSessionById(sessionId)
if (session) {
return <TabataPlayerScreen session={session} />
}
// Fallback to legacy if session not found
}
return <LegacyPlayerScreen id={sessionId} />
}
/**
* 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<TabataSession | null | undefined>(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 (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: TEXT.SECONDARY }}>Chargement...</Text>
</View>
)
}
if (!session) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: TEXT.SECONDARY }}>Programme non trouvé</Text>
</View>
)
}
return <TabataPlayerScreen session={session} />
}
/**
* 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<number | null>(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 (
<View style={styles.container}>
<StatusBar hidden />
{/* Background video or gradient fallback */}
<VideoPlayer
videoUrl={workout?.videoUrl}
gradientColors={[colors.bg.base, colors.bg.surface]}
mode="background"
isPlaying={timer.isRunning && !timer.isPaused}
style={StyleSheet.absoluteFill}
/>
{/* Phase background tint */}
<View style={[styles.phaseBg, { backgroundColor: phaseColor }]} />
{/* Main content */}
<Pressable style={styles.content} onPress={toggleControls}>
{/* Header */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
<View style={StyleSheet.absoluteFill} />
<Icon name="xmark" size={24} tintColor={colors.text.primary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.title}>{workout?.title ?? 'Workout'}</Text>
<Text style={styles.subtitle}>
{t('durationLevel', {
duration: workout?.duration ?? 0,
level: t(`levels.${(workout?.level ?? 'beginner').toLowerCase()}`),
})}
</Text>
</View>
<View style={styles.closeBtn} />
</View>
)}
{/* Stats overlay — above timer ring */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={styles.statsContainer}>
<StatsOverlay
calories={timer.calories}
heartRate={heartRate}
elapsedRounds={timer.currentRound - 1}
totalRounds={timer.totalRounds}
/>
</View>
)}
{/* Timer ring + inner text */}
<Animated.View
style={[styles.timerContainer, { transform: [{ scale: timerScaleAnim }] }]}
>
<TimerRing progress={timer.progress} phase={timer.phase} />
<View style={styles.timerInner}>
<PhaseIndicator phase={timer.phase} />
<Text selectable style={styles.timerTime}>
{formatTime(timer.timeRemaining)}
</Text>
<RoundIndicator current={timer.currentRound} total={timer.totalRounds} />
</View>
</Animated.View>
{/* Exercise name + coach encouragement */}
{!timer.isComplete && (
<>
<ExerciseDisplay
exercise={timer.currentExercise}
nextExercise={timer.nextExercise}
/>
<CoachEncouragement
phase={timer.phase}
currentRound={timer.currentRound}
totalRounds={timer.totalRounds}
/>
</>
)}
{/* Complete state */}
{timer.isComplete && (
<View style={styles.completeSection}>
<Text style={styles.completeTitle}>{t('screens:player.workoutComplete')}</Text>
<Text style={[styles.completeSubtitle, { color: trainerColor }]}>{t('screens:player.greatJob')}</Text>
<View style={styles.completeStats}>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.rounds')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.calories}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.calories')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{workout?.duration ?? 4}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.minutes')}</Text>
</View>
</View>
</View>
)}
{/* Player controls */}
{showControls && !timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<PlayerControls
isRunning={timer.isRunning}
isPaused={timer.isPaused}
onStart={startTimer}
onPause={() => { timer.pause(); haptics.selection(); track('workout_paused', { workout_id: workout?.id ?? id ?? '' }) }}
onResume={() => { timer.resume(); haptics.selection(); track('workout_resumed', { workout_id: workout?.id ?? id ?? '' }) }}
onStop={stopWorkout}
onSkip={handleSkip}
/>
</View>
)}
{/* Complete CTA */}
{timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<Pressable style={styles.doneButton} onPress={completeWorkout}>
<Text style={styles.doneButtonText}>{t('common:done')}</Text>
</Pressable>
</View>
)}
{/* Burn bar */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={[styles.burnBarContainer, { bottom: insets.bottom + 140 }]}>
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
</View>
)}
{/* Now Playing music pill */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 220 }]}>
<NowPlaying
track={music.currentTrack}
isReady={music.isReady}
onSkipTrack={music.nextTrack}
/>
</View>
)}
</Pressable>
</View>
)
}
// ─── Styles ──────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
},
phaseBg: {
...StyleSheet.absoluteFillObject,
opacity: 0.15,
},
content: {
flex: 1,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
},
closeBtn: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
},
headerCenter: {
alignItems: 'center',
},
title: {
...TYPOGRAPHY.HEADLINE,
color: TEXT.PRIMARY,
},
subtitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
},
// Stats overlay
statsContainer: {
marginTop: SPACING[4],
marginHorizontal: SPACING[4],
},
// Timer
timerContainer: {
alignItems: 'center',
justifyContent: 'center',
marginTop: SPACING[6],
},
timerInner: {
position: 'absolute',
alignItems: 'center',
},
timerTime: {
...TYPOGRAPHY.TIMER_NUMBER,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
// Controls
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
},
// Burn Bar
burnBarContainer: {
position: 'absolute',
left: SPACING[4],
right: SPACING[4],
height: 72,
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
padding: SPACING[3],
},
// Now Playing
nowPlayingContainer: {
position: 'absolute',
left: SPACING[6],
right: SPACING[6],
},
// Complete
completeSection: {
alignItems: 'center',
marginTop: SPACING[8],
},
completeTitle: {
...TYPOGRAPHY.LARGE_TITLE,
color: TEXT.PRIMARY,
},
completeSubtitle: {
...TYPOGRAPHY.TITLE_3,
marginTop: SPACING[1],
},
completeStats: {
flexDirection: 'row',
marginTop: SPACING[6],
gap: SPACING[8],
},
completeStat: {
alignItems: 'center',
},
completeStatValue: {
...TYPOGRAPHY.TITLE_1,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
completeStatLabel: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
doneButton: {
width: 200,
height: 56,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: GREEN[500],
},
doneButtonText: {
...TYPOGRAPHY.BUTTON_MEDIUM,
color: NAVY[900],
letterSpacing: 1,
},
})