feat: shared components, hooks, and audio engine

Components:
- StyledText: unified text component replacing 5 local copies
- GlassCard: reusable glassmorphic card with blur background
- VideoPlayer: expo-video wrapper with preview/background modes
  and gradient fallback when no video URL

Hooks:
- useTimer: extracted timer engine with phase transitions
  (PREP → WORK → REST → repeat → COMPLETE), calorie tracking,
  and actions (start, pause, resume, skip, stop)
- useHaptics: centralized haptic feedback respecting user settings
- useAudio: expo-av sound effects (countdown beep, phase ding,
  completion chime) respecting soundEffects setting

Audio assets: 3 generated WAV files for timer events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-20 13:23:50 +01:00
parent 5477ecb852
commit 13faf21b8d
11 changed files with 541 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
/**
* TabataFit Shared Hooks
*/
export { useTimer } from './useTimer'
export type { TimerPhase } from './useTimer'
export { useHaptics } from './useHaptics'
export { useAudio } from './useAudio'

View File

@@ -0,0 +1,74 @@
/**
* TabataFit Audio Hook
* Sound effects for timer events using expo-av
* Respects userStore soundEffects setting
*/
import { useRef, useEffect, useCallback } from 'react'
import { Audio } from 'expo-av'
import { useUserStore } from '../stores'
// Audio assets
const SOUNDS = {
countdown: require('../../../assets/audio/countdown.wav'),
phaseStart: require('../../../assets/audio/phase-start.wav'),
complete: require('../../../assets/audio/complete.wav'),
}
type SoundKey = keyof typeof SOUNDS
export function useAudio() {
const soundEnabled = useUserStore((s) => s.settings.soundEffects)
const loadedSounds = useRef<Record<string, Audio.Sound>>({})
// Configure audio session
useEffect(() => {
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: false,
shouldDuckAndroid: true,
})
return () => {
// Unload all sounds on cleanup
Object.values(loadedSounds.current).forEach(sound => {
sound.unloadAsync().catch(() => {})
})
loadedSounds.current = {}
}
}, [])
const getSound = useCallback(async (key: SoundKey): Promise<Audio.Sound | null> => {
if (loadedSounds.current[key]) {
return loadedSounds.current[key]
}
try {
const { sound } = await Audio.Sound.createAsync(SOUNDS[key])
loadedSounds.current[key] = sound
return sound
} catch {
return null
}
}, [])
const play = useCallback(async (key: SoundKey) => {
if (!soundEnabled) return
const sound = await getSound(key)
if (!sound) return
try {
await sound.setPositionAsync(0)
await sound.playAsync()
} catch {
// Sound may have been unloaded
}
}, [soundEnabled, getSound])
return {
/** Short beep for countdown ticks (3, 2, 1) */
countdownBeep: useCallback(() => play('countdown'), [play]),
/** Ding for phase transitions (work → rest, rest → work) */
phaseStart: useCallback(() => play('phaseStart'), [play]),
/** Celebration chime on workout completion */
workoutComplete: useCallback(() => play('complete'), [play]),
}
}

View File

@@ -0,0 +1,45 @@
/**
* TabataFit Haptics Hook
* Centralized haptic feedback respecting user settings
*/
import { useCallback } from 'react'
import * as Haptics from 'expo-haptics'
import { useUserStore } from '../stores'
export function useHaptics() {
const haptics = useUserStore((s) => s.settings.haptics)
const phaseChange = useCallback(() => {
if (!haptics) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}, [haptics])
const buttonTap = useCallback(() => {
if (!haptics) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
}, [haptics])
const countdownTick = useCallback(() => {
if (!haptics) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}, [haptics])
const workoutComplete = useCallback(() => {
if (!haptics) return
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}, [haptics])
const selection = useCallback(() => {
if (!haptics) return
Haptics.selectionAsync()
}, [haptics])
return {
phaseChange,
buttonTap,
countdownTick,
workoutComplete,
selection,
}
}

View File

@@ -0,0 +1,170 @@
/**
* TabataFit Timer Hook
* Extracted from player/[id].tsx — reusable timer logic
*/
import { useRef, useEffect, useCallback } from 'react'
import { usePlayerStore } from '../stores'
import type { Workout } from '../types'
export type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
interface UseTimerReturn {
phase: TimerPhase
timeRemaining: number
currentRound: number
totalRounds: number
currentExercise: string
nextExercise: string | undefined
progress: number
isPaused: boolean
isRunning: boolean
isComplete: boolean
calories: number
start: () => void
pause: () => void
resume: () => void
skip: () => void
stop: () => void
}
export function useTimer(workout: Workout | null): UseTimerReturn {
const store = usePlayerStore()
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Load workout into store on mount
useEffect(() => {
if (workout) {
store.loadWorkout(workout)
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [workout?.id])
const w = workout ?? {
prepTime: 10,
workTime: 20,
restTime: 10,
rounds: 8,
exercises: [{ name: 'Ready', duration: 20 }],
}
// Calculate phase duration
const phaseDuration =
store.phase === 'PREP' ? w.prepTime :
store.phase === 'WORK' ? w.workTime :
store.phase === 'REST' ? w.restTime : 0
const progress = phaseDuration > 0 ? 1 - store.timeRemaining / phaseDuration : 1
// Exercise index based on current round
const exerciseIndex = (store.currentRound - 1) % w.exercises.length
const currentExercise = w.exercises[exerciseIndex]?.name ?? ''
const nextExercise = w.exercises[(exerciseIndex + 1) % w.exercises.length]?.name
// Timer tick
useEffect(() => {
if (!store.isRunning || store.isPaused || store.phase === 'COMPLETE') {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
return
}
intervalRef.current = setInterval(() => {
const s = usePlayerStore.getState()
if (s.timeRemaining <= 1) {
// Phase transition
if (s.phase === 'PREP') {
store.setPhase('WORK')
store.setTimeRemaining(w.workTime)
} else if (s.phase === 'WORK') {
// Add calories for completed work phase
const caloriesPerRound = workout ? Math.round(workout.calories / workout.rounds) : 5
store.addCalories(caloriesPerRound)
store.setPhase('REST')
store.setTimeRemaining(w.restTime)
} else if (s.phase === 'REST') {
if (s.currentRound >= (workout?.rounds ?? 8)) {
store.setPhase('COMPLETE')
store.setTimeRemaining(0)
store.setRunning(false)
} else {
store.setPhase('WORK')
store.setTimeRemaining(w.workTime)
store.setCurrentRound(s.currentRound + 1)
}
}
} else {
store.setTimeRemaining(s.timeRemaining - 1)
}
}, 1000)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [store.isRunning, store.isPaused, store.phase])
const start = useCallback(() => {
store.setRunning(true)
store.setPaused(false)
}, [])
const pause = useCallback(() => {
store.setPaused(true)
}, [])
const resume = useCallback(() => {
store.setPaused(false)
}, [])
const skip = useCallback(() => {
const s = usePlayerStore.getState()
if (s.phase === 'PREP') {
store.setPhase('WORK')
store.setTimeRemaining(w.workTime)
} else if (s.phase === 'WORK') {
store.setPhase('REST')
store.setTimeRemaining(w.restTime)
} else if (s.phase === 'REST') {
if (s.currentRound >= (workout?.rounds ?? 8)) {
store.setPhase('COMPLETE')
store.setTimeRemaining(0)
store.setRunning(false)
} else {
store.setPhase('WORK')
store.setTimeRemaining(w.workTime)
store.setCurrentRound(s.currentRound + 1)
}
}
}, [])
const stop = useCallback(() => {
store.reset()
}, [])
return {
phase: store.phase as TimerPhase,
timeRemaining: store.timeRemaining,
currentRound: store.currentRound,
totalRounds: workout?.rounds ?? 8,
currentExercise,
nextExercise: store.phase === 'REST' ? nextExercise : undefined,
progress,
isPaused: store.isPaused,
isRunning: store.isRunning,
isComplete: store.phase === 'COMPLETE',
calories: store.calories,
start,
pause,
resume,
skip,
stop,
}
}