import React, { useEffect, useMemo, useRef, useState } from "react"; import { TouchableOpacity, Animated, PanResponder, View } from "react-native"; import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import DigitalTimeString from "./DigitalTimeString"; import useStyles from "./styles"; import withHooks from "~/hoc/withHooks"; import IconTouchTarget from "~/components/IconTouchTarget"; const TRACK_SIZE = 4; const THUMB_SIZE = 20; function clamp(n, min, max) { return Math.max(min, Math.min(n, max)); } function AudioSlider(props) { // Props mapping (kept compatible with previous class component) const { audio: audioUrl, registry, style: styleProp } = props; const pauseAllBeforePlay = props.pauseAllBeforePlay === undefined ? true : props.pauseAllBeforePlay; // Styles injected by withHooks HOC const styles = props.styles; // Track layout (for computing pixel & time mappings) const [trackLayout, setTrackLayout] = useState({ width: 0, height: 0 }); // Thumb X position (in pixels) — single Animated.Value used both for dragging and syncing to playback const dotX = useRef(new Animated.Value(0)).current; const isDraggingRef = useRef(false); const xDotOffsetAtStartRef = useRef(0); // While dragging, we derive the current time from the thumb position for live display const [dragTimeMs, setDragTimeMs] = useState(0); // Player using new expo-audio hook API const player = useAudioPlayer(audioUrl, 250); const status = useAudioPlayerStatus(player) || {}; const durationSec = status.duration || 0; const currentTimeSec = status.currentTime || 0; // Register in an optional registry to allow pausing other players before play const selfRef = useRef({ pause: () => { try { player.pause(); } catch {} }, }); useEffect(() => { selfRef.current.pause = () => { try { player.pause(); } catch {} }; }, [player]); useEffect(() => { if (!registry) return; const self = selfRef.current; registry.register(self); return () => { try { registry.unregister(self); } catch {} }; }, [registry]); // Ensure no looping (mimics the previous behavior) useEffect(() => { try { player.loop = false; } catch {} }, [player]); // When not dragging, keep the thumb in sync with the playback position useEffect(() => { if (!isDraggingRef.current) { const w = trackLayout.width || 0; const x = durationSec > 0 ? (currentTimeSec / durationSec) * w : 0; dotX.setValue(x); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTimeSec, durationSec, trackLayout.width]); // When playback finishes, reset to start (seek to 0 and move thumb to start) useEffect(() => { if (status.didJustFinish) { try { player.pause(); } catch {} try { player.seekTo(0); } catch {} dotX.setValue(0); } }, [status.didJustFinish, player, dotX]); // Safety: if we detect playback reached or passed the end, stop and reset useEffect(() => { if (durationSec > 0 && currentTimeSec >= durationSec - 0.05) { try { player.pause(); } catch {} try { player.seekTo(0); } catch {} dotX.setValue(0); } }, [currentTimeSec, durationSec, player, dotX]); const onPressPlayPause = async () => { if (status.playing) { try { player.pause(); } catch {} return; } // Pause others first if asked if (registry && pauseAllBeforePlay) { try { const players = registry.getAll ? registry.getAll() : []; players .filter((p) => p !== selfRef.current && typeof p.pause === "function") .forEach((p) => p.pause()); } catch {} } try { if (durationSec > 0 && currentTimeSec >= durationSec - 0.05) { await player.seekTo(0); } player.play(); } catch {} }; const a11yPlayPauseLabel = status.playing ? "Mettre en pause" : "Lire le message audio"; const a11yPlayPauseHint = status.playing ? "Met en pause la lecture." : "Démarre la lecture du message audio."; // Pan handling for seeking const panResponder = useMemo( () => PanResponder.create({ onMoveShouldSetResponderCapture: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: async () => { // Pause if currently playing (mimic previous behavior) if (status.playing) { try { player.pause(); } catch {} } isDraggingRef.current = true; // Initialize offset for drag const currentX = dotX.__getValue(); xDotOffsetAtStartRef.current = currentX; dotX.setOffset(currentX); dotX.setValue(0); // While dragging, update displayed time dotX.addListener(({ value }) => { const w = trackLayout.width || 1; const currentOffset = xDotOffsetAtStartRef.current + (typeof value === "number" ? value : 0); const clampedX = clamp(currentOffset, 0, w); const percent = w > 0 ? clampedX / w : 0; const ms = Math.round(percent * durationSec * 1000); setDragTimeMs(ms); }); }, onPanResponderMove: Animated.event([null, { dx: dotX }], { useNativeDriver: false, }), onPanResponderTerminationRequest: () => false, onPanResponderTerminate: async () => { // Another component took the responder dotX.removeAllListeners(); const w = trackLayout.width || 1; const value = dotX.__getValue(); const currentOffset = xDotOffsetAtStartRef.current + (typeof value === "number" ? value : 0); let clampedX = clamp(currentOffset, 0, w); dotX.flattenOffset(); dotX.setValue(clampedX); if (durationSec > 0) { const targetSec = (clampedX / w) * durationSec; try { await player.seekTo(targetSec); } catch {} } isDraggingRef.current = false; setDragTimeMs(0); }, onPanResponderRelease: async () => { dotX.removeAllListeners(); const w = trackLayout.width || 1; const value = dotX.__getValue(); const currentOffset = xDotOffsetAtStartRef.current + (typeof value === "number" ? value : 0); let clampedX = clamp(currentOffset, 0, w); dotX.flattenOffset(); dotX.setValue(clampedX); if (durationSec > 0) { const targetSec = (clampedX / w) * durationSec; try { await player.seekTo(targetSec); } catch {} } isDraggingRef.current = false; setDragTimeMs(0); }, }), [dotX, durationSec, player, status.playing, trackLayout.width], ); const measureTrack = (event) => { setTrackLayout(event.nativeEvent.layout || {}); }; // Times for display (DigitalTimeString expects milliseconds) const durationMs = Math.round(durationSec * 1000); const currentTimeMs = isDraggingRef.current ? dragTimeMs : Math.round(currentTimeSec * 1000); return ( {status.playing ? ( ) : ( )} {/* A11y: ensure minimum touch target and stateful labels for SR users */} ); } export default withHooks(AudioSlider, () => { const styles = useStyles(); return { styles }; });