import { MouseEvent, useMemo, useRef, useCallback, useEffect, useState, } from 'react' import { useTour } from '@reactour/tour' import size from 'lodash/size' import isNumber from 'lodash/isNumber' import isEmpty from 'lodash/isEmpty' import isUndefined from 'lodash/isUndefined' import findIndex from 'lodash/findIndex' import inRange from 'lodash/inRange' import sum from 'lodash/sum' import values from 'lodash/values' import Hls from 'hls.js' import { isIOS, isMobileDevice, KEYBOARD_KEYS, } from 'config' import { useObjectState, useEventListener, usePageParams, useInterval, useDuration, } from 'hooks' 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 { useMatchPageStore } from 'features/MatchPage/store' import { VIEW_INTERVAL_MS, 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 { useAudioTrack } from './useAudioTrack' import { FULL_GAME_KEY } from '../../MatchPage/helpers/buildPlaylists' export type PlayerState = typeof initialState const toMilliSeconds = (seconds: number) => seconds * 1000 const BUFFERING_TIME = 30 * 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, url?: string, } export const useVideoPlayer = ({ chapters: chaptersProps, isLive, onDurationChange, onPlayingChange, onProgressChange: progressChangeCallback, resumeFrom, }: Props) => { const [currentPlayingOrder, setCurrentPlayingOrder] = useState(0) const ordersObj = useRef>({}) const [{ activeChapterIndex, buffering, chapters, duration: fullMatchDuration, loadedProgress, playedProgress, playing: statePlaying, ready, seek, seeking, }, setPlayerState] = useObjectState({ ...initialState, chapters: chaptersProps }) const { onPlaylistSelect } = useLiveMatch() const { lang } = useLexicsStore() const { profileId, sportType } = usePageParams() const { disablePlayingEpisodes, isPlayFilterEpisodes, isPlayingEpisode, matchPlaylists, playingOrder, playingProgress, playNextEpisode, profile, selectedPlaylist, setCircleAnimation, setIsFullScreen, setPlayingProgress, } = useMatchPageStore() const { isOpen } = useTour() const playing = Boolean(statePlaying && !isOpen) /** время для сохранения статистики просмотра матча */ const timeForStatistics = useRef(0) const resumeTimeWithOffset = useMemo(() => { const chapterWithOffset = chapters[0].startOffsetMs / 1000 return !isUndefined(resumeFrom) && isLive && chapters[0].isFullMatchChapter ? resumeFrom + chapterWithOffset : resumeFrom // eslint-disable-next-line react-hooks/exhaustive-deps }, [resumeFrom]) const { url } = chapters[0] ?? { url: '' } const numberOfChapters = size(chapters) const { hls, videoRef } = useHlsPlayer({ isLive, resumeFrom: resumeTimeWithOffset, src: url, }) // eslint-disable-next-line no-unsafe-optional-chaining const videoRefDurationMs = videoRef.current?.duration! * 1000 const [isLivePlaying, setIsLivePlaying] = useState(false) const [isPausedTime, setIsPausedTime] = useState(false) const pausedProgress = useRef(0) const getActiveChapter = useCallback( (index: number = activeChapterIndex) => chapters[index], [chapters, activeChapterIndex], ) useEffect(() => { const splitEpisodeDuration = (duration: number, count: number) => { const result: Array<[number, number]> = [] for (let i = 0; i < duration; i += count) { const start = i const end = Math.min(i + count, duration) result.push([start, end]) } return result } const getCurrentPlayingOrder = () => { const { count, duration } = getActiveChapter() const arr = splitEpisodeDuration(duration, duration / count!) const index = findIndex(arr, (elem) => inRange( playingProgress * 1000, elem[0], elem[1] + 1, )) if (index !== -1 && ordersObj.current[playingOrder] !== index) { ordersObj.current = { ...ordersObj.current, [playingOrder]: index, } } return playingOrder + sum(values(ordersObj.current)) } setCurrentPlayingOrder(getCurrentPlayingOrder()) }, [getActiveChapter, isPlayingEpisode, playingOrder, playingProgress]) useEffect(() => { if (!isPlayingEpisode) { ordersObj.current = {} } }, [isPlayingEpisode]) const chaptersDuration = useDuration(chapters) const duration = useMemo(() => { if (isLive && chapters[0]?.isFullMatchChapter) { if (isIOS) { return fullMatchDuration - getActiveChapter().startOffsetMs } if (Number.isFinite(videoRefDurationMs)) { return videoRefDurationMs - getActiveChapter().startOffsetMs } } return chaptersDuration // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chapters, chaptersDuration, fullMatchDuration, getActiveChapter, isLive, ]) const { onReady, playNextChapter, playPrevChapter, stopPlaying, togglePlaying, } = usePlayingHandlers(setPlayerState, chapters) const restartVideo = useCallback(() => { onPlaylistSelect(matchPlaylists.match[0]) }, [matchPlaylists.match, onPlaylistSelect]) 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 isLiveTime = useMemo(() => { const matchDuration = duration === 0 && Number.isFinite(videoRefDurationMs) ? videoRefDurationMs : duration return chapters[0]?.isFullMatchChapter && isLive && playedProgress > matchDuration - BUFFERING_TIME * 1.5 }, [ chapters, duration, isLive, playedProgress, videoRefDurationMs, ]) 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 }) timeForStatistics.current = (value + chapter.startMs) / 1000 chapter.isFullMatchChapter && setPlayingProgress(Math.floor(value / 1000)) progressChangeCallback(value / 1000) } const backToLive = useCallback(() => { if (!duration) return if (selectedPlaylist?.id !== FULL_GAME_KEY) { restartVideo() setIsLivePlaying(true) return } const matchDuration = fullMatchDuration === 0 && Number.isFinite(videoRefDurationMs) ? videoRefDurationMs : fullMatchDuration const liveProgressMs = Math.max(matchDuration - BUFFERING_TIME, 0) setPlayerState({ playedProgress: liveProgressMs, seek: liveProgressMs / 1000 }) if (liveProgressMs > 0 && isLiveTime) setIsLivePlaying(false) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ duration, selectedPlaylist, setPlayerState, matchPlaylists.match, isLiveTime, fullMatchDuration, videoRefDurationMs, restartVideo, ]) const backToPausedTime = useCallback(() => { if (!duration) return if (selectedPlaylist?.id !== FULL_GAME_KEY) { restartVideo() setIsPausedTime(true) return } setIsPausedTime(false) // eslint-disable-next-line }, [ duration, selectedPlaylist, restartVideo, ]) const stopPlayingEpisodes = () => { disablePlayingEpisodes() restartVideo() setTimeout(() => { setPlayerState({ playedProgress: pausedProgress.current, seek: pausedProgress.current / 1000, }) }, 100) } useEffect(() => { if (chapters[0]?.isFullMatchChapter) { pausedProgress.current = playedProgress + chapters[0].startOffsetMs } // eslint-disable-next-line }, [selectedPlaylist]) useEffect(() => { if ( isLive && isNumber(duration) && isUndefined(resumeFrom) && chaptersProps[0]?.isFullMatchChapter ) { backToLive() } // eslint-disable-next-line }, []) useEventListener({ callback: (e: KeyboardEvent) => { if (isOpen) return if (e.code === KEYBOARD_KEYS.ArrowLeft) rewindBackward() else if (e.code === KEYBOARD_KEYS.ArrowRight) rewindForward() }, event: 'keydown', }) useEffect(() => { if (isNumber(seek)) { setPlayerState({ seek: undefined }) } }, [seek, setPlayerState]) useEffect(() => { onPlayingChange(selectedPlaylist?.id === FULL_GAME_KEY ? playing : false) // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, selectedPlaylist]) useEffect(() => { if (playing) { setPlayerState({ buffering: false }) } // eslint-disable-next-line }, [playing]) const regURL = /\d{6,20}/gi useEffect(() => { if (isLive) { setPlayerState({ ...initialState, chapters: chaptersProps, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLive, chaptersProps[0].startOffsetMs]) 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 if ( !isUndefined(resumeFrom) && chaptersProps[0].isFullMatchChapter && !isLivePlaying && !isPausedTime ) { setPlayerState({ ...initialState, chapters: chaptersProps, playing: true, seek: resumeFrom + chaptersProps[0].startOffsetMs / 1000, }) return } if ( chaptersProps[0].isFullMatchChapter && isLive && selectedPlaylist.id === FULL_GAME_KEY && isLivePlaying && !isPausedTime ) { setIsLivePlaying(false) setPlayerState({ ...initialState, chapters: chaptersProps, duration: videoRefDurationMs, playing: true, seek: Number.isFinite(videoRefDurationMs) ? (videoRefDurationMs - BUFFERING_TIME) / 1000 : 0, }) return } if ( chaptersProps[0].isFullMatchChapter && selectedPlaylist.id === FULL_GAME_KEY && isPausedTime && !isLivePlaying ) { setIsPausedTime(false) setPlayerState({ ...initialState, chapters: chaptersProps, duration: videoRefDurationMs, playing: true, seek: pausedProgress.current / 1000, }) return } setPlayerState({ ...initialState, chapters: chaptersProps, playing: true, seek: chaptersProps[0].startOffsetMs / 1000, }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chapters, chaptersProps, isLive, setPlayerState, isPausedTime, isLivePlaying, videoRef, selectedPlaylist, ]) /** * Для воcпроизведения нового эпизода, аналогичного текущему, с начала */ useEffect(() => { if (chaptersProps[0].isFullMatchChapter) return setPlayerState({ ...initialState, chapters: chaptersProps, playing: true, seek: chaptersProps[0].startOffsetMs / 1000, }) }, [chaptersProps, setPlayerState]) useEffect(() => { if (( chapters[0]?.isFullMatchChapter) || isEmpty(chapters) || selectedPlaylist.id === FULL_GAME_KEY ) return const { duration: chapterDuration } = getActiveChapter() if (playedProgress >= chapterDuration && !seeking && !isPlayFilterEpisodes) { if (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 (isLive) hls?.on(Hls.Events.BUFFER_EOS, restartVideo) return () => { hls?.off(Hls.Events.BUFFER_EOS, restartVideo) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hls, isLive]) useEffect(() => { if (!navigator.serviceWorker || !isIOS) return undefined const listener = (event: MessageEvent<{ duration: number }>) => { setPlayerState({ duration: toMilliSeconds(event.data.duration) }) } navigator.serviceWorker.addEventListener('message', listener) return () => { navigator.serviceWorker.removeEventListener('message', listener) } }, [setPlayerState]) useEffect(() => { if (ready && videoRef && !isMobileDevice) { videoRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start', }) } }, [ready, videoRef]) useEffect(() => { 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: useCallback( () => { if (timeForStatistics.current !== 0) { saveMatchStats({ matchDate: profile?.date, matchId: profileId, matchSecond: timeForStatistics.current, seasonId: profile?.season.id, sportType, teamFirst: profile?.team1.id, teamSecond: profile?.team2.id, tournamentId: profile?.tournament.id, }) } }, [profile?.date, profile?.season.id, profile?.team1.id, profile?.team2.id, profile?.tournament.id, profileId, sportType, ], ), intervalDuration: VIEW_INTERVAL_MS, startImmediate: false, }) useEffect(() => { if (playing) { startCollectingStats() } else { stopCollectingStats() } }, [ playing, startCollectingStats, stopCollectingStats, profileId, ]) useEffect(() => { setIsFullScreen(isFullscreen) }, [ isFullscreen, setIsFullScreen, ]) return { activeChapterIndex, allPlayedProgress: playedProgress + getActiveChapter().startMs, backToLive, buffering, chapters, currentPlayingOrder, duration, isFirstChapterPlaying, isFullscreen, isLastChapterPlaying, isLive, isLiveTime, loadedProgress, numberOfChapters, onDuration, onError, onFullscreenClick, onLoadedProgress, onPause, onPlay, onPlayedProgress, onPlayerClick, onPlaying, onProgressChange, onReady, onWaiting, playNextChapter, playPrevChapter, playedProgress, playing, ready, rewindBackward, rewindForward, seek, sizeOptions, stopPlayingEpisodes, togglePlaying, url, videoRef, warningText, wrapperRef, ...useControlsVisibility(isFullscreen, playing), ...useVolume(), ...useVideoQuality(hls), ...useAudioTrack(hls), } }