You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
spa_instat_tv/src/features/StreamPlayer/hooks/index.tsx

748 lines
19 KiB

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<Record<string, number>>({})
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<HTMLDivElement>) => {
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),
}
}