import {Dispatch, SetStateAction, useRef} from 'react'; import {PanResponder} from 'react-native'; type PanResponderInstance = ReturnType; interface PanRespondersProps { duration: number; volumePosition: number; loading: boolean; seeking: boolean; seekerPosition: number; seek?: (time: number, tolerance?: number) => void; seekerWidth: number; volumeTrackWidth: number; // Constant slider width (px), i.e. the max volume position clearControlTimeout: () => void; setVolumePosition: (position: number) => void; setSeekerPosition: (position: number) => void; setSeeking: Dispatch>; setControlTimeout: () => void; onEnd: () => void; horizontal?: boolean; inverted?: boolean; } export const usePanResponders = ({ duration, volumePosition, loading, seeking, seekerPosition, seek, seekerWidth, volumeTrackWidth, clearControlTimeout, setVolumePosition, setSeekerPosition, setSeeking, setControlTimeout, onEnd, horizontal = true, inverted = false, }: PanRespondersProps) => { // ── Why this hook looks the way it does ───────────────────────────────── // // 1) PanResponder.create() used to be called directly in the hook body, // which built a BRAND NEW responder (and brand new panHandlers) on // every single render. Because every touch-move during a drag updates // state (to move the knob), that meant React was tearing down and // rebuilding the active touch handlers on nearly every frame of a // gesture — the underlying cause of touches feeling dropped or laggy. // The responders are now created exactly ONCE (see `respondersRef`) // and never recreated. // // 2) Because the responders are created once, their closures can't // directly reference render-scoped variables (that would make them // stale forever). Instead, every value/callback the handlers need is // kept fresh on a single mutable `latest` ref, updated on every render. // // 3) The seek drag is tracked in SECONDS, not pixels. `seekTimeRef` is // the single source of truth for "what time will we seek to" and is // kept up to date on every move — the pixel position is only ever a // rendering detail derived from it. This is what onPanResponderRelease // hands to `seek()`, so there's no separate pixel→percent→duration // recomputation at release that could drift from what was on screen. // // 4) The actual video `seek()` call now happens in exactly one place: // onPanResponderRelease (and onPanResponderTerminate, see #6). // Previously a separate effect also fired a real seek on every // touch-move while dragging, flooding the player with (often // network-bound) seeks many times a second — this was the single // biggest cause of laggy, unresponsive scrubbing. // // 5) onPanResponderTerminationRequest always returns false: once a drag // on the seekbar/volume slider has started, nothing (a parent // ScrollView, another gesture, etc.) is allowed to steal it mid-drag. // // 6) onPanResponderTerminate mirrors onPanResponderRelease. Previously, // if the gesture was ever interrupted/cancelled by the OS or a parent // view instead of cleanly released, `setSeeking(false)` was never // called — leaving `seeking` stuck `true` forever, which silently // freezes the seekbar (VideoPlayer's progress effect intentionally // ignores progress updates while `seeking` is true). const latest = useRef({ duration, volumePosition, loading, seeking, seekerPosition, seek, seekerWidth, volumeTrackWidth, clearControlTimeout, setVolumePosition, setSeekerPosition, setSeeking, setControlTimeout, onEnd, horizontal, inverted, }); // Refresh the snapshot every render so the (stable) responders below // always see up-to-date values without ever being recreated. latest.current.duration = duration; latest.current.volumePosition = volumePosition; latest.current.loading = loading; latest.current.seeking = seeking; latest.current.seekerPosition = seekerPosition; latest.current.seek = seek; latest.current.seekerWidth = seekerWidth; latest.current.volumeTrackWidth = volumeTrackWidth; latest.current.clearControlTimeout = clearControlTimeout; latest.current.setVolumePosition = setVolumePosition; latest.current.setSeekerPosition = setSeekerPosition; latest.current.setSeeking = setSeeking; latest.current.setControlTimeout = setControlTimeout; latest.current.onEnd = onEnd; latest.current.horizontal = horizontal; latest.current.inverted = inverted; // Drag-start snapshots — written once per gesture, in onPanResponderGrant. const seekerOffsetRef = useRef(0); const volumeOffsetRef = useRef(0); // Source of truth for "where will we seek to", tracked in SECONDS. // Updated on every move; read once, at the end of the gesture. const seekTimeRef = useRef(0); const respondersRef = useRef<{ volumePanResponder: PanResponderInstance; seekPanResponder: PanResponderInstance; } | null>(null); if (!respondersRef.current) { const finalizeVolumeGesture = () => { latest.current.setControlTimeout(); }; const volumePanResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, onPanResponderGrant: () => { latest.current.clearControlTimeout(); // Snapshot the starting position for THIS drag only. volumeOffsetRef.current = latest.current.volumePosition; }, onPanResponderMove: (_evt, gestureState) => { const { horizontal: h, inverted: inv, volumeTrackWidth: maxWidth, } = latest.current; const diff = h ? gestureState.dx : gestureState.dy; let position = volumeOffsetRef.current + diff * (inv ? -1 : 1); if (position < 0) { position = 0; } else if (position > maxWidth) { position = maxWidth; } latest.current.volumePosition = position; latest.current.setVolumePosition(position); }, onPanResponderRelease: finalizeVolumeGesture, onPanResponderTerminate: finalizeVolumeGesture, }); // Convert a pixel position on the track into a time in seconds, // guarding against a not-yet-measured (0-width) track. const positionToSeconds = (position: number) => { const {seekerWidth: width, duration: dur} = latest.current; if (!width || width <= 0 || !dur || dur <= 0) return 0; const clampedPosition = Math.max(0, Math.min(position, width)); const percent = clampedPosition / width; return dur * percent; }; const finalizeSeekGesture = () => { const { seekerWidth: width, duration: dur, loading: isLoading, onEnd: end, seek: seekFn, setSeeking: setSeekingFn, } = latest.current; setSeekingFn(false); // Track wasn't measured yet (e.g. gesture ended before layout) — // bail out instead of seeking to 0, which is what a divide-by-zero // guard would otherwise silently do. if (!width || width <= 0 || !dur || dur <= 0) { return; } const time = Math.max(0, Math.min(seekTimeRef.current, dur)); if (time >= dur && !isLoading) { if (typeof end === 'function') { end(); } } // The ONLY place a real video seek is triggered — once, at the end // of the gesture. `time` is already in seconds, so this hands the // player exactly what was shown on screen, with no extra rounding. seekFn && seekFn(time); }; const seekPanResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, onPanResponderGrant: (evt) => { latest.current.setSeeking(true); latest.current.clearControlTimeout(); const {seekerWidth: width} = latest.current; // Tap-to-jump: start the drag from wherever the user touched down. const rawPosition = evt.nativeEvent.locationX; const position = Math.max( 0, Math.min(rawPosition, width > 0 ? width : rawPosition), ); seekerOffsetRef.current = position; seekTimeRef.current = positionToSeconds(position); latest.current.seekerPosition = position; latest.current.setSeekerPosition(position); }, onPanResponderMove: (_evt, gestureState) => { const { horizontal: h, inverted: inv, seekerWidth: width, } = latest.current; const diff = h ? gestureState.dx : gestureState.dy; const rawPosition = seekerOffsetRef.current + diff * (inv ? -1 : 1); const position = Math.max( 0, Math.min(rawPosition, width > 0 ? width : rawPosition), ); seekTimeRef.current = positionToSeconds(position); latest.current.seekerPosition = position; latest.current.setSeekerPosition(position); }, onPanResponderRelease: finalizeSeekGesture, onPanResponderTerminate: finalizeSeekGesture, }); respondersRef.current = {volumePanResponder, seekPanResponder}; } return respondersRef.current; };