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

@ -52,21 +52,21 @@ const chapters = [
endMs: 30000,
period: 0,
startMs: 0,
url: '',
urls: {},
},
{
duration: 30000,
endMs: 60000,
period: 0,
startMs: 30000,
url: '',
urls: {},
},
{
duration: 10000,
endMs: 70000,
period: 0,
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 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
if (!video) return
const wasAtTime = video.currentTime
// eslint-disable-next-line no-param-reassign
video.src = url
if (resume) {
video.currentTime = wasAtTime
}
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

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

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

@ -8,15 +8,14 @@ import throttle from 'lodash/throttle'
import type { Chapters } from '../types'
import {
findChapterByProgress,
preparePlayer,
startPlayer,
} from '../helpers'
type Args = {
activeChapterIndex: MutableRefObject<number>,
chapters: Chapters,
continuePlaying: (url: string) => void,
duration: number,
playing: boolean,
selectedQuality: string,
setPlayedProgress: (value: number) => void,
videoRef: RefObject<HTMLVideoElement>,
}
@ -24,8 +23,9 @@ type Args = {
export const useProgressChangeHandler = ({
activeChapterIndex,
chapters,
continuePlaying,
duration,
playing,
selectedQuality,
setPlayedProgress,
videoRef,
}: Args) => {
@ -49,10 +49,7 @@ export const useProgressChangeHandler = ({
)
// если ползунок остановили на другой главе
if (isProgressOnDifferentChapter) {
// если проигрывали видео то продолжаем проигрывание,
// если нет то только загружаем видео
const continueOrPreparePlayer = playing ? startPlayer : preparePlayer
continueOrPreparePlayer(videoRef, chapter)
continuePlaying(chapter.urls[selectedQuality])
// eslint-disable-next-line no-param-reassign
activeChapterIndex.current = chapterIndex
}
@ -63,13 +60,13 @@ export const useProgressChangeHandler = ({
const chapterProgressSec = (progressMs - chapter.startMs) / 1000
setPlayerProgress(chapterProgressSec)
}, [
playing,
selectedQuality,
chapters,
duration,
continuePlaying,
setPlayedProgress,
setPlayerProgress,
activeChapterIndex,
videoRef,
])
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 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 isEmpty from 'lodash/isEmpty'
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 getHighestQualityVideo = (videos: Videos, period: number) => {
type Video = {
duration: number,
period: number,
urls: Urls,
}
const getVideoByPeriod = (videos: Videos, period: number) => {
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(
periods,
(acc: Videos, period) => {
const video = getHighestQualityVideo(videos, period)
(acc: Array<Video>, period) => {
const video = getVideoByPeriod(videos, period)
return video ? concat(acc, video) : acc
},
[],
)
)
const getChapters = (videos: Videos) => {
const getChapters = (videos: Array<Video>) => {
const sortedVideos = orderBy(videos, 'period')
return reduce(
sortedVideos,
@ -42,7 +63,7 @@ const getChapters = (videos: Videos) => {
endMs: prevVideoEndMs + video.duration,
period: video.period,
startMs: prevVideoEndMs,
url: video.url,
urls: video.urls,
}
return concat(acc, nextChapter)
},
@ -52,7 +73,7 @@ const getChapters = (videos: Videos) => {
const buildChapters = (videos: Videos) => {
const periods = getUniquePeriods(videos)
const highQualityVideos = getHighQualityVideos(videos, periods)
const highQualityVideos = getVideoByPeriods(videos, periods)
return getChapters(highQualityVideos)
}

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

@ -1,9 +1,11 @@
export type Urls = { [quality: string]: string }
export type Chapter = {
duration: number,
endMs: number,
period: number,
startMs: number,
url: string,
urls: Urls,
}
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;
height: 20px;
margin-left: 25px;
background-image: url(/images/player-settings.svg);
`

Loading…
Cancel
Save