Ott 403 finished game playing (#151)

* Ott 403 finished game playing part 1 (#134)

* fix(#430): fixed styled-components warnings on often changing css rules

* refactor(#430): moved match profile hook to MatchPage level

* feat(#430): added video request

* feat(#430): added playing match video link

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>

* Ott 403 finished game playing part 2 (#136)

* refactor(#403): updated getVideos request return type

* feat(#403): added displaying video chapters in player progressbar

* feat(#430): added progressBar stories

* feat(#403): added tests to helpers

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>

* Ott 403 finished game playing part 3 (#144)

* feat(#403): added useEventListener hook

* refactor(#403): player progress reporting

* refactor(#403): added MultiSourcePlayer component

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>

* Ott 403 finished game playing part 4 (#147)

* refactor(#403): renamed VideoPlayer -> StreamPlayer

* refactor(#403): restored previous simple progress bar of StreamPlayer

* fix(#403): storybook component name

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>

* Ott 403 finished game playing part 5 (#150)

* fix(#403): added player ready state

* refactor(#403): added converting videos response to chapters

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>
keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
Mirlan 5 years ago committed by GitHub
parent 264a7acab7
commit 194d09e009
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 46
      src/features/MatchPage/MatchProfileCard/hooks.tsx
  2. 82
      src/features/MatchPage/MatchProfileCard/index.tsx
  3. 58
      src/features/MatchPage/hooks/useMatchProfile.tsx
  4. 11
      src/features/MatchPage/hooks/usePlayerProgressReporter.tsx
  5. 30
      src/features/MatchPage/hooks/useVideoData.tsx
  6. 37
      src/features/MatchPage/index.tsx
  7. 44
      src/features/MultiSourcePlayer/components/Chapters/index.tsx
  8. 18
      src/features/MultiSourcePlayer/components/Chapters/styled.tsx
  9. 36
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterProgress/__tests__/index.tsx
  10. 8
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterProgress/index.tsx
  11. 43
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterStyles/__tests__/index.tsx
  12. 26
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterStyles/index.tsx
  13. 38
      src/features/MultiSourcePlayer/components/ProgressBar/hooks.tsx
  14. 27
      src/features/MultiSourcePlayer/components/ProgressBar/index.tsx
  15. 111
      src/features/MultiSourcePlayer/components/ProgressBar/stories.tsx
  16. 7
      src/features/MultiSourcePlayer/components/ProgressBar/styled.tsx
  17. 24
      src/features/MultiSourcePlayer/helpers/index.tsx
  18. 154
      src/features/MultiSourcePlayer/hooks/index.tsx
  19. 9
      src/features/MultiSourcePlayer/hooks/useDuration.tsx
  20. 42
      src/features/MultiSourcePlayer/hooks/usePlayingState.tsx
  21. 76
      src/features/MultiSourcePlayer/hooks/useProgressChangeHandler.tsx
  22. 68
      src/features/MultiSourcePlayer/hooks/useProgressEvents.tsx
  23. 62
      src/features/MultiSourcePlayer/hooks/useVideos.tsx
  24. 50
      src/features/MultiSourcePlayer/hooks/useVolume.tsx
  25. 64
      src/features/MultiSourcePlayer/index.tsx
  26. 9
      src/features/MultiSourcePlayer/styled.tsx
  27. 9
      src/features/MultiSourcePlayer/types.tsx
  28. 18
      src/features/StreamPlayer/components/ProgressBar/index.tsx
  29. 83
      src/features/StreamPlayer/components/ProgressBar/stories.tsx
  30. 13
      src/features/StreamPlayer/components/ProgressBar/styled.tsx
  31. 0
      src/features/StreamPlayer/components/TimeTooltip/index.tsx
  32. 0
      src/features/StreamPlayer/components/TimeTooltip/styled.tsx
  33. 2
      src/features/StreamPlayer/components/VolumeBar/index.tsx
  34. 0
      src/features/StreamPlayer/components/VolumeBar/styled.tsx
  35. 4
      src/features/StreamPlayer/config.tsx
  36. 30
      src/features/StreamPlayer/hooks/index.tsx
  37. 0
      src/features/StreamPlayer/hooks/useFullscreen.tsx
  38. 0
      src/features/StreamPlayer/hooks/useSlider.tsx
  39. 2
      src/features/StreamPlayer/hooks/useVolume.tsx
  40. 14
      src/features/StreamPlayer/index.tsx
  41. 0
      src/features/StreamPlayer/styled.tsx
  42. 1
      src/hooks/index.tsx
  43. 39
      src/hooks/useEventListener.tsx
  44. 1
      src/requests/getMatches.tsx
  45. 31
      src/requests/getVideos.tsx
  46. 1
      src/requests/index.tsx
  47. 2
      src/requests/reportPlayerProgress.tsx

@ -1,46 +0,0 @@
import { useEffect, useState } from 'react'
import type { MatchInfo } from 'requests'
import { getMatchInfo } from 'requests'
import { useLexicsStore } from 'features/LexicsStore'
import { useSportNameParam, usePageId } from 'hooks'
type Name = 'name_rus' | 'name_eng'
export const useMatchProfileCard = () => {
const [matchProfile, setMatchProfile] = useState<MatchInfo>(null)
const { sportType } = useSportNameParam()
const pageId = usePageId()
const { suffix } = useLexicsStore()
const addNames = (profile: MatchInfo, suffixArg: string) => (
profile
? ({
...profile,
team1: {
...profile.team1,
name: profile.team1[`name_${suffixArg}` as Name],
},
team2: {
...profile.team2,
name: profile.team2[`name_${suffixArg}` as Name],
},
tournament: {
...profile.tournament,
name: profile.tournament[`name_${suffixArg}` as Name],
},
})
: null
)
useEffect(() => {
getMatchInfo(sportType, pageId)
.then(setMatchProfile)
}, [sportType, pageId])
return {
matchProfile: addNames(matchProfile, suffix),
}
}

@ -11,8 +11,8 @@ import { SportName } from 'features/Common/SportName'
import { useScoreStore } from 'features/ToggleScore'
import { useSportNameParam } from 'hooks/useSportNameParam'
import { useMatchProfileCard } from './hooks'
import type { MatchProfile } from '../hooks/useMatchProfile'
import {
Wrapper,
Teams,
@ -22,55 +22,55 @@ import {
StyledLink,
} from './styled'
export const MatchProfileCard = () => {
const {
matchProfile,
} = useMatchProfileCard()
type Props = {
profile: MatchProfile,
}
export const MatchProfileCard = ({ profile }: Props) => {
const { sportName, sportType } = useSportNameParam()
const { isHidden } = useScoreStore()
if (!profile) return <Wrapper />
const color = getSportColor(sportType)
const {
team1,
team2,
tournament,
} = matchProfile || {}
} = profile
return (
<Wrapper>
{!isNull(matchProfile)
{!isNull(profile)
&& (
<Fragment>
<Teams>
{team1 && (
<StyledLink
to={getProfileUrl({
id: team1.id,
profileType: ProfileTypes.TEAMS,
sportType,
})}
>
{team1.name}
</StyledLink>
)}
{!isHidden
? (
<Score>
{team1?.score} : {team2?.score}
</Score>
)
: <Dash />}
{team2 && (
<StyledLink to={getProfileUrl({
id: team2.id,
<StyledLink
to={getProfileUrl({
id: team1.id,
profileType: ProfileTypes.TEAMS,
sportType,
})}
>
{team2.name}
</StyledLink>
)}
>
{team1.name}
</StyledLink>
{
!isHidden
? (
<Score>
{team1?.score} : {team2?.score}
</Score>
)
: <Dash />
}
<StyledLink to={getProfileUrl({
id: team2.id,
profileType: ProfileTypes.TEAMS,
sportType,
})}
>
{team2.name}
</StyledLink>
</Teams>
<Tournament>
@ -78,16 +78,14 @@ export const MatchProfileCard = () => {
color={color}
t={sportName}
/>
{tournament && (
<StyledLink to={getProfileUrl({
id: tournament.id,
profileType: ProfileTypes.TOURNAMENTS,
sportType,
})}
>
{tournament?.name}
</StyledLink>
)}
<StyledLink to={getProfileUrl({
id: tournament.id,
profileType: ProfileTypes.TOURNAMENTS,
sportType,
})}
>
{tournament?.name}
</StyledLink>
</Tournament>
</Fragment>
)}

@ -0,0 +1,58 @@
import {
useEffect,
useState,
useMemo,
} from 'react'
import type { MatchInfo } from 'requests'
import { getMatchInfo } from 'requests'
import { useLexicsStore } from 'features/LexicsStore'
import { useSportNameParam, usePageId } from 'hooks'
type Name = 'name_rus' | 'name_eng'
const addNames = (profile: MatchInfo, suffixArg: string) => (
profile
? ({
...profile,
team1: {
...profile.team1,
name: profile.team1[`name_${suffixArg}` as Name],
},
team2: {
...profile.team2,
name: profile.team2[`name_${suffixArg}` as Name],
},
tournament: {
...profile.tournament,
name: profile.tournament[`name_${suffixArg}` as Name],
},
})
: null
)
export type MatchProfile = ReturnType<typeof useMatchProfile>
export const useMatchProfile = () => {
const [rawMatchProfile, setMatchProfile] = useState<MatchInfo>(null)
const { sportType } = useSportNameParam()
const matchId = usePageId()
const { suffix } = useLexicsStore()
useEffect(() => {
getMatchInfo(sportType, matchId).then(setMatchProfile)
},
[
sportType,
matchId,
])
const matchProfile = useMemo(
() => addNames(rawMatchProfile, suffix),
[rawMatchProfile, suffix],
)
return matchProfile
}

@ -13,18 +13,21 @@ const reportRequestInterval = 30000
export const usePlayerProgressReporter = () => {
const { sportType } = useSportNameParam()
const matchId = usePageId()
const secondsRef = useRef(0)
const playerData = useRef({ period: 0, seconds: 0 })
const intervalCallback = () => {
const { period, seconds } = playerData.current
reportPlayerProgress({
half: period,
matchId,
seconds: secondsRef.current,
seconds,
sport: sportType,
})
}
const { start, stop } = useInterval({
callback: intervalCallback,
intervalDuration: reportRequestInterval,
startImmediate: false,
})
const onPlayingChange = useCallback((playing: boolean) => {
@ -35,8 +38,8 @@ export const usePlayerProgressReporter = () => {
}
}, [start, stop])
const onPlayerProgressChange = useCallback((seconds: number) => {
secondsRef.current = seconds
const onPlayerProgressChange = useCallback((seconds: number, period = 0) => {
playerData.current = { period, seconds }
}, [])
return { onPlayerProgressChange, onPlayingChange }

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import type { Videos } from 'requests'
import { getVideos } from 'requests'
import { MatchStatuses } from 'features/HeaderFilters'
import { useSportNameParam, usePageId } from 'hooks'
export const useVideoData = (matchStatus?: MatchStatuses) => {
const [videos, setVideos] = useState<Videos>([])
const { sportType } = useSportNameParam()
const matchId = usePageId()
useEffect(() => {
if (matchStatus === MatchStatuses.Finished) {
getVideos(sportType, matchId).then(setVideos)
}
},
[
matchStatus,
sportType,
matchId,
])
return {
url: '',
videos,
}
}

@ -1,25 +1,42 @@
import React from 'react'
import { VideoPlayer } from 'features/VideoPlayer'
import { StreamPlayer } from 'features/StreamPlayer'
import { MatchStatuses } from 'features/HeaderFilters'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { MatchProfileCard } from './MatchProfileCard'
import { usePlayerProgressReporter } from './hooks'
import { usePlayerProgressReporter } from './hooks/usePlayerProgressReporter'
import { useMatchProfile } from './hooks/useMatchProfile'
import { useVideoData } from './hooks/useVideoData'
import { MainWrapper, Container } from './styled'
const url = 'https://bserv.instatfootball.tv/common/outhls.m3u8'
export const MatchPage = () => {
const profile = useMatchProfile()
const { url, videos } = useVideoData(profile?.stream_status)
const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter()
return (
<MainWrapper>
<MatchProfileCard />
<MatchProfileCard profile={profile} />
<Container>
<VideoPlayer
url={url}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
/>
{
profile?.stream_status === MatchStatuses.Live && (
<StreamPlayer
url={url}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
/>
)
}
{
profile?.stream_status === MatchStatuses.Finished && (
<MultiSourcePlayer
videos={videos}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
/>
)
}
</Container>
</MainWrapper>
)

@ -0,0 +1,44 @@
import React from 'react'
import map from 'lodash/map'
import {
LoadedProgress,
PlayedProgress,
} from 'features/StreamPlayer/components/ProgressBar/styled'
import type { Chapter } from '../../types'
import {
ChapterList,
ChapterContainer,
} from './styled'
type ChapterWithStyles = Chapter & {
loaded: number,
played: number,
width: number,
}
type Props = {
chapters: Array<ChapterWithStyles>,
}
export const Chapters = ({ chapters }: Props) => (
<ChapterList>
{
map(
chapters,
({
loaded,
played,
width,
}, index) => (
<ChapterContainer key={index} style={{ width: `${width}%` }}>
<LoadedProgress style={{ width: `${loaded}%` }} />
<PlayedProgress style={{ width: `${played}%` }} />
</ChapterContainer>
),
)
}
</ChapterList>
)

@ -0,0 +1,18 @@
import styled from 'styled-components/macro'
export const ChapterList = styled.div`
width: 100%;
height: 100%;
display: flex;
`
export const ChapterContainer = styled.div`
position: relative;
height: 100%;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
:not(:last-child) {
margin-right: 3px;
}
`

@ -0,0 +1,36 @@
import { calculateChapterProgress } from '..'
it('calculates zero chapter progress for zero progress or less than chapter start ms', () => {
const chapter = {
duration: 10000,
endMs: 15000,
period: '',
startMs: 5000,
url: '',
}
expect(calculateChapterProgress(0, chapter)).toBe(0)
expect(calculateChapterProgress(4999, chapter)).toBe(0)
})
it('calculates half chapter progress', () => {
const chapter = {
duration: 10000,
endMs: 10000,
period: '',
startMs: 0,
url: '',
}
expect(calculateChapterProgress(5000, chapter)).toBe(50)
})
it('calculates full chapter progress for full progress or more than chapter end ms', () => {
const chapter = {
duration: 10000,
endMs: 10000,
period: '',
startMs: 0,
url: '',
}
expect(calculateChapterProgress(10000, chapter)).toBe(100)
expect(calculateChapterProgress(99999, chapter)).toBe(100)
})

@ -0,0 +1,8 @@
import type { Chapter } from 'features/MultiSourcePlayer/types'
export const calculateChapterProgress = (progress: number, chapter: Chapter) => {
if (progress <= chapter.startMs) return 0
if (progress >= chapter.endMs) return 100
const progressInChapter = progress - chapter.startMs
return progressInChapter * 100 / chapter.duration
}

@ -0,0 +1,43 @@
import { calculateChapterStyles } from '..'
const videoDuration = 60000
it('return correct progress and width lengthes', () => {
let chapter = {
duration: 15000,
endMs: 20000,
period: '',
startMs: 5000,
}
let expected = {
...chapter,
loaded: 100,
played: 100,
width: 25,
}
expect(calculateChapterStyles({
chapters: [chapter],
loadedProgress: 30000,
playedProgress: 30000,
videoDuration,
})).toEqual([expected])
chapter = {
duration: 30000,
endMs: 30000,
period: '',
startMs: 0,
}
expected = {
...chapter,
loaded: 50,
played: 50,
width: 50,
}
expect(calculateChapterStyles({
chapters: [chapter],
loadedProgress: 15000,
playedProgress: 15000,
videoDuration,
})).toEqual([expected])
})

@ -0,0 +1,26 @@
import map from 'lodash/map'
import type { Chapters } from 'features/MultiSourcePlayer/types'
import { calculateChapterProgress } from '../calculateChapterProgress'
type Args = {
chapters: Chapters,
loadedProgress: number,
playedProgress: number,
videoDuration: number,
}
export const calculateChapterStyles = ({
chapters,
loadedProgress,
playedProgress,
videoDuration,
}: Args) => (
map(chapters, (chapter) => ({
...chapter,
loaded: calculateChapterProgress(loadedProgress, chapter),
played: calculateChapterProgress(playedProgress, chapter),
width: chapter.duration * 100 / videoDuration,
}))
)

@ -0,0 +1,38 @@
import { useMemo } from 'react'
import type { Chapters } from '../../types'
import { calculateChapterStyles } from './helpers/calculateChapterStyles'
export type Props = {
chapters?: Chapters,
duration: number,
loadedProgress: number,
onPlayedProgressChange: (progress: number) => void,
playedProgress: number,
}
export const useProgressBar = ({
chapters = [],
duration,
loadedProgress,
playedProgress,
}: Props) => {
const calculatedChapters = useMemo(
() => calculateChapterStyles({
chapters,
loadedProgress,
playedProgress,
videoDuration: duration,
}),
[
loadedProgress,
playedProgress,
duration,
chapters,
],
)
return {
calculatedChapters,
playedProgressInPercent: playedProgress * 100 / duration,
}
}

@ -0,0 +1,27 @@
import React from 'react'
import { secondsToHms } from 'helpers'
import { useSlider } from 'features/StreamPlayer/hooks/useSlider'
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip'
import { Scrubber } from 'features/StreamPlayer/components/ProgressBar/styled'
import { Chapters } from '../Chapters'
import type { Props } from './hooks'
import { useProgressBar } from './hooks'
import { ProgressBarList } from './styled'
export const ProgressBar = (props: Props) => {
const { onPlayedProgressChange, playedProgress } = props
const progressBarRef = useSlider({ onChange: onPlayedProgressChange })
const { calculatedChapters, playedProgressInPercent } = useProgressBar(props)
return (
<ProgressBarList ref={progressBarRef}>
<Chapters chapters={calculatedChapters} />
<Scrubber style={{ left: `${playedProgressInPercent}%` }}>
<TimeTooltip time={secondsToHms(playedProgress / 1000)} />
</Scrubber>
</ProgressBarList>
)
}

@ -0,0 +1,111 @@
import type { ReactElement } from 'react'
import React from 'react'
import styled from 'styled-components/macro'
import { VolumeBar } from 'features/StreamPlayer/components/VolumeBar'
import {
Controls,
Fullscreen,
PlayStop,
} from 'features/StreamPlayer/styled'
import { ProgressBar } from '.'
export default {
component: ProgressBar,
title: 'ProgressBarWithChapters',
}
const Wrapper = styled.div`
position: relative;
width: 95vw;
height: 50vh;
left: 50%;
transform: translateX(-50%);
background-color: #000;
`
const callback = () => {}
const renderInControls = (progressBarElement: ReactElement) => (
<Wrapper>
<Controls>
<PlayStop onClick={callback} playing={false} />
<VolumeBar
value={50}
muted={false}
onChange={callback}
onClick={callback}
/>
{progressBarElement}
<Fullscreen onClick={callback} isFullscreen={false} />
</Controls>
</Wrapper>
)
const duration = 70000
const chapters = [
{
duration: 30000,
endMs: 30000,
period: 0,
startMs: 0,
url: '',
},
{
duration: 30000,
endMs: 60000,
period: 0,
startMs: 30000,
url: '',
},
{
duration: 10000,
endMs: 70000,
period: 0,
startMs: 60000,
url: '',
},
]
export const Empty = () => renderInControls(
<ProgressBar
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
playedProgress={0}
loadedProgress={0}
/>,
)
export const HalfLoaded = () => renderInControls(
<ProgressBar
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
playedProgress={0}
loadedProgress={30000}
/>,
)
export const HalfPlayed = () => renderInControls(
<ProgressBar
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
playedProgress={30000}
loadedProgress={0}
/>,
)
export const Loaded40AndPlayed20 = () => renderInControls(
<ProgressBar
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
playedProgress={20000}
loadedProgress={40000}
/>,
)

@ -0,0 +1,7 @@
import styled from 'styled-components/macro'
export const ProgressBarList = styled.div`
flex-grow: 1;
height: 4px;
position: relative;
`

@ -0,0 +1,24 @@
import { RefObject } from 'react'
import findIndex from 'lodash/findIndex'
import type { Chapters, Chapter } from '../types'
export const preparePlayer = (videoRef: RefObject<HTMLVideoElement>, { url }: Chapter) => {
const video = videoRef?.current
if (!video) return
// eslint-disable-next-line no-param-reassign
video.src = url
video.load()
}
export const startPlayer = (videoRef: RefObject<HTMLVideoElement>, chapter: Chapter) => {
preparePlayer(videoRef, chapter)
videoRef.current?.play()
}
export const findChapterByProgress = (chapters: Chapters, progress: number) => (
findIndex(chapters, ({ endMs, startMs }) => (
startMs / 1000 <= progress && progress <= endMs / 1000
))
)

@ -0,0 +1,154 @@
import type { MouseEvent } from 'react'
import {
useEffect,
useState,
useRef,
} from 'react'
import size from 'lodash/size'
import isEmpty from 'lodash/isEmpty'
import type { Videos } from 'requests'
import { useEventListener } from 'hooks'
import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen'
import { useProgressChangeHandler } from './useProgressChangeHandler'
import { useLoadedEvent, usePlayedEvent } from './useProgressEvents'
import { usePlayingState } from './usePlayingState'
import { useDuration } from './useDuration'
import { useVolume } from './useVolume'
import { useVideos } from './useVideos'
import {
preparePlayer,
startPlayer,
} from '../helpers'
export type Props = {
onError?: () => void,
onPlayingChange: (playing: boolean) => void,
onProgressChange: (seconds: number, period: number) => void,
videos: Videos,
}
export const useMultiSourcePlayer = ({
onError = () => {},
videos,
onPlayingChange,
onProgressChange: onProgressChangeCallback,
}: Props) => {
const activeChapterIndex = useRef(0)
const wrapperRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const [loadedProgress, setLoadedProgress] = useState(0)
const [playedProgress, setPlayedProgress] = useState(0)
const {
playing,
ready,
togglePlaying,
} = usePlayingState(videoRef)
const { chapters } = useVideos(videos)
const duration = useDuration(chapters)
const onProgressChange = useProgressChangeHandler({
activeChapterIndex,
chapters,
duration,
playing,
setPlayedProgress,
videoRef,
})
const playNextChapter = () => {
activeChapterIndex.current += 1
const isLastChapterPlayed = activeChapterIndex.current === size(chapters)
if (isLastChapterPlayed) {
activeChapterIndex.current = 0
preparePlayer(videoRef, chapters[activeChapterIndex.current])
togglePlaying()
} else {
startPlayer(videoRef, chapters[activeChapterIndex.current])
}
}
const onPlayerClick = (e: MouseEvent<HTMLDivElement>) => {
if (e.target === videoRef.current) {
togglePlaying()
}
}
const getActiveChapterStart = () => (
chapters[activeChapterIndex.current]?.startMs || 0
)
const onLoadedChange = (loadedMs: number) => {
const chapterStart = getActiveChapterStart()
setLoadedProgress(chapterStart + loadedMs)
}
const onPlayedChange = (playedMs: number) => {
const chapterStart = getActiveChapterStart()
setPlayedProgress(chapterStart + playedMs)
}
useEffect(() => {
if (isEmpty(chapters)) return
startPlayer(videoRef, chapters[activeChapterIndex.current])
togglePlaying()
}, [chapters, togglePlaying])
useLoadedEvent({
onChange: onLoadedChange,
videoRef,
})
usePlayedEvent({
onChange: onPlayedChange,
videoRef,
})
useEventListener({
callback: playNextChapter,
event: 'ended',
ref: videoRef,
})
useEventListener({
callback: onError,
event: 'error',
ref: videoRef,
})
useEffect(() => {
onPlayingChange(playing)
}, [playing, onPlayingChange])
useEffect(() => {
if (!ready) return
const progressSeconds = playedProgress / 1000
const { period } = chapters[activeChapterIndex.current]
onProgressChangeCallback(progressSeconds, Number(period))
}, [
ready,
playedProgress,
chapters,
onProgressChangeCallback,
])
return {
chapters,
duration,
loadedProgress,
onPlayerClick,
onProgressChange,
playedProgress,
playing,
togglePlaying,
videoRef,
wrapperRef,
...useFullscreen(wrapperRef),
...useVolume(videoRef),
}
}

@ -0,0 +1,9 @@
import { useMemo } from 'react'
import sumBy from 'lodash/sumBy'
import type { Chapters } from '../types'
export const useDuration = (chapters: Chapters) => (
useMemo(() => sumBy(chapters, 'duration'), [chapters])
)

@ -0,0 +1,42 @@
import type { RefObject } from 'react'
import { useState, useCallback } from 'react'
import once from 'lodash/once'
import { useEventListener } from 'hooks'
export const usePlayingState = (videoRef: RefObject<HTMLVideoElement>) => {
const [playing, setPlaying] = useState(false)
const [ready, setReady] = useState(false)
const togglePlaying = useCallback(() => {
setPlaying((isPlaying) => {
const video = videoRef.current
if (!ready || !video) return false
const nextIsPlaying = !isPlaying
if (nextIsPlaying) {
video.play()
} else {
video.pause()
}
return nextIsPlaying
})
}, [videoRef, ready])
const onReady = useCallback(once(() => {
setReady(true)
}), [])
useEventListener({
callback: onReady,
event: 'canplay',
ref: videoRef,
})
return {
playing,
ready,
togglePlaying,
}
}

@ -0,0 +1,76 @@
import type { MutableRefObject, RefObject } from 'react'
import {
useCallback,
} from 'react'
import throttle from 'lodash/throttle'
import type { Chapters } from '../types'
import {
findChapterByProgress,
preparePlayer,
startPlayer,
} from '../helpers'
type Args = {
activeChapterIndex: MutableRefObject<number>,
chapters: Chapters,
duration: number,
playing: boolean,
setPlayedProgress: (value: number) => void,
videoRef: RefObject<HTMLVideoElement>,
}
export const useProgressChangeHandler = ({
activeChapterIndex,
chapters,
duration,
playing,
setPlayedProgress,
videoRef,
}: Args) => {
const setPlayerProgress = useCallback(
throttle((value: number) => {
const video = videoRef.current
if (!video) return
video.currentTime = value
}, 100),
[],
)
const onProgressChange = useCallback((progress: number) => {
// значение новой позиции ползунка в миллисекундах
const progressMs = progress * duration
const chapterIndex = findChapterByProgress(chapters, progressMs / 1000)
const chapter = chapters[chapterIndex]
const isProgressOnDifferentChapter = (
chapterIndex !== -1
&& chapterIndex !== activeChapterIndex.current
)
// если ползунок остановили на другой главе
if (isProgressOnDifferentChapter) {
// если проигрывали видео то продолжаем проигрывание,
// если нет то только загружаем видео
const continueOrPreparePlayer = playing ? startPlayer : preparePlayer
continueOrPreparePlayer(videoRef, chapter)
// eslint-disable-next-line no-param-reassign
activeChapterIndex.current = chapterIndex
}
setPlayedProgress(progressMs)
// отнимаем начало главы на котором остановились от общего прогресса
// чтобы получить прогресс текущей главы
const chapterProgressSec = (progressMs - chapter.startMs) / 1000
setPlayerProgress(chapterProgressSec)
}, [
playing,
chapters,
duration,
setPlayedProgress,
setPlayerProgress,
activeChapterIndex,
videoRef,
])
return onProgressChange
}

@ -0,0 +1,68 @@
import type { RefObject } from 'react'
import size from 'lodash/size'
import { useEventListener } from 'hooks'
/**
* Возвращает значение отрезка буфера которое ближе
* к проигрываемому времени
*/
const getLoadedSeconds = (video: HTMLVideoElement | null) => {
const bufferLength = size(video?.buffered)
if (!video || bufferLength === 0) return 0
for (let i = 0; i < bufferLength; i++) {
const bufferPosition = bufferLength - 1 - i
if (video.buffered.start(bufferPosition) < video.currentTime) {
return video.buffered.end(bufferPosition)
}
}
return 0
}
type Args = {
onChange: (millis: number) => void,
videoRef: RefObject<HTMLVideoElement>,
}
/**
* Подписывается на прогресс буферизации и вызывает колбек
* со значением прогресса в милисекундах
*/
export const useLoadedEvent = ({
onChange,
videoRef,
}: Args) => {
const listenLoadedProgress = () => {
const loadedSeconds = getLoadedSeconds(videoRef.current)
onChange(loadedSeconds * 1000)
}
useEventListener({
callback: listenLoadedProgress,
event: 'progress',
ref: videoRef,
})
}
/**
* Подписывается на прогресс проигывания и вызывает колбек
* со значением прогресса в милисекундах
*/
export const usePlayedEvent = ({
onChange,
videoRef,
}: Args) => {
const listenPlayedProgresses = () => {
const video = videoRef.current
if (!video) return
onChange(video.currentTime * 1000)
}
useEventListener({
callback: listenPlayedProgresses,
event: 'timeupdate',
ref: videoRef,
})
}

@ -0,0 +1,62 @@
import { useMemo } from 'react'
import map from 'lodash/map'
import last from 'lodash/last'
import uniq from 'lodash/uniq'
import maxBy from 'lodash/maxBy'
import filter from 'lodash/filter'
import reduce from 'lodash/reduce'
import concat from 'lodash/concat'
import orderBy from 'lodash/orderBy'
import type { Videos } from 'requests'
import type { Chapters } from '../types'
const getUniquePeriods = (videos: Videos) => uniq(map(videos, 'period'))
const getHighestQualityVideo = (videos: Videos, period: number) => {
const videosWithSamePeriod = filter(videos, { period })
return maxBy(videosWithSamePeriod, ({ quality }) => Number(quality))
}
const getHighQualityVideos = (videos: Videos, periods: Array<number>) => (
reduce(
periods,
(acc: Videos, period) => {
const video = getHighestQualityVideo(videos, period)
return video ? concat(acc, video) : acc
},
[],
)
)
const getChapters = (videos: Videos) => {
const sortedVideos = orderBy(videos, 'period')
return reduce(
sortedVideos,
(acc: Chapters, video) => {
const prevVideoEndMs = last(acc)?.endMs || 0
const nextChapter = {
duration: video.duration,
endMs: prevVideoEndMs + video.duration,
period: video.period,
startMs: prevVideoEndMs,
url: video.url,
}
return concat(acc, nextChapter)
},
[],
)
}
const buildChapters = (videos: Videos) => {
const periods = getUniquePeriods(videos)
const highQualityVideos = getHighQualityVideos(videos, periods)
return getChapters(highQualityVideos)
}
export const useVideos = (videos: Videos) => {
const chapters = useMemo(() => buildChapters(videos), [videos])
return { chapters }
}

@ -0,0 +1,50 @@
import type { RefObject } from 'react'
import { useState, useCallback } from 'react'
import { useToggle } from 'hooks'
const useVolumeState = (videoRef: RefObject<HTMLVideoElement>) => {
const [volume, setVolume] = useState(0.5)
const setVideoVolume = useCallback((value: number) => {
if (videoRef.current) {
// eslint-disable-next-line no-param-reassign
videoRef.current.volume = value
}
setVolume(value)
}, [videoRef])
return [volume, setVideoVolume] as const
}
export const useVolume = (videoRef: RefObject<HTMLVideoElement>) => {
const {
close: unmute,
isOpen: muted,
open: mute,
toggle: toggleMuted,
} = useToggle(true)
const [volume, setVolume] = useVolumeState(videoRef)
const onVolumeChange = (value: number) => {
if (value === 0) {
mute()
} else {
unmute()
}
setVolume(value)
}
const onVolumeClick = () => {
if (muted && volume === 0) {
setVolume(0.1)
}
toggleMuted()
}
return {
muted,
onVolumeChange,
onVolumeClick,
volume,
volumeInPercent: volume * 100,
}
}

@ -0,0 +1,64 @@
import React from 'react'
import { VolumeBar } from 'features/StreamPlayer/components/VolumeBar'
import {
PlayerWrapper,
Controls,
PlayStop,
Fullscreen,
} from 'features/StreamPlayer/styled'
import { ProgressBar } from './components/ProgressBar'
import type { Props } from './hooks'
import { useMultiSourcePlayer } from './hooks'
import { Video } from './styled'
export const MultiSourcePlayer = (props: Props) => {
const {
chapters,
duration,
isFullscreen,
loadedProgress,
muted,
onFullscreenClick,
onPlayerClick,
onProgressChange,
onVolumeChange,
onVolumeClick,
playedProgress,
playing,
togglePlaying,
videoRef,
volumeInPercent,
wrapperRef,
} = useMultiSourcePlayer(props)
return (
<PlayerWrapper
ref={wrapperRef}
playing={playing}
onClick={onPlayerClick}
>
<Video
muted={muted}
ref={videoRef}
/>
<Controls>
<PlayStop onClick={togglePlaying} playing={playing} />
<VolumeBar
value={volumeInPercent}
muted={muted}
onChange={onVolumeChange}
onClick={onVolumeClick}
/>
<ProgressBar
duration={duration}
chapters={chapters}
onPlayedProgressChange={onProgressChange}
playedProgress={playedProgress}
loadedProgress={loadedProgress}
/>
<Fullscreen onClick={onFullscreenClick} isFullscreen={isFullscreen} />
</Controls>
</PlayerWrapper>
)
}

@ -0,0 +1,9 @@
import styled from 'styled-components/macro'
export const Video = styled.video`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`

@ -0,0 +1,9 @@
export type Chapter = {
duration: number,
endMs: number,
period: number,
startMs: number,
url: string,
}
export type Chapters = Array<Chapter>

@ -2,9 +2,9 @@ import React from 'react'
import { secondsToHms } from 'helpers'
import { useSlider } from 'features/VideoPlayer/hooks/useSlider'
import { useSlider } from 'features/StreamPlayer/hooks/useSlider'
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip'
import { TimeTooltip } from '../TimeTooltip'
import {
ProgressBarList,
LoadedProgress,
@ -13,26 +13,28 @@ import {
} from './styled'
type Props = {
duration: number,
loadedProgress: number,
onPlayedProgressChange: (progress: number) => void,
playedProgress: number,
playedSeconds: number,
}
export const ProgressBar = ({
duration,
loadedProgress,
onPlayedProgressChange,
playedProgress,
playedSeconds,
}: Props) => {
const progressBarRef = useSlider({ onChange: onPlayedProgressChange })
const loadedFraction = loadedProgress * 100 / duration
const playedFraction = playedProgress * 100 / duration
return (
<ProgressBarList ref={progressBarRef}>
<LoadedProgress value={loadedProgress} />
<PlayedProgress value={playedProgress} />
<Scrubber value={playedProgress}>
<TimeTooltip time={secondsToHms(playedSeconds)} />
<LoadedProgress style={{ width: `${loadedFraction}%` }} />
<PlayedProgress style={{ width: `${playedFraction}%` }} />
<Scrubber style={{ left: `${playedFraction}%` }}>
<TimeTooltip time={secondsToHms(playedProgress / 1000)} />
</Scrubber>
</ProgressBarList>
)

@ -0,0 +1,83 @@
import type { ReactElement } from 'react'
import React from 'react'
import styled from 'styled-components/macro'
import { VolumeBar } from 'features/StreamPlayer/components/VolumeBar'
import {
Controls,
Fullscreen,
PlayStop,
} from 'features/StreamPlayer/styled'
import { ProgressBar } from '.'
export default {
component: ProgressBar,
title: 'ProgressBar',
}
const Wrapper = styled.div`
position: relative;
width: 95vw;
height: 50vh;
left: 50%;
transform: translateX(-50%);
background-color: #000;
`
const callback = () => {}
const renderInControls = (progressBarElement: ReactElement) => (
<Wrapper>
<Controls>
<PlayStop onClick={callback} playing={false} />
<VolumeBar
value={50}
muted={false}
onChange={callback}
onClick={callback}
/>
{progressBarElement}
<Fullscreen onClick={callback} isFullscreen={false} />
</Controls>
</Wrapper>
)
const duration = 70000
export const Empty = () => renderInControls(
<ProgressBar
duration={duration}
onPlayedProgressChange={callback}
playedProgress={0}
loadedProgress={0}
/>,
)
export const HalfLoaded = () => renderInControls(
<ProgressBar
duration={duration}
onPlayedProgressChange={callback}
playedProgress={0}
loadedProgress={30000}
/>,
)
export const HalfPlayed = () => renderInControls(
<ProgressBar
duration={duration}
onPlayedProgressChange={callback}
playedProgress={30000}
loadedProgress={0}
/>,
)
export const Loaded40AndPlayed20 = () => renderInControls(
<ProgressBar
duration={duration}
onPlayedProgressChange={callback}
playedProgress={20000}
loadedProgress={40000}
/>,
)

@ -8,32 +8,25 @@ export const ProgressBarList = styled.div`
cursor: pointer;
`
type ProgressProps = {
value: number,
}
export const LoadedProgress = styled.div<ProgressProps>`
export const LoadedProgress = styled.div`
position: absolute;
z-index: 1;
background-color: rgba(255, 255, 255, 0.6);;
height: 100%;
width: ${({ value }) => value}%;
`
export const PlayedProgress = styled.div<ProgressProps>`
export const PlayedProgress = styled.div`
position: absolute;
z-index: 2;
background-color: #F2C94C;
height: 100%;
width: ${({ value }) => value}%;
`
export const Scrubber = styled.button<ProgressProps>`
export const Scrubber = styled.button`
border: none;
outline: none;
position: absolute;
top: 0;
left: ${({ value }) => value}%;
transform: translate(-50%, -38%);
z-index: 3;
width: 18px;

@ -1,6 +1,6 @@
import React from 'react'
import { useSlider } from 'features/VideoPlayer/hooks/useSlider'
import { useSlider } from 'features/StreamPlayer/hooks/useSlider'
import {
Wrapper,

@ -1,4 +1,6 @@
export const playerConfig = {
import type { Config } from 'react-player'
export const streamConfig: Config = {
file: {
forceHLS: true,
hlsOptions: {

@ -25,6 +25,8 @@ export type Props = {
url: string,
}
const toMilliSeconds = (seconds: number) => seconds * 1000
export const useVideoPlayer = ({
onPlayingChange,
onProgressChange: progressChangeCallback,
@ -34,7 +36,6 @@ export const useVideoPlayer = ({
const [duration, setDuration] = useState(0)
const [loadedProgress, setLoadedProgress] = useState(0)
const [playedProgress, setPlayedProgress] = useState(0)
const [playedSeconds, setPlayedSeconds] = useState(0)
const wrapperRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<ReactPlayer>(null)
@ -59,41 +60,44 @@ export const useVideoPlayer = ({
const setPlayerProgress = useCallback(
throttle((value: number) => {
playerRef.current?.seekTo(value, 'fraction')
playerRef.current?.seekTo(value, 'seconds')
}, 100),
[],
)
const onDuration = (durationSeconds: number) => {
setDuration(toMilliSeconds(durationSeconds))
}
const onProgressChange = useCallback((progress: number) => {
setPlayedProgress(progress * 100)
setPlayerProgress(progress)
const progressSeconds = progress * duration
setPlayedProgress(toMilliSeconds(progressSeconds))
setPlayerProgress(progressSeconds)
}, [
duration,
setPlayedProgress,
setPlayerProgress,
])
const setProgress = ({
loaded,
played,
playedSeconds: seconds,
loadedSeconds,
playedSeconds,
}: ProgressState) => {
setLoadedProgress(loaded * 100)
setPlayedProgress(played * 100)
setPlayedSeconds(seconds)
setLoadedProgress(toMilliSeconds(loadedSeconds))
setPlayedProgress(toMilliSeconds(playedSeconds))
progressChangeCallback(seconds)
progressChangeCallback(playedSeconds)
}
return {
duration,
loadedProgress,
onDuration,
onPlayerClick,
onProgressChange,
playedProgress,
playedSeconds,
playerRef,
playing,
setDuration,
setProgress,
setReady,
startPlaying,

@ -8,7 +8,7 @@ export const useVolume = () => {
isOpen: muted,
open: mute,
toggle: toggleMuted,
} = useToggle()
} = useToggle(true)
const [volume, setVolume] = useState(0.5)
const onVolumeChange = (value: number) => {

@ -1,6 +1,6 @@
import React from 'react'
import { playerConfig, progressCallbackInterval } from './config'
import { streamConfig, progressCallbackInterval } from './config'
import type { Props } from './hooks'
import { useVideoPlayer } from './hooks'
import { ProgressBar } from './components/ProgressBar'
@ -13,22 +13,22 @@ import {
Fullscreen,
} from './styled'
export const VideoPlayer = (props: Props) => {
export const StreamPlayer = (props: Props) => {
const { url } = props
const {
duration,
isFullscreen,
loadedProgress,
muted,
onDuration,
onFullscreenClick,
onPlayerClick,
onProgressChange,
onVolumeChange,
onVolumeClick,
playedProgress,
playedSeconds,
playerRef,
playing,
setDuration,
setProgress,
startPlaying,
togglePlaying,
@ -52,10 +52,10 @@ export const VideoPlayer = (props: Props) => {
playing={playing}
volume={volume}
muted={muted}
config={playerConfig}
config={streamConfig}
progressInterval={progressCallbackInterval}
onProgress={setProgress}
onDuration={setDuration}
onDuration={onDuration}
onEnded={togglePlaying}
onReady={startPlaying}
/>
@ -68,8 +68,8 @@ export const VideoPlayer = (props: Props) => {
onClick={onVolumeClick}
/>
<ProgressBar
duration={duration}
onPlayedProgressChange={onProgressChange}
playedSeconds={playedSeconds}
playedProgress={playedProgress}
loadedProgress={loadedProgress}
/>

@ -4,3 +4,4 @@ export * from './useRequest'
export * from './useSportNameParam'
export * from './useStorage'
export * from './useInterval'
export * from './useEventListener'

@ -0,0 +1,39 @@
import type { RefObject } from 'react'
import { useEffect, useRef } from 'react'
type Args<E> = {
callback: (e: Event) => void,
element?: HTMLElement,
event: E,
ref?: RefObject<HTMLElement>,
}
/**
* Хук для подписки и отписки на события
* на html element, window и react ref
*/
export const useEventListener = <E extends keyof HTMLElementEventMap>({
callback,
element,
event,
ref,
}: Args<E>) => {
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
const htmlElement = ref?.current || element || window
if (!htmlElement) return undefined
const listener = (e: Event) => {
callbackRef.current(e)
}
htmlElement.addEventListener(event, listener)
return () => {
htmlElement.removeEventListener(event, listener)
}
}, [event, ref, element])
}

@ -36,6 +36,7 @@ export type Content = {
type Match = {
date: string,
has_video: boolean,
id: number,
round_id: number | null,
stream_status: MatchStatuses,

@ -0,0 +1,31 @@
import { API_ROOT, SportTypes } from 'config'
import { callApi } from 'helpers'
type Video = {
duration: number,
match_id: number,
name: string,
period: number,
quality: string,
start_ms: number,
url: string,
}
export type Videos = Array<Video>
export const getVideos = (
sportType: SportTypes,
matchId: number,
): Promise<Videos> => {
const config = {
body: {
match_id: matchId,
sport_id: sportType,
},
}
return callApi({
config,
url: `${API_ROOT}/videoapi`,
})
}

@ -13,3 +13,4 @@ export * from './getTournamentInfo'
export * from './getTeamInfo'
export * from './getMatchInfo'
export * from './reportPlayerProgress'
export * from './getVideos'

@ -23,7 +23,7 @@ export const reportPlayerProgress = ({
const config = {
body: {
params: {
_p_half: half || 0,
_p_half: half,
_p_match_id: matchId,
_p_second: seconds,
_p_sport: sport,

Loading…
Cancel
Save