import { useRef, useState } from 'react'; import { View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native'; import { Text } from './Text'; import { radius, spacing, } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; import { playHaptic } from '@/lib/haptics'; const THUMB_SIZE = 12; interface SeekBarProps { currentTime: number; duration: number; onSeek: (seconds: number) => void; /** Identity of the playing track; a pending seek only applies to its own track. */ trackKey?: string | number; } const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction)); /** * Tap/drag seek bar with time labels. Plain progress bar for M1 — the * waveform seek bar port (desktop WaveformSeekBar) replaces the visuals at M3+. */ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProps) { const styles = useStyles(); const [scrubFraction, setScrubFraction] = useState(null); const [barWidth, setBarWidth] = useState(0); // Last released seek. Displayed instead of live progress until playback // catches up, so the bar doesn't snap back to a stale pre-seek progress // event; never cleared, just superseded or ignored (pure derivation below). const [pendingSeek, setPendingSeek] = useState<{ target: number; key?: string | number } | null>( null ); // Gesture-internal values (only touched inside event handlers). const widthRef = useRef(0); const scrubRef = useRef(null); const grantRef = useRef({ fraction: 0, pageX: 0 }); const setScrub = (fraction: number | null) => { scrubRef.current = fraction; setScrubFraction(fraction); }; const onLayout = (event: LayoutChangeEvent) => { widthRef.current = event.nativeEvent.layout.width; setBarWidth(event.nativeEvent.layout.width); }; const handleGrant = (event: GestureResponderEvent) => { const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current)); grantRef.current = { fraction, pageX: event.nativeEvent.pageX }; setScrub(fraction); playHaptic('threshold'); }; const handleMove = (event: GestureResponderEvent) => { const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current); setScrub(clamp(grantRef.current.fraction + delta)); }; const handleRelease = () => { const fraction = scrubRef.current ?? grantRef.current.fraction; const target = fraction * duration; setPendingSeek({ target, key: trackKey }); onSeek(target); setScrub(null); }; // Displayed position: scrub > held seek target > live progress. The held // target applies only while playback hasn't caught up on the same track. const holdSeek = pendingSeek != null && pendingSeek.key === trackKey && duration > 0 && Math.abs(currentTime - pendingSeek.target) >= 1.5; const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0; const heldFraction = holdSeek ? clamp(pendingSeek.target / duration) : null; const fraction = scrubFraction ?? heldFraction ?? liveFraction; const shownTime = fraction * duration; return ( duration > 0} onMoveShouldSetResponder={() => duration > 0} onResponderTerminationRequest={() => false} onResponderGrant={handleGrant} onResponderMove={handleMove} onResponderRelease={handleRelease} onResponderTerminate={() => setScrub(null)} accessibilityRole="adjustable" accessibilityLabel="Seek" accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime), }} > {formatDuration(shownTime)} {formatDuration(duration)} ); } const useStyles = createThemedStyles((colors) => ({ touchArea: { justifyContent: 'center', paddingVertical: spacing.md, // generous touch target around the 4px track }, track: { height: 4, borderRadius: radius.pill, backgroundColor: colors.glassBorder, overflow: 'hidden', }, fill: { height: 4, borderRadius: radius.pill, backgroundColor: colors.accent, }, thumb: { position: 'absolute', width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: THUMB_SIZE / 2, backgroundColor: colors.accent, }, thumbActive: { transform: [{ scale: 1.35 }], backgroundColor: colors.accentHover, }, times: { flexDirection: 'row', justifyContent: 'space-between', }, time: { color: colors.textTertiary, }, timeActive: { color: colors.accentText, }, })); export default SeekBar;