import React, { useCallback, useContext, useEffect, useState } from 'react'; import { type LayoutChangeEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { PlayerContext } from '../util/PlayerContext'; import { Slider } from '@miblanchard/react-native-slider'; import { useChaptersTrack, useDebounce, useDuration, useSeekable } from '../../hooks/barrel'; import { SingleThumbnailView } from './thumbnail/SingleThumbnailView'; import { useSlider } from './useSlider'; import { TestIDs } from '../../utils/TestIDs'; import { SeekBarTouchHandler } from './SeekBarTouchHandler'; import { PlayerEventType, SeekedEvent } from 'react-native-theoplayer'; import { fuzzyEquals } from '../../utils/NumberUtils'; export type ThumbDimensions = { height: number; width: number; }; const SEEKED_TOLERANCE = 1e3; const WAIT_FOR_SEEKED_TIMEOUT = 8e3; export interface SeekBarProps { /** * Optional style applied to the SeekBar. */ style?: StyleProp; /** * Optional style applied to the SeekBar container. */ sliderContainerStyle?: ViewStyle; /** * Optional style applied to the track left of the thumb. */ sliderMinimumTrackStyle?: ViewStyle; /** * Optional style applied to the track right of the thumb. */ sliderMaximumTrackStyle?: ViewStyle; /** * Optional */ chapterMarkers?: (index?: number) => React.ReactNode; /** * Callback for slider value updates. The provided callback will not be debounced. */ onScrubbing?: (scrubberTime: number | undefined) => void; /** * Optional override the component that is rendered above the thumbnail. */ renderAboveThumbComponent?: (isScrubbing: boolean, scrubberTime: number | undefined, seekBarWidth: number) => React.ReactNode; /** * Optional style applied to the thumb of the slider. */ thumbStyle?: StyleProp; /** * Expose thumbTouchSize prop to allow custom thumb touch size. */ thumbTouchSize?: ThumbDimensions; /** * An id used to locate this view in end-to-end tests. * * @default 'seek-bar' */ testID?: string; } /** * The delay in milliseconds before an actual seek is executed while scrubbing the SeekBar. */ const DEBOUNCE_SEEK_DELAY = 250; const renderThumbnailView = (isScrubbing: boolean, scrubberTime: number | undefined, seekBarWidth: number): React.ReactNode => { return isScrubbing && scrubberTime !== undefined && ; }; export const SeekBar = (props: SeekBarProps) => { const { onScrubbing, renderAboveThumbComponent: customRenderAboveThumbComponent } = props; const { player, style: theme, adInProgress } = useContext(PlayerContext); const [width, setWidth] = useState(0); const [seekTarget, setSeekTarget] = useState(undefined); const duration = useDuration(); const seekable = useSeekable(); const [sliderTime, isScrubbing, setIsScrubbing] = useSlider(); const chapters = useChaptersTrack(); const chapterMarkerTimes: number[] = chapters?.cues?.map((cue) => cue.endTime).slice(0, -1) ?? []; // Do not continuously seek while dragging the slider const debounceSeek = useDebounce((value: number) => { // eslint-disable-next-line react-hooks/immutability player.currentTime = value; }, DEBOUNCE_SEEK_DELAY); const onSlidingStart = useCallback( ([value]: number[]) => { setIsScrubbing(true); debounceSeek(value); }, [setIsScrubbing, debounceSeek], ); const onSlidingValueChange = useCallback( ([value]: number[]) => { setSeekTarget(value); if (isScrubbing) { if (onScrubbing) onScrubbing(value); debounceSeek(value); } }, [isScrubbing, onScrubbing, debounceSeek, setSeekTarget], ); const onSlidingComplete = useCallback( ([value]: number[]) => { setSeekTarget(value); debounceSeek(value, true); }, [setSeekTarget, debounceSeek], ); useEffect(() => { if (seekTarget === undefined) return; const onSeeked = (event: SeekedEvent) => { if (!fuzzyEquals(event.currentTime, seekTarget, SEEKED_TOLERANCE)) { return; } cleanup(); setIsScrubbing(false); setSeekTarget(undefined); }; const cleanup = () => { clearTimeout(timeout); player.removeEventListener(PlayerEventType.SEEKED, onSeeked); }; player.addEventListener(PlayerEventType.SEEKED, onSeeked); const timeout = setTimeout(cleanup, WAIT_FOR_SEEKED_TIMEOUT); return cleanup; }, [player, seekTarget, setIsScrubbing]); const normalizedDuration = normalizedTime(duration); const seekableRange = { start: seekable.length > 0 ? seekable[0].start : 0, end: seekable.length > 0 ? seekable[seekable.length - 1].end : normalizedDuration, }; const renderAboveThumbComponent = (_index: number, value: number) => { if (customRenderAboveThumbComponent) { return customRenderAboveThumbComponent(isScrubbing, value, width); } return renderThumbnailView(isScrubbing, value, width); }; /** * Disable the seekbar: * - while playing an ad; * - if duration === 0; * - if seekable length === 0; * * Do not disable seekbar for live content. */ const isLive = duration === Infinity; const disabled = (!(normalizedDuration > 0 || isLive) && seekable.length > 0) || adInProgress; return ( { setWidth(event.nativeEvent.layout.width); }}> ); }; function normalizedTime(time: number): number { return isNaN(time) || !isFinite(time) ? 0 : Math.max(0, time); }