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:
8
src/shared/hooks/index.ts
Normal file
8
src/shared/hooks/index.ts
Normal 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'
|
||||
74
src/shared/hooks/useAudio.ts
Normal file
74
src/shared/hooks/useAudio.ts
Normal 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]),
|
||||
}
|
||||
}
|
||||
45
src/shared/hooks/useHaptics.ts
Normal file
45
src/shared/hooks/useHaptics.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
170
src/shared/hooks/useTimer.ts
Normal file
170
src/shared/hooks/useTimer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user