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

540 lines
14 KiB

import type { MouseEvent } from 'react'
import {
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 Hls from 'hls.js'
import { isIOS, KEYBOARD_KEYS } from 'config'
import {
useObjectState,
useEventListener,
usePageParams,
useInterval,
} 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 { useDuration } from './useDuration'
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 [{
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 {
isPlayFilterEpisodes,
matchPlaylists,
playNextEpisode,
selectedPlaylist,
setCircleAnimation,
setPlayingProgress,
} = useMatchPageStore()
const { isOpen } = useTour()
const playing = Boolean(statePlaying && !isOpen)
/** время для сохранения статистики просмотра матча */
const timeForStatistics = useRef(0)
const { url } = chapters[0] ?? { url: '' }
const numberOfChapters = size(chapters)
const { hls, videoRef } = useHlsPlayer({
isLive,
resumeFrom,
src: url,
})
const [isLivePlaying, setIsLivePlaying] = useState(false)
const [isPausedTime, setIsPausedTime] = useState(false)
const [pausedProgress, setPausedProgress] = useState(0)
const getActiveChapter = useCallback(
(index: number = activeChapterIndex) => chapters[index],
[chapters, activeChapterIndex],
)
const chaptersDuration = useDuration(chapters)
const duration = (isLive && chapters[0]?.isFullMatchChapter)
? fullMatchDuration - getActiveChapter().startOffsetMs
: chaptersDuration
const {
onReady,
playNextChapter,
playPrevChapter,
stopPlaying,
togglePlaying,
} = usePlayingHandlers(setPlayerState, chapters)
const restartVideo = () => {
onPlaylistSelect(matchPlaylists.match[0])
}
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 checkLive = () => chapters[0]?.isFullMatchChapter
&& isLive
&& playedProgress > duration - BUFFERING_TIME * 1.5
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
setPlayingProgress(Math.floor(value / 1000))
progressChangeCallback(value / 1000)
}
const backToLive = useCallback(() => {
if (!duration) return
if (selectedPlaylist?.id !== FULL_GAME_KEY) {
restartVideo()
setIsLivePlaying(true)
}
const liveProgressMs = Math.max(fullMatchDuration - BUFFERING_TIME, 0)
setPlayerState({ playedProgress: liveProgressMs, seek: liveProgressMs / 1000 })
if (liveProgressMs > 0) setIsLivePlaying(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
duration,
onPlaylistSelect,
selectedPlaylist,
setPlayerState,
matchPlaylists.match,
])
const backToPausedTime = useCallback(() => {
if (!duration) return
if (selectedPlaylist?.id !== FULL_GAME_KEY) {
restartVideo()
setIsPausedTime(true)
}
const liveProgressMs = Math.max(duration - BUFFERING_TIME, 0)
setPlayerState({ playedProgress: pausedProgress, seek: pausedProgress / 1000 })
if (liveProgressMs > 0) setIsPausedTime(false)
// eslint-disable-next-line
}, [
duration,
onPlaylistSelect,
selectedPlaylist,
setPlayerState,
matchPlaylists.match,
])
useEffect(() => {
if (chapters[0]?.isFullMatchChapter) {
setPausedProgress(playedProgress + chapters[0].startOffsetMs)
}
// eslint-disable-next-line
}, [selectedPlaylist])
useEffect(() => {
if (duration && isLivePlaying && chapters[0]?.isFullMatchChapter) {
backToLive()
}
// eslint-disable-next-line
}, [duration, isLivePlaying])
useEffect(() => {
if (duration
&& isPausedTime
&& chapters[0]?.isFullMatchChapter
) {
backToPausedTime()
}
// eslint-disable-next-line
}, [duration, isPausedTime])
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(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, 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
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 ((chapters[0]?.isFullMatchChapter) || isEmpty(chapters)) 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) {
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: () => {
if (timeForStatistics.current !== 0) {
saveMatchStats({
matchId: profileId,
matchSecond: timeForStatistics.current,
sportType,
})
}
},
intervalDuration: VIEW_INTERVAL_MS,
startImmediate: false,
})
useEffect(() => {
if (playing) {
startCollectingStats()
} else {
stopCollectingStats()
}
}, [
playing,
startCollectingStats,
stopCollectingStats,
profileId,
])
return {
activeChapterIndex,
allPlayedProgress: playedProgress + getActiveChapter().startMs,
backToLive,
buffering,
chapters,
duration,
isFirstChapterPlaying,
isFullscreen,
isLastChapterPlaying,
isLive,
isLiveTime: checkLive(),
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),
}
}