import { useEffect, useMemo, useRef, useState } from 'react'; import { View, StyleSheet, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native'; import { Canvas, Group, Path, Skia, rect } from '@shopify/react-native-skia'; import { Text } from './Text'; import { colors, spacing } from '@/theme'; import { formatDuration } from '@/lib/format'; import { downsampleWaveform, getWaveform } from '@/scope/waveform'; const CANVAS_HEIGHT = 58; const BAR_WIDTH = 3; const BAR_GAP = 2; const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver // While a seek is pending, keep showing the target until the player's reported // position moves off the pre-seek value (`from`) — i.e. the seek has landed. const HOLD_EPS = 0.75; interface WaveformSeekBarProps { currentTime: number; duration: number; onSeek: (seconds: number) => void; height?: number; touchPadding?: number; /** Identity of the playing track; a pending seek only applies to its own track. */ trackKey?: string | number; /** Track file URI used to load/cache the offline waveform peaks. */ trackPath?: string; } const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction)); /** * Waveform seek bar (M3) — ports desktop WaveformSeekBar's look (RMS bars, a * played/unplayed split, draggable playhead) on Skia, while keeping SeekBar's * tap/drag + pending-seek "hold" state machine verbatim so seeking behaves * identically. Peaks load offline (getWaveform) and fall back to flat bars. */ export function WaveformSeekBar({ currentTime, duration, onSeek, height = CANVAS_HEIGHT, touchPadding = spacing.md, trackKey, trackPath, }: WaveformSeekBarProps) { const [scrubFraction, setScrubFraction] = useState(null); const [barWidth, setBarWidth] = useState(0); const [pendingSeek, setPendingSeek] = useState<{ target: number; from: number; key?: string | number; } | null>(null); // Peaks tagged with the path they belong to, so a track change drops the old // waveform as a pure derivation (no synchronous setState in the effect). const [loaded, setLoaded] = useState<{ path: string; peaks: Float32Array | null } | null>(null); const widthRef = useRef(0); const scrubRef = useRef(null); const grantRef = useRef({ fraction: 0, pageX: 0 }); // Load (cache-first) the offline peaks whenever the track changes. useEffect(() => { if (!trackPath) return; let cancelled = false; void getWaveform(trackPath).then((peaks) => { if (!cancelled) setLoaded({ path: trackPath, peaks }); }); return () => { cancelled = true; }; }, [trackPath]); const source = loaded && loaded.path === trackPath ? loaded.peaks : null; 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); }; 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; // Capture the pre-seek position so we can hold the target until the player // moves off it. Using `from` (not the target) means the hold releases when // the seek lands and can never re-engage as playback advances past target. setPendingSeek({ target, from: currentTime, key: trackKey }); onSeek(target); setScrub(null); }; // Displayed position: scrub > held seek target > live progress. Hold while the // player still reports the stale pre-seek position; release once it jumps. const holdSeek = pendingSeek != null && pendingSeek.key === trackKey && duration > 0 && Math.abs(currentTime - pendingSeek.from) < HOLD_EPS; 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; const barCount = Math.max(1, Math.floor(barWidth / (BAR_WIDTH + BAR_GAP))); // Build one Skia path of all bars (rounded rects). Drawn twice with a clip // split at the playhead: played in accent, unplayed in glassBorder. const barsPath = useMemo(() => { const path = Skia.Path.Make(); if (barWidth <= 0) return path; const display = source ? downsampleWaveform(source, barCount) : new Float32Array(barCount).fill(MIN_BAR); const r = BAR_WIDTH / 2; for (let i = 0; i < barCount; i++) { const amp = Math.max(MIN_BAR, display[i] ?? MIN_BAR); const h = amp * height; const x = i * (BAR_WIDTH + BAR_GAP); const y = (height - h) / 2; path.addRRect(Skia.RRectXY(Skia.XYWHRect(x, y, BAR_WIDTH, h), r, r)); } return path; }, [source, barCount, barWidth, height]); const splitX = fraction * barWidth; 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 styles = StyleSheet.create({ touchArea: { justifyContent: 'center', }, times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing.xs, }, time: { color: colors.textTertiary, fontSize: 13, }, timeActive: { color: colors.accentText, }, }); export default WaveformSeekBar;