Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/common-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
"react-native-audio-worklets": "workspace:*",
"react-native-background-timer": "2.4.1",
"react-native-gesture-handler": "3.1.0",
"react-native-reanimated": "4.6.0-nightly-20260812-0f8cf6dcc",
"react-native-reanimated": "4.6.0",
"react-native-safe-area-context": "5.9.0",
"react-native-screens": "4.27.0",
"react-native-svg": "15.15.4",
"react-native-worklets": "0.12.0"
"react-native-worklets": "0.12.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
Expand Down
28 changes: 14 additions & 14 deletions apps/fabric-example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1859,7 +1859,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.6.0-nightly-20260812-0f8cf6dcc):
- RNReanimated (4.6.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -1881,12 +1881,12 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/apple (= 4.6.0-nightly-20260812-0f8cf6dcc)
- RNReanimated/common (= 4.6.0-nightly-20260812-0f8cf6dcc)
- RNReanimated/view (= 4.6.0-nightly-20260812-0f8cf6dcc)
- RNReanimated/apple (= 4.6.0)
- RNReanimated/common (= 4.6.0)
- RNReanimated/view (= 4.6.0)
- RNWorklets
- Yoga
- RNReanimated/apple (4.6.0-nightly-20260812-0f8cf6dcc):
- RNReanimated/apple (4.6.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -1910,7 +1910,7 @@ PODS:
- ReactNativeDependencies
- RNWorklets
- Yoga
- RNReanimated/common (4.6.0-nightly-20260812-0f8cf6dcc):
- RNReanimated/common (4.6.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -1934,7 +1934,7 @@ PODS:
- ReactNativeDependencies
- RNWorklets
- Yoga
- RNReanimated/view (4.6.0-nightly-20260812-0f8cf6dcc):
- RNReanimated/view (4.6.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2050,7 +2050,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets (0.12.0):
- RNWorklets (0.12.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -2072,10 +2072,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/apple (= 0.12.0)
- RNWorklets/common (= 0.12.0)
- RNWorklets/apple (= 0.12.1)
- RNWorklets/common (= 0.12.1)
- Yoga
- RNWorklets/apple (0.12.0):
- RNWorklets/apple (0.12.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand All @@ -2098,7 +2098,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets/common (0.12.0):
- RNWorklets/common (0.12.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2507,10 +2507,10 @@ SPEC CHECKSUMS:
RNAudioAPI: 5fe6257beb4b9d735cb0916630067b6d2f57b22c
RNAudioWorklets: e763c48efcbd47d25f61eb5bb49a72d8205b5f4e
RNGestureHandler: f09eca0b47053142f7543f5c71d183f215ba5d81
RNReanimated: eec73cef964aed3be56e059cae1e9cf8d3b42cfe
RNReanimated: eabb9b5c492b8e1f16f5523c354ab3a8770c4102
RNScreens: a2103f13fa603c405092daf70ca4eb3a6291fd6d
RNSVG: 2db63ac8e69ffb8575003599f6871f9b890a9b1e
RNWorklets: 43618a5f1408b84ed727788aaa54a5a618880659
RNWorklets: 1d81d29f943ad914556a0ad6b5f6b1c50aa844c0
SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871
Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e

Expand Down
223 changes: 91 additions & 132 deletions packages/react-native-audio-api/src/Audio/controls/AudioControls.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Image,
PanResponder,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import type { LayoutChangeEvent } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedRef,
useSharedValue,
} from 'react-native-reanimated';
import { useAudioTagContext } from '../AudioTagContext';
import { isPlaybackActive } from '../utils';
import {
Expand All @@ -31,10 +28,8 @@ import MuteIcon from './icons/speaker-x.png';
const TRACK_BAR_HEIGHT = 12;
const TRACK_BAR_HEIGHT_PRESSED = 18;
const TRACK_BAR_ANIM_MS = 150;
const SCRUB_PAN_MIN_DISTANCE = 8;
const BUFFERING_SWEEP_MS = 1100;
const BUFFERING_SWEEP_WIDTH_RATIO = 0.35;
// Opacity ramp faking a soft-edged gradient band
const BUFFERING_SWEEP_SLICE_OPACITIES = [
0.04, 0.18, 0.45, 0.6, 0.45, 0.18, 0.04,
];
Expand All @@ -59,103 +54,70 @@ const AudioControls: React.FC = () => {
);

const [scrubTime, setScrubTime] = useState<number | null>(null);
const progressTrackRef = useAnimatedRef<View>();
const progressMetricsWidth = useSharedValue(0);
const [trackWidth, setTrackWidth] = useState(0);
const isBuffering = playbackState === 'buffering';
const bufferingSweepStyle = useBufferingSweep(
isBuffering,
progressMetricsWidth,
trackWidth,
BUFFERING_SWEEP_WIDTH_RATIO,
BUFFERING_SWEEP_MS
);
const durationRef = useRef(duration);
durationRef.current = duration;

const onStart = useCallback(
(x: number) => {
progressTrackAnim.expand();
const d = durationRef.current;
progressTrackRef.current?.measureInWindow(
(_left: number, _y: number, width: number, _h: number) => {
progressMetricsWidth.value = width;
setScrubTime(timeFromLocationX(x, width, d));
}
);
},
[progressTrackAnim, progressMetricsWidth, setScrubTime, progressTrackRef]
);

const onUpdate = useCallback(
(x: number) => {
const d = durationRef.current;
const w = progressMetricsWidth.value;
setScrubTime(timeFromLocationX(x, w, d));
},
[progressMetricsWidth]
);

const seekTo = useCallback(
(x: number) => {
const d = durationRef.current;
const w = progressMetricsWidth.value;
const t = timeFromLocationX(x, w, d);
seekToTime(t);
},
[progressMetricsWidth, seekToTime]
);

const onEnd = useCallback(
(x: number) => {
seekTo(x);
progressTrackAnim.collapse();
setScrubTime(null);
},
[progressTrackAnim, seekTo]
);

const onCancel = useCallback(() => {
progressTrackAnim.collapse();
setScrubTime(null);
}, [progressTrackAnim]);
const scrub = useRef({
startX: 0,
trackWidth: 0,
duration,
seekToTime,
expand: progressTrackAnim.expand,
collapse: progressTrackAnim.collapse,
});
scrub.current.duration = duration;
scrub.current.seekToTime = seekToTime;
scrub.current.expand = progressTrackAnim.expand;
scrub.current.collapse = progressTrackAnim.collapse;

const onTapSeek = useCallback(
(x: number) => {
onEnd(x);
},
[onEnd]
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (event) => {
const s = scrub.current;
s.startX = event.nativeEvent.locationX;
s.expand();
setScrubTime(timeFromLocationX(s.startX, s.trackWidth, s.duration));
},
onPanResponderMove: (_event, gestureState) => {
const s = scrub.current;
setScrubTime(
timeFromLocationX(
s.startX + gestureState.dx,
s.trackWidth,
s.duration
)
);
},
onPanResponderRelease: (_event, gestureState) => {
const s = scrub.current;
s.seekToTime(
timeFromLocationX(
s.startX + gestureState.dx,
s.trackWidth,
s.duration
)
);
s.collapse();
setScrubTime(null);
},
onPanResponderTerminate: () => {
scrub.current.collapse();
setScrubTime(null);
},
}),
[]
);

const scrubGesture = useMemo(() => {
const panGesture = Gesture.Pan()
.runOnJS(true)
.minDistance(SCRUB_PAN_MIN_DISTANCE)
.onStart((e) => {
onStart(e.x);
})
.onUpdate((e) => {
onUpdate(e.x);
})
.onEnd((e) => {
onEnd(e.x);
})
.onFinalize((_e, success) => {
if (!success) {
onCancel();
}
});

const tapGesture = Gesture.Tap()
.runOnJS(true)
.maxDistance(14)
.onEnd((e, success) => {
if (success) {
onTapSeek(e.x);
}
});

return Gesture.Race(panGesture, tapGesture);
}, [onStart, onUpdate, onEnd, onCancel, onTapSeek]);

const onPlayPausePress = useCallback(() => {
if (isPlaybackActive(playbackState)) {
pause();
Expand All @@ -164,12 +126,11 @@ const AudioControls: React.FC = () => {
}
}, [playbackState, pause, play]);

const onProgressTrackLayout = useCallback(
(event: LayoutChangeEvent) => {
progressMetricsWidth.value = event.nativeEvent.layout.width;
},
[progressMetricsWidth]
);
const onProgressTrackLayout = useCallback((event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
scrub.current.trackWidth = width;
setTrackWidth(width);
}, []);

if (!ready) {
return (
Expand All @@ -187,50 +148,44 @@ const AudioControls: React.FC = () => {
<View style={styles.topRow}>
<Pressable style={styles.playPause} onPress={onPlayPausePress}>
{isPlaybackActive(playbackState) ? (
<Image source={PauseIcon} style={{ width: 24, height: 24 }} />
<Image source={PauseIcon} style={styles.icon} />
) : (
<Image source={PlayIcon} style={{ width: 24, height: 24 }} />
<Image source={PlayIcon} style={styles.icon} />
)}
</Pressable>

<Text style={styles.timeText}>
{formatTime(displayTime)} / {formatTime(duration)}
</Text>

<GestureDetector gesture={scrubGesture}>
{/* prettier-ignore */}
<View
ref={progressTrackRef}
onLayout={onProgressTrackLayout}
style={styles.progressTrack}
collapsable={false}>
<Animated.View
style={[styles.trackInner, progressTrackAnim.animatedStyle]}
>
<View
style={[styles.trackFill, { width: `${progress * 100}%` }]}
/>
{isBuffering && (
<Animated.View
pointerEvents="none"
style={[styles.bufferingSweep, bufferingSweepStyle]}>
{BUFFERING_SWEEP_SLICE_OPACITIES.map((opacity, index) => (
<View
key={index}
style={[styles.bufferingSweepSlice, { opacity }]}
/>
))}
</Animated.View>
)}
</Animated.View>
</View>
</GestureDetector>
{/* prettier-ignore */}
<View
onLayout={onProgressTrackLayout}
style={styles.progressTrack}
{...panResponder.panHandlers}>
<Animated.View
style={[styles.trackInner, progressTrackAnim.animatedStyle]}>
<View style={[styles.trackFill, { width: `${progress * 100}%` }]} />
{isBuffering && (
<Animated.View
pointerEvents="none"
style={[styles.bufferingSweep, bufferingSweepStyle]}>
{BUFFERING_SWEEP_SLICE_OPACITIES.map((opacity, index) => (
<View
key={index}
style={[styles.bufferingSweepSlice, { opacity }]}
/>
))}
</Animated.View>
)}
</Animated.View>
</View>

<Pressable style={styles.volumeIcon} onPress={() => setMuted(!muted)}>
{muted ? (
<Image source={MuteIcon} style={{ width: 24, height: 24 }} />
<Image source={MuteIcon} style={styles.icon} />
) : (
<Image source={VolumeIcon} style={{ width: 24, height: 24 }} />
<Image source={VolumeIcon} style={styles.icon} />
)}
</Pressable>
</View>
Expand Down Expand Up @@ -265,6 +220,10 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
},
icon: {
width: 24,
height: 24,
},
playPause: {
padding: 4,
marginRight: 12,
Expand Down
Loading
Loading