import type { MouseEvent } from 'react' import { useCallback, useEffect, useState, } from 'react' import size from 'lodash/size' import isNumber from 'lodash/isNumber' import isEmpty from 'lodash/isEmpty' import { isIOS } from 'config/userAgent' import { useObjectState, useEventListener, usePageParams, useInterval, } from 'hooks' import type { TSetCircleAnimation } from 'features/CircleAnimationBar' import type { Chapters } from 'features/StreamPlayer/types' import { useVolume } from 'features/VideoPlayer/hooks/useVolume' import { useNoNetworkPopupStore } from 'features/NoNetworkPopup' import { useLiveMatch } from 'features/MatchPage/components/LiveMatch/hooks' import { useLexicsStore } from 'features/LexicsStore' import { intervalMs, saveMatchStats } from 'requests' import { REWIND_SECONDS } from '../config' import { useHlsPlayer } from './useHlsPlayer' import { useFullscreen } from './useFullscreen' import { useVideoQuality } from './useVideoQuality' import { useControlsVisibility } from './useControlsVisibility' import { useProgressChangeHandler } from './useProgressChangeHandler' import { usePlayingHandlers } from './usePlayingHandlers' import { useDuration } from './useDuration' import { useAudioTrack } from './useAudioTrack' export type PlayerState = typeof initialState const toMilliSeconds = (seconds: number) => seconds * 1000 const initialState = { activeChapterIndex: 0, buffering: true, chapters: [] as Chapters, duration: 0, loadedProgress: 0, playedProgress: 0, playing: false, ready: false, seek: 0, seeking: false, } export type Props = { chapters: Chapters, isLive: boolean, onDurationChange?: (duration: number) => void, onPlayingChange: (playing: boolean) => void, onProgressChange: (seconds: number) => void, resumeFrom?: number, setCircleAnimation: TSetCircleAnimation, url?: string, } export const useVideoPlayer = ({ chapters: chaptersProps, isLive, onDurationChange, onPlayingChange, onProgressChange: progressChangeCallback, resumeFrom, setCircleAnimation, }: Props) => { const [{ activeChapterIndex, buffering, chapters, duration: fullMatchDuration, loadedProgress, playedProgress, playing, ready, seek, seeking, }, setPlayerState] = useObjectState({ ...initialState, chapters: chaptersProps }) const { isPlayFilterEpisodes, onPlaylistSelect, playNextEpisode, selectedPlaylist, } = useLiveMatch() const { lang } = useLexicsStore() const { profileId, sportType } = usePageParams() const { url } = chapters[0] ?? { url: '' } const numberOfChapters = size(chapters) const { hls, videoRef } = useHlsPlayer({ isLive, resumeFrom, src: url, }) const [isLiveTime, setIsLiveTime] = useState(false) const [isPausedTime, setIsPausedTime] = useState(false) const [pausedProgress, setPausedProgress] = useState(0) const chaptersDuration = useDuration(chapters) const duration = (isLive && chapters[0]?.isFullMatchChapter) ? fullMatchDuration : chaptersDuration const { onReady, playNextChapter, playPrevChapter, stopPlaying, togglePlaying, } = usePlayingHandlers(setPlayerState, chapters) const getActiveChapter = useCallback( (index: number = activeChapterIndex) => chapters[index], [chapters, activeChapterIndex], ) const { isFullscreen, onFullscreenClick, wrapperRef, } = useFullscreen(videoRef) const [sizeOptions, setSizeOptions] = useState({ height: wrapperRef.current?.clientHeight, width: wrapperRef.current?.clientWidth, }) const isFirstChapterPlaying = activeChapterIndex === 0 const isLastChapterPlaying = activeChapterIndex === numberOfChapters - 1 const seekTo = useCallback((progressMs: number) => { if (!videoRef.current) return videoRef.current.currentTime = progressMs / 1000 }, [videoRef]) const rewindForward = () => { const chapter = getActiveChapter() const newProgress = playedProgress + REWIND_SECONDS * 1000 if (newProgress <= chapter.duration || isLive) { seekTo(chapter.startOffsetMs + newProgress) } else if (isLastChapterPlaying) { playNextChapter() } else { const nextChapter = getActiveChapter(activeChapterIndex + 1) const fromMs = newProgress - chapter.duration playNextChapter(fromMs, nextChapter.startOffsetMs) } } const rewindBackward = () => { const chapter = getActiveChapter() const newProgress = playedProgress - REWIND_SECONDS * 1000 if (newProgress >= 0) { seekTo(chapter.startOffsetMs + newProgress) } else if (isFirstChapterPlaying) { seekTo(chapter.startOffsetMs) } else { const prevChapter = getActiveChapter(activeChapterIndex - 1) const fromMs = prevChapter.duration + newProgress playPrevChapter(fromMs, prevChapter.startOffsetMs) } } const onError = useCallback(() => { setPlayerState({ playing: false }) }, [setPlayerState]) const onPlayerClick = (e: MouseEvent) => { if (e.target === videoRef.current) { togglePlaying() } } const onWaiting = () => { setPlayerState({ buffering: true }) } const onPlaying = () => { setPlayerState({ buffering: false }) } const onPause = () => { setPlayerState({ playing: false }) } const onPlay = () => { setPlayerState({ playing: true }) } const onDuration = (durationSeconds: number) => { setPlayerState({ duration: toMilliSeconds(durationSeconds) }) onDurationChange?.(durationSeconds) } const onProgressChange = useProgressChangeHandler({ chapters, duration, setPlayerState, }) const onLoadedProgress = (loadedMs: number) => { const chapter = getActiveChapter() const value = loadedMs - chapter.startOffsetMs setPlayerState({ loadedProgress: value }) } const onPlayedProgress = (playedMs: number) => { const chapter = getActiveChapter() const value = Math.max(playedMs - chapter.startOffsetMs, 0) setPlayerState({ playedProgress: value }) progressChangeCallback(value / 1000) } const backToLive = useCallback(() => { if (!duration) return if (selectedPlaylist?.id !== 'full_game') { onPlaylistSelect({ duration: 0, episodes: [], id: 'full_game', lexic: 13028, type: 0, }) setIsLiveTime(true) } const liveProgressMs = Math.max(duration - 30000, 0) setPlayerState({ playedProgress: liveProgressMs, seek: liveProgressMs / 1000 }) if (liveProgressMs > 0) setIsLiveTime(false) }, [ duration, onPlaylistSelect, selectedPlaylist, setPlayerState, ]) const backToPausedTime = useCallback(() => { if (!duration) return if (selectedPlaylist?.id !== 'full_game') { onPlaylistSelect({ duration: 0, episodes: [], id: 'full_game', lexic: 13028, type: 0, }) setIsPausedTime(true) } const liveProgressMs = Math.max(duration - 30000, 0) setPlayerState({ playedProgress: pausedProgress, seek: pausedProgress / 1000 }) if (liveProgressMs > 0) setIsPausedTime(false) // eslint-disable-next-line }, [ duration, onPlaylistSelect, selectedPlaylist, setPlayerState, ]) useEffect(() => { if (chapters[0]?.isFullMatchChapter) { setPausedProgress(playedProgress) } // eslint-disable-next-line }, [selectedPlaylist]) useEffect(() => { if (duration && isLiveTime && chapters[0]?.isFullMatchChapter) { backToLive() } // eslint-disable-next-line }, [duration, isLiveTime]) useEffect(() => { if (duration && isPausedTime && chapters[0]?.isFullMatchChapter ) { backToPausedTime() } // eslint-disable-next-line }, [duration, isPausedTime]) useEventListener({ callback: (e: KeyboardEvent) => { if (e.code === 'ArrowLeft') rewindBackward() else if (e.code === 'ArrowRight') rewindForward() }, event: 'keydown', }) useEffect(() => { if (isNumber(seek)) { setPlayerState({ seek: undefined }) } }, [seek, setPlayerState]) useEffect(() => { onPlayingChange(playing) if (playing) { setPlayerState({ buffering: false }) } // eslint-disable-next-line }, [playing, onPlayingChange]) const regURL = /\d{6,20}/gi useEffect(() => { if (isLive) { setPlayerState({ ...initialState, chapters: chaptersProps, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLive]) useEffect(() => { if (((isLive || chapters[0].duration === chaptersProps[0].duration) && chapters[0]?.endOffsetMs === chaptersProps[0]?.endOffsetMs && chapters[0]?.url.match(regURL)?.[0] === chaptersProps[0]?.url.match(regURL)?.[0]) || (isEmpty(chapters) || isEmpty(chaptersProps))) return setPlayerState({ ...initialState, chapters: chaptersProps, playing: true, seek: chaptersProps[0].startOffsetMs / 1000, }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chapters, chaptersProps, isLive, setPlayerState, ]) useEffect(() => { if ((isLive && chapters[0]?.isFullMatchChapter) || isEmpty(chapters)) return const { duration: chapterDuration } = getActiveChapter() if (playedProgress >= chapterDuration && !seeking && !isPlayFilterEpisodes) { if (isLive && isLastChapterPlaying) { backToPausedTime() } else { playNextChapter() } } if (playedProgress >= chapterDuration && !seeking && isPlayFilterEpisodes) { setPlayerState({ playedProgress: 0 }) playNextEpisode() } // eslint-disable-next-line }, [ isLive, chapters, getActiveChapter, onPlaylistSelect, playedProgress, seeking, playNextChapter, playNextEpisode, isPlayFilterEpisodes, ]) const { isOnline } = useNoNetworkPopupStore() useEffect(() => { if (wrapperRef.current) { setSizeOptions({ height: wrapperRef.current?.clientHeight, width: wrapperRef.current?.clientWidth, }) } }, [wrapperRef]) useEffect(() => { if (!isOnline) { stopPlaying() } }, [ isOnline, stopPlaying, ]) useEffect(() => { if (!navigator.serviceWorker || !isIOS) return undefined const listener = (event: MessageEvent) => { setPlayerState({ duration: toMilliSeconds(event.data.duration) }) } navigator.serviceWorker.addEventListener('message', listener) return () => { navigator.serviceWorker.removeEventListener('message', listener) } }, [setPlayerState]) useEffect(() => { if (ready && videoRef) { videoRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start', }) } }, [ready, videoRef]) useEffect(() => { if (!setCircleAnimation) return setCircleAnimation((state) => ({ ...state, playedProgress, playing, ready, })) }, [ playedProgress, playing, ready, setCircleAnimation, ]) const warningText = lang === 'es' ? 'La transmisión en vivo no está disponible temporalmente en dispositivos iOS' : 'Live streaming is temporarily unavailable on iOS devices' // ведем статистику просмотра матча const { start: startCollectingStats, stop: stopCollectingStats } = useInterval({ callback: () => { saveMatchStats({ matchId: profileId, matchSecond: videoRef.current?.currentTime!, sportType, }) }, intervalDuration: intervalMs, startImmediate: false, }) useEffect(() => { if (playing) { startCollectingStats() } else { stopCollectingStats() } }, [playing, startCollectingStats, stopCollectingStats]) return { activeChapterIndex, allPlayedProgress: playedProgress + getActiveChapter().startMs, backToLive, buffering, chapters, duration, isFirstChapterPlaying, isFullscreen, isLastChapterPlaying, loadedProgress, numberOfChapters, onDuration, onError, onFullscreenClick, onLoadedProgress, onPause, onPlay, onPlayedProgress, onPlayerClick, onPlaying, onProgressChange, onReady, onWaiting, playNextChapter, playPrevChapter, playedProgress, playing, ready, rewindBackward, rewindForward, seek, sizeOptions, togglePlaying, url, videoRef, warningText, wrapperRef, ...useControlsVisibility(isFullscreen, playing), ...useVolume(), ...useVideoQuality(hls), ...useAudioTrack(hls), } }