import React, { PureComponent } from "react"; import { TouchableOpacity, Animated, PanResponder, View, Easing, } from "react-native"; import { Audio } from "expo-av"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import sleep from "./sleep"; import DigitalTimeString from "./DigitalTimeString"; import useStyles from "./styles"; import withHooks from "~/hoc/withHooks"; const TRACK_SIZE = 4; const THUMB_SIZE = 20; class AudioSlider extends PureComponent { constructor(props) { super(props); this.state = { playing: false, currentTime: 0, // miliseconds; value interpolated by animation. duration: 0, trackLayout: {}, dotOffset: new Animated.ValueXY(), xDotOffsetAtAnimationStart: 0, }; this._updateProps(); // Important: // this.state.dotOffset.x is the actual offset // this.state.dotOffset.x._value is the offset from the point where the animation started // However, since this.state.dotOffset.x is an object and not a value, it is difficult // to compare it with other numbers. Therefore, the const currentOffsetX is used. // To print all attributes of the object see https://stackoverflow.com/questions/9209747/printing-all-the-hidden-properties-of-an-object this._panResponder = PanResponder.create({ onMoveShouldSetResponderCapture: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: async (e, gestureState) => { if (this.state.playing) { await this.pause(); } await this.setState({ xDotOffsetAtAnimationStart: this.state.dotOffset.x._value, }); await this.state.dotOffset.setOffset({ x: this.state.dotOffset.x._value, }); await this.state.dotOffset.setValue({ x: 0, y: 0 }); }, onPanResponderMove: (e, gestureState) => { Animated.event([ null, { dx: this.state.dotOffset.x, dy: this.state.dotOffset.y }, ])(e, gestureState); }, onPanResponderTerminationRequest: () => false, onPanResponderTerminate: async (evt, gestureState) => { // Another component has become the responder, so this gesture is cancelled. const currentOffsetX = this.state.xDotOffsetAtAnimationStart + this.state.dotOffset.x._value; if ( currentOffsetX < 0 || currentOffsetX > this.state.trackLayout.width ) { await this.state.dotOffset.setValue({ x: -this.state.xDotOffsetAtAnimationStart, y: 0, }); } await this.state.dotOffset.flattenOffset(); await this.mapAudioToCurrentTime(); }, onPanResponderRelease: async (e, { vx }) => { const currentOffsetX = this.state.xDotOffsetAtAnimationStart + this.state.dotOffset.x._value; if ( currentOffsetX < 0 || currentOffsetX > this.state.trackLayout.width ) { await this.state.dotOffset.setValue({ x: -this.state.xDotOffsetAtAnimationStart, y: 0, }); } await this.state.dotOffset.flattenOffset(); await this.mapAudioToCurrentTime(); }, }); } _updateProps() { const props = this.props; this.registry = props.registry; this.style = props.style || {}; if (this.registry) { this.registry.register(this); } const { pauseAllBeforePlay = true } = props; this.pauseAllBeforePlay = pauseAllBeforePlay; } componentDidUpdate() { this._updateProps(); } mapAudioToCurrentTime = async () => { await this.soundObject.setPositionAsync(this.state.currentTime); }; onPressPlayPause = async () => { if (this.state.playing) { await this.pause(); return; } await this.play(); }; play = async () => { if (this.registry && this.pauseAllBeforePlay) { const players = this.registry.getAll(); await Promise.all( players.filter((p) => this !== p).map((p) => p.pause()), ); } await this.soundObject.playAsync(); this.setState({ playing: true }); // This is for the play-button to go to play this.startMovingDot(); }; pause = async () => { await this.soundObject.pauseAsync(); this.setState({ playing: false }); // This is for the play-button to go to pause Animated.timing(this.state.dotOffset, { useNativeDriver: false }).stop(); // Will also call animationPausedOrStopped() }; startMovingDot = async () => { const status = await this.soundObject.getStatusAsync(); const durationLeft = status["durationMillis"] - status["positionMillis"]; Animated.timing(this.state.dotOffset, { toValue: { x: this.state.trackLayout.width, y: 0 }, duration: durationLeft, easing: Easing.linear, useNativeDriver: false, }).start(() => this.animationPausedOrStopped()); }; animationPausedOrStopped = async () => { if (!this.state.playing) { // Audio has been paused return; } // Animation-duration is over (reset Animation and Audio): await sleep(200); // In case animation has finished, but audio has not this.setState({ playing: false }); await this.state.dotOffset.setValue({ x: 0, y: 0 }); // this.state.dotOffset.setValue(0); await this.soundObject.setPositionAsync(0); }; measureTrack = (event) => { this.setState({ trackLayout: event.nativeEvent.layout }); // {x, y, width, height} }; async componentDidMount() { // https://github.com/olapiv/expo-audio-player/issues/13 const loadAudio = async () => { try { const { sound: newSound } = await Audio.Sound.createAsync({ uri: this.props.audio, }); this.soundObject = newSound; // // https://github.com/expo/expo/issues/1873 this.soundObject.setOnPlaybackStatusUpdate((status) => { if (!status.didJustFinish) return; this.soundObject.unloadAsync().catch(() => {}); }); } catch (error) { console.log("Error loading audio:", error); } }; await loadAudio(); const status = await this.soundObject.getStatusAsync(); this.setState({ duration: status.durationMillis }); // This requires measureTrack to have been called. this.state.dotOffset.addListener(() => { const animatedCurrentTime = this.state.dotOffset.x .interpolate({ inputRange: [0, this.state.trackLayout.width], outputRange: [0, this.state.duration], extrapolate: "clamp", }) .__getValue(); this.setState({ currentTime: animatedCurrentTime }); }); } async componentWillUnmount() { await this.soundObject.unloadAsync(); this.state.dotOffset.removeAllListeners(); if (this.registry) { this.registry.unregister(this); } } render() { return ( {this.state.playing ? ( ) : ( )} ); } } export default withHooks(AudioSlider, () => { const styles = useStyles(); return { styles }; });