import React, { type PropsWithChildren, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react'; import { AccessibilityChangeEventName, AccessibilityInfo, StyleSheet, View, type ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import ControlsVisibilityProvider from '../context/ControlsVisibility'; import useLayout from '../hooks/useLayout'; import usePinchGesture from '../hooks/usePinchGesture'; import useTapGesture from '../hooks/useTapGesture'; import { ControlSlider } from './ControlSlider'; import ControlVideoState from './ControlVideoState'; import { defaultProps } from './defaultProps'; import type { ComponentProps, Components, ControlSliderProps, ControlVideoStateProps, VideoControlProps, } from './types'; const defaultComponents: Components = { slider: ControlSlider, videoState: ControlVideoState, }; type VideoControlMethods = { toggleVisible: () => void; setVisible: (visible: boolean) => void; }; export const VideoControls = forwardRef< VideoControlMethods, PropsWithChildren >( ( { initialVisible = true, components, componentsProps, onFastForward, onFastRewind, autoHideAfterDuration = 3000, children, videoStateContainerStyle, containerStyle, videoElement, onZoomIn, onZoomOut, autoDismiss = true, enableDismissOnTap = true, }, ref ) => { const [visible, setVisible] = useState(initialVisible); const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); const opacityAnim = useSharedValue(initialVisible ? 1 : 0); const usedComponents = useMemo(() => { return { ...defaultComponents, ...components }; }, [components]); const _componentsProps = useMemo(() => { return { slider: { ...defaultProps.slider, ...componentsProps?.slider, } as ControlSliderProps, videoState: { ...defaultProps.videoState, ...componentsProps?.videoState, } as ControlVideoStateProps, }; }, [componentsProps]); const [containerLayout, onContainerLayout] = useLayout(); const [videoStateLayout, onVideoStateLayout] = useLayout(); const { pinchGesture } = usePinchGesture({ onPinchIn: () => { onZoomOut?.(); }, onPinchOut: () => { onZoomIn?.(); }, }); const toggleVisible = useCallback(() => { if (!isScreenReaderEnabled) { setVisible((old) => !old); } }, []); const tapGesture = useTapGesture({ numberOfTaps: 1, maxTapDuration: 100, onEnd: () => { 'worklet'; if (enableDismissOnTap && !isScreenReaderEnabled) { runOnJS(toggleVisible)(); } }, }); const doubleTap = useTapGesture({ numberOfTaps: 2, maxTapDuration: 250, onEnd: (event) => { 'worklet'; const { x } = event; const containerWidth = containerLayout?.width ?? 0; if (x < containerWidth / 2) { if (onFastRewind) { runOnJS(onFastRewind)(); } } else { if (onFastForward) { runOnJS(onFastForward)(); } } }, }); const videoStatePosition = useMemo(() => { if (!videoStateLayout || !containerLayout) { return undefined; } return { left: containerLayout.width / 2 - videoStateLayout.width / 2, top: containerLayout.height / 2 - videoStateLayout.height / 2, }; }, [videoStateLayout, containerLayout]); useEffect(() => { opacityAnim.value = withTiming(visible ? 1 : 0, { duration: visible ? 200 : 600, }); }, [visible]); const animatedContainerStyle = useAnimatedStyle(() => { return { opacity: opacityAnim.value, }; }, []); const SliderComponent = usedComponents.slider!; const VideoStateComponent = usedComponents.videoState!; const onHide = useCallback(() => { setVisible(false); }, []); useImperativeHandle(ref, () => ({ toggleVisible, setVisible, })); useEffect(() => { if (isScreenReaderEnabled) setVisible(true); }, [isScreenReaderEnabled]); useEffect(() => { const screenReaderChangedSubscription = AccessibilityInfo.addEventListener( 'screenReaderChanged' as AccessibilityChangeEventName, (value: boolean) => { setIsScreenReaderEnabled(value); } ); return () => { // @ts-expect-error screenReaderChangedSubscription.remove(); }; }, []); useEffect(() => { AccessibilityInfo.isScreenReaderEnabled().then(setIsScreenReaderEnabled); }, []); return ( { if (enableDismissOnTap) { toggleVisible(); } }} importantForAccessibility="no" > {videoElement} {children} ); } ); export default VideoControls; const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.3)', zIndex: 999, }, videoStateContainer: { position: 'absolute', }, });