feat(#454): added video quality selector (#155)

* feat(#454): added video quality selector

* refactor(#454): pr comments fix

Co-authored-by: mirlan.maksitaliev <mirlan.maksitaliev@instatsport.com>
keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
Mirlan 5 years ago committed by GitHub
parent fd9cbf786c
commit 14cbfaf47e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      src/features/MatchPage/index.tsx
  2. 6
      src/features/MultiSourcePlayer/components/ProgressBar/stories.tsx
  3. 27
      src/features/MultiSourcePlayer/components/Settings/hooks.tsx
  4. 44
      src/features/MultiSourcePlayer/components/Settings/index.tsx
  5. 53
      src/features/MultiSourcePlayer/components/Settings/styled.tsx
  6. 24
      src/features/MultiSourcePlayer/helpers/index.tsx
  7. 56
      src/features/MultiSourcePlayer/hooks/index.tsx
  8. 51
      src/features/MultiSourcePlayer/hooks/usePlayingState.tsx
  9. 17
      src/features/MultiSourcePlayer/hooks/useProgressChangeHandler.tsx
  10. 27
      src/features/MultiSourcePlayer/hooks/useVideoQuality.tsx
  11. 41
      src/features/MultiSourcePlayer/hooks/useVideos.tsx
  12. 9
      src/features/MultiSourcePlayer/index.tsx
  13. 4
      src/features/MultiSourcePlayer/types.tsx
  14. 3
      src/features/StreamPlayer/styled.tsx

@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import isEmpty from 'lodash/isEmpty'
import { StreamPlayer } from 'features/StreamPlayer' import { StreamPlayer } from 'features/StreamPlayer'
import { MatchStatuses } from 'features/HeaderFilters' import { MatchStatuses } from 'features/HeaderFilters'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer' import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
@ -15,12 +17,18 @@ export const MatchPage = () => {
const { url, videos } = useVideoData(profile?.stream_status) const { url, videos } = useVideoData(profile?.stream_status)
const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter() const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter()
const isLiveMatch = profile?.stream_status === MatchStatuses.Live
const isFinishedMatch = (
profile?.stream_status === MatchStatuses.Finished
&& !isEmpty(videos)
)
return ( return (
<MainWrapper> <MainWrapper>
<MatchProfileCard profile={profile} /> <MatchProfileCard profile={profile} />
<Container> <Container>
{ {
profile?.stream_status === MatchStatuses.Live && ( isLiveMatch && (
<StreamPlayer <StreamPlayer
url={url} url={url}
onPlayingChange={onPlayingChange} onPlayingChange={onPlayingChange}
@ -29,7 +37,7 @@ export const MatchPage = () => {
) )
} }
{ {
profile?.stream_status === MatchStatuses.Finished && ( isFinishedMatch && (
<MultiSourcePlayer <MultiSourcePlayer
videos={videos} videos={videos}
onPlayingChange={onPlayingChange} onPlayingChange={onPlayingChange}

@ -52,21 +52,21 @@ const chapters = [
endMs: 30000, endMs: 30000,
period: 0, period: 0,
startMs: 0, startMs: 0,
url: '', urls: {},
}, },
{ {
duration: 30000, duration: 30000,
endMs: 60000, endMs: 60000,
period: 0, period: 0,
startMs: 30000, startMs: 30000,
url: '', urls: {},
}, },
{ {
duration: 10000, duration: 10000,
endMs: 70000, endMs: 70000,
period: 0, period: 0,
startMs: 60000, startMs: 60000,
url: '', urls: {},
}, },
] ]

@ -0,0 +1,27 @@
import { useToggle } from 'hooks'
export type Props = {
onSelect: (quality: string) => void,
selectedQuality: string,
videoQualities: Array<string>,
}
export const useSettings = ({ onSelect }: Props) => {
const {
close,
isOpen,
open,
} = useToggle()
const onItemClick = (quality: string) => {
onSelect(quality)
close()
}
return {
close,
isOpen,
onItemClick,
open,
}
}

@ -0,0 +1,44 @@
import React, { Fragment } from 'react'
import map from 'lodash/map'
import { SettingsButton } from 'features/StreamPlayer/styled'
import { OutsideClick } from 'features/OutsideClick'
import type { Props } from './hooks'
import { useSettings } from './hooks'
import { QualitiesList, QualityItem } from './styled'
export const Settings = (props: Props) => {
const { selectedQuality, videoQualities } = props
const {
close,
isOpen,
onItemClick,
open,
} = useSettings(props)
return (
<Fragment>
<SettingsButton onClick={open} />
{
isOpen && (
<OutsideClick onClick={close}>
<QualitiesList>
{
map(videoQualities, (quality) => (
<QualityItem
key={quality}
active={quality === selectedQuality}
onClick={() => onItemClick(quality)}
>
{quality}
</QualityItem>
))
}
</QualitiesList>
</OutsideClick>
)
}
</Fragment>
)
}

@ -0,0 +1,53 @@
import styled, { css } from 'styled-components/macro'
export const QualitiesList = styled.ul`
position: absolute;
z-index: 1;
bottom: calc(100% + 14px);
right: 24px;
width: 52px;
list-style: none;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
`
type QualityItemProps = {
active: boolean,
}
const activeIcon = css`
:before {
position: absolute;
top: 45%;
transform: rotate(-45deg);
content: '';
left: 8px;
width: 5px;
height: 3px;
border-left: 1px solid #fff;
border-bottom: 1px solid #fff;
}
`
export const QualityItem = styled.li<QualityItemProps>`
width: 100%;
padding: 5px 8px;
text-align: right;
font-style: normal;
font-weight: normal;
font-family: Montserrat;
font-size: 10px;
line-height: 12px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: #fff;
cursor: pointer;
position: relative;
:hover, :focus {
background-color: rgba(255, 255, 255, 0.1);
}
${({ active }) => (active ? activeIcon : '')}
`

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

@ -1,12 +1,12 @@
import type { MouseEvent } from 'react' import type { MouseEvent } from 'react'
import { import {
useCallback,
useEffect, useEffect,
useState, useState,
useRef, useRef,
} from 'react' } from 'react'
import size from 'lodash/size' import size from 'lodash/size'
import isEmpty from 'lodash/isEmpty'
import type { Videos } from 'requests' import type { Videos } from 'requests'
@ -16,13 +16,10 @@ import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen'
import { useProgressChangeHandler } from './useProgressChangeHandler' import { useProgressChangeHandler } from './useProgressChangeHandler'
import { useLoadedEvent, usePlayedEvent } from './useProgressEvents' import { useLoadedEvent, usePlayedEvent } from './useProgressEvents'
import { usePlayingState } from './usePlayingState' import { usePlayingState } from './usePlayingState'
import { useVideoQuality } from './useVideoQuality'
import { useDuration } from './useDuration' import { useDuration } from './useDuration'
import { useVolume } from './useVolume' import { useVolume } from './useVolume'
import { useVideos } from './useVideos' import { useVideos } from './useVideos'
import {
preparePlayer,
startPlayer,
} from '../helpers'
export type Props = { export type Props = {
onError?: () => void, onError?: () => void,
@ -43,31 +40,50 @@ export const useMultiSourcePlayer = ({
const [loadedProgress, setLoadedProgress] = useState(0) const [loadedProgress, setLoadedProgress] = useState(0)
const [playedProgress, setPlayedProgress] = useState(0) const [playedProgress, setPlayedProgress] = useState(0)
const { const {
continuePlaying,
firstTimeStart,
playing, playing,
ready, startPlaying,
stopPlaying,
togglePlaying, togglePlaying,
} = usePlayingState(videoRef) } = usePlayingState(videoRef)
const {
selectedQuality,
setSelectedQuality,
videoQualities,
} = useVideoQuality(videos)
const { chapters } = useVideos(videos) const { chapters } = useVideos(videos)
const duration = useDuration(chapters) const duration = useDuration(chapters)
const onProgressChange = useProgressChangeHandler({ const onProgressChange = useProgressChangeHandler({
activeChapterIndex, activeChapterIndex,
chapters, chapters,
continuePlaying,
duration, duration,
playing, selectedQuality,
setPlayedProgress, setPlayedProgress,
videoRef, videoRef,
}) })
const getCurrentChapterUrl = useCallback((quality: string = selectedQuality) => (
chapters[activeChapterIndex.current].urls[quality]
), [selectedQuality, chapters])
const onQualitySelect = (quality: string) => {
setSelectedQuality(quality)
continuePlaying(getCurrentChapterUrl(quality), true)
}
const playNextChapter = () => { const playNextChapter = () => {
activeChapterIndex.current += 1 activeChapterIndex.current += 1
const isLastChapterPlayed = activeChapterIndex.current === size(chapters) const isLastChapterPlayed = activeChapterIndex.current === size(chapters)
if (isLastChapterPlayed) { if (isLastChapterPlayed) {
activeChapterIndex.current = 0 activeChapterIndex.current = 0
preparePlayer(videoRef, chapters[activeChapterIndex.current]) stopPlaying(getCurrentChapterUrl())
togglePlaying()
} else { } else {
startPlayer(videoRef, chapters[activeChapterIndex.current]) continuePlaying(getCurrentChapterUrl())
} }
} }
@ -92,11 +108,15 @@ export const useMultiSourcePlayer = ({
} }
useEffect(() => { useEffect(() => {
if (isEmpty(chapters)) return const url = getCurrentChapterUrl()
if (url && firstTimeStart) {
startPlayer(videoRef, chapters[activeChapterIndex.current]) startPlaying(url)
togglePlaying() }
}, [chapters, togglePlaying]) }, [
firstTimeStart,
getCurrentChapterUrl,
startPlaying,
])
useLoadedEvent({ useLoadedEvent({
onChange: onLoadedChange, onChange: onLoadedChange,
@ -125,13 +145,10 @@ export const useMultiSourcePlayer = ({
}, [playing, onPlayingChange]) }, [playing, onPlayingChange])
useEffect(() => { useEffect(() => {
if (!ready) return
const progressSeconds = playedProgress / 1000 const progressSeconds = playedProgress / 1000
const { period } = chapters[activeChapterIndex.current] const { period } = chapters[activeChapterIndex.current]
onProgressChangeCallback(progressSeconds, Number(period)) onProgressChangeCallback(progressSeconds, Number(period))
}, [ }, [
ready,
playedProgress, playedProgress,
chapters, chapters,
onProgressChangeCallback, onProgressChangeCallback,
@ -143,9 +160,12 @@ export const useMultiSourcePlayer = ({
loadedProgress, loadedProgress,
onPlayerClick, onPlayerClick,
onProgressChange, onProgressChange,
onQualitySelect,
playedProgress, playedProgress,
playing, playing,
selectedQuality,
togglePlaying, togglePlaying,
videoQualities,
videoRef, videoRef,
wrapperRef, wrapperRef,
...useFullscreen(wrapperRef), ...useFullscreen(wrapperRef),

@ -1,18 +1,16 @@
import type { RefObject } from 'react' import type { RefObject } from 'react'
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import once from 'lodash/once' import { preparePlayer } from '../helpers'
import { useEventListener } from 'hooks'
export const usePlayingState = (videoRef: RefObject<HTMLVideoElement>) => { export const usePlayingState = (videoRef: RefObject<HTMLVideoElement>) => {
const [firstTimeStart, setFirstTimeStart] = useState(true)
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [ready, setReady] = useState(false)
const togglePlaying = useCallback(() => { const togglePlaying = useCallback(() => {
setPlaying((isPlaying) => { setPlaying((isPlaying) => {
const video = videoRef.current const video = videoRef.current
if (!ready || !video) return false if (!video || firstTimeStart) return false
const nextIsPlaying = !isPlaying const nextIsPlaying = !isPlaying
if (nextIsPlaying) { if (nextIsPlaying) {
@ -22,21 +20,44 @@ export const usePlayingState = (videoRef: RefObject<HTMLVideoElement>) => {
} }
return nextIsPlaying return nextIsPlaying
}) })
}, [videoRef, ready]) }, [firstTimeStart, videoRef])
const onReady = useCallback(once(() => { const stopPlaying = useCallback((nextUrl: string = '') => {
setReady(true) preparePlayer({ url: nextUrl, videoRef })
}), []) setPlaying(false)
}, [videoRef])
const startPlaying = useCallback((url: string) => {
preparePlayer({ url, videoRef })
videoRef.current?.play()
setFirstTimeStart(false)
setPlaying(true)
}, [videoRef])
const continuePlaying = useCallback((
url: string,
rememberTime: boolean = false,
) => {
preparePlayer({
resume: rememberTime,
url,
videoRef,
})
useEventListener({ setPlaying((isPlaying) => {
callback: onReady, if (isPlaying) {
event: 'canplay', videoRef.current?.play()
ref: videoRef, }
return isPlaying
}) })
}, [videoRef])
return { return {
continuePlaying,
firstTimeStart,
playing, playing,
ready, startPlaying,
stopPlaying,
togglePlaying, togglePlaying,
} }
} }

@ -8,15 +8,14 @@ import throttle from 'lodash/throttle'
import type { Chapters } from '../types' import type { Chapters } from '../types'
import { import {
findChapterByProgress, findChapterByProgress,
preparePlayer,
startPlayer,
} from '../helpers' } from '../helpers'
type Args = { type Args = {
activeChapterIndex: MutableRefObject<number>, activeChapterIndex: MutableRefObject<number>,
chapters: Chapters, chapters: Chapters,
continuePlaying: (url: string) => void,
duration: number, duration: number,
playing: boolean, selectedQuality: string,
setPlayedProgress: (value: number) => void, setPlayedProgress: (value: number) => void,
videoRef: RefObject<HTMLVideoElement>, videoRef: RefObject<HTMLVideoElement>,
} }
@ -24,8 +23,9 @@ type Args = {
export const useProgressChangeHandler = ({ export const useProgressChangeHandler = ({
activeChapterIndex, activeChapterIndex,
chapters, chapters,
continuePlaying,
duration, duration,
playing, selectedQuality,
setPlayedProgress, setPlayedProgress,
videoRef, videoRef,
}: Args) => { }: Args) => {
@ -49,10 +49,7 @@ export const useProgressChangeHandler = ({
) )
// если ползунок остановили на другой главе // если ползунок остановили на другой главе
if (isProgressOnDifferentChapter) { if (isProgressOnDifferentChapter) {
// если проигрывали видео то продолжаем проигрывание, continuePlaying(chapter.urls[selectedQuality])
// если нет то только загружаем видео
const continueOrPreparePlayer = playing ? startPlayer : preparePlayer
continueOrPreparePlayer(videoRef, chapter)
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
activeChapterIndex.current = chapterIndex activeChapterIndex.current = chapterIndex
} }
@ -63,13 +60,13 @@ export const useProgressChangeHandler = ({
const chapterProgressSec = (progressMs - chapter.startMs) / 1000 const chapterProgressSec = (progressMs - chapter.startMs) / 1000
setPlayerProgress(chapterProgressSec) setPlayerProgress(chapterProgressSec)
}, [ }, [
playing, selectedQuality,
chapters, chapters,
duration, duration,
continuePlaying,
setPlayedProgress, setPlayedProgress,
setPlayerProgress, setPlayerProgress,
activeChapterIndex, activeChapterIndex,
videoRef,
]) ])
return onProgressChange return onProgressChange

@ -0,0 +1,27 @@
import { useState } from 'react'
import map from 'lodash/map'
import uniq from 'lodash/uniq'
import orderBy from 'lodash/orderBy'
import type { Videos } from 'requests'
const getVideoQualities = (videos: Videos) => {
const qualities = uniq(map(videos, 'quality'))
return orderBy(
qualities,
Number,
'desc',
)
}
export const useVideoQuality = (videos: Videos) => {
const videoQualities = getVideoQualities(videos)
const [selectedQuality, setSelectedQuality] = useState(videoQualities[0])
return {
selectedQuality,
setSelectedQuality,
videoQualities,
}
}

@ -3,35 +3,56 @@ import { useMemo } from 'react'
import map from 'lodash/map' import map from 'lodash/map'
import last from 'lodash/last' import last from 'lodash/last'
import uniq from 'lodash/uniq' import uniq from 'lodash/uniq'
import maxBy from 'lodash/maxBy'
import filter from 'lodash/filter' import filter from 'lodash/filter'
import reduce from 'lodash/reduce' import reduce from 'lodash/reduce'
import concat from 'lodash/concat' import concat from 'lodash/concat'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import isEmpty from 'lodash/isEmpty'
import type { Videos } from 'requests' import type { Videos } from 'requests'
import type { Chapters } from '../types' import type { Chapters, Urls } from '../types'
const getUniquePeriods = (videos: Videos) => uniq(map(videos, 'period')) const getUniquePeriods = (videos: Videos) => uniq(map(videos, 'period'))
const getHighestQualityVideo = (videos: Videos, period: number) => { type Video = {
duration: number,
period: number,
urls: Urls,
}
const getVideoByPeriod = (videos: Videos, period: number) => {
const videosWithSamePeriod = filter(videos, { period }) const videosWithSamePeriod = filter(videos, { period })
return maxBy(videosWithSamePeriod, ({ quality }) => Number(quality)) if (isEmpty(videosWithSamePeriod)) return null
const urls = reduce(
videosWithSamePeriod,
(acc: Urls, video) => ({
...acc,
[video.quality]: video.url,
}),
{},
)
const [video] = videosWithSamePeriod
return {
duration: video.duration,
period: video.period,
urls,
}
} }
const getHighQualityVideos = (videos: Videos, periods: Array<number>) => ( const getVideoByPeriods = (videos: Videos, periods: Array<number>) => (
reduce( reduce(
periods, periods,
(acc: Videos, period) => { (acc: Array<Video>, period) => {
const video = getHighestQualityVideo(videos, period) const video = getVideoByPeriod(videos, period)
return video ? concat(acc, video) : acc return video ? concat(acc, video) : acc
}, },
[], [],
) )
) )
const getChapters = (videos: Videos) => { const getChapters = (videos: Array<Video>) => {
const sortedVideos = orderBy(videos, 'period') const sortedVideos = orderBy(videos, 'period')
return reduce( return reduce(
sortedVideos, sortedVideos,
@ -42,7 +63,7 @@ const getChapters = (videos: Videos) => {
endMs: prevVideoEndMs + video.duration, endMs: prevVideoEndMs + video.duration,
period: video.period, period: video.period,
startMs: prevVideoEndMs, startMs: prevVideoEndMs,
url: video.url, urls: video.urls,
} }
return concat(acc, nextChapter) return concat(acc, nextChapter)
}, },
@ -52,7 +73,7 @@ const getChapters = (videos: Videos) => {
const buildChapters = (videos: Videos) => { const buildChapters = (videos: Videos) => {
const periods = getUniquePeriods(videos) const periods = getUniquePeriods(videos)
const highQualityVideos = getHighQualityVideos(videos, periods) const highQualityVideos = getVideoByPeriods(videos, periods)
return getChapters(highQualityVideos) return getChapters(highQualityVideos)
} }

@ -9,6 +9,7 @@ import {
} from 'features/StreamPlayer/styled' } from 'features/StreamPlayer/styled'
import { ProgressBar } from './components/ProgressBar' import { ProgressBar } from './components/ProgressBar'
import { Settings } from './components/Settings'
import type { Props } from './hooks' import type { Props } from './hooks'
import { useMultiSourcePlayer } from './hooks' import { useMultiSourcePlayer } from './hooks'
import { Video } from './styled' import { Video } from './styled'
@ -23,11 +24,14 @@ export const MultiSourcePlayer = (props: Props) => {
onFullscreenClick, onFullscreenClick,
onPlayerClick, onPlayerClick,
onProgressChange, onProgressChange,
onQualitySelect,
onVolumeChange, onVolumeChange,
onVolumeClick, onVolumeClick,
playedProgress, playedProgress,
playing, playing,
selectedQuality,
togglePlaying, togglePlaying,
videoQualities,
videoRef, videoRef,
volumeInPercent, volumeInPercent,
wrapperRef, wrapperRef,
@ -58,6 +62,11 @@ export const MultiSourcePlayer = (props: Props) => {
loadedProgress={loadedProgress} loadedProgress={loadedProgress}
/> />
<Fullscreen onClick={onFullscreenClick} isFullscreen={isFullscreen} /> <Fullscreen onClick={onFullscreenClick} isFullscreen={isFullscreen} />
<Settings
onSelect={onQualitySelect}
selectedQuality={selectedQuality}
videoQualities={videoQualities}
/>
</Controls> </Controls>
</PlayerWrapper> </PlayerWrapper>
) )

@ -1,9 +1,11 @@
export type Urls = { [quality: string]: string }
export type Chapter = { export type Chapter = {
duration: number, duration: number,
endMs: number, endMs: number,
period: number, period: number,
startMs: number, startMs: number,
url: string, urls: Urls,
} }
export type Chapters = Array<Chapter> export type Chapters = Array<Chapter>

@ -98,8 +98,9 @@ export const Fullscreen = styled(ButtonBase)<FullscreenProps>`
)}; )};
` `
export const Settings = styled(ButtonBase)` export const SettingsButton = styled(ButtonBase)`
width: 22px; width: 22px;
height: 20px; height: 20px;
margin-left: 25px;
background-image: url(/images/player-settings.svg); background-image: url(/images/player-settings.svg);
` `

Loading…
Cancel
Save