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

505 lines
12 KiB

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<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 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),
}
}