fix(#1701): return multisource and finished matches players
parent
c52a48383d
commit
602050f347
@ -0,0 +1,137 @@ |
|||||||
|
import map from 'lodash/map' |
||||||
|
import last from 'lodash/last' |
||||||
|
import uniq from 'lodash/uniq' |
||||||
|
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 groupBy from 'lodash/groupBy' |
||||||
|
|
||||||
|
import type { |
||||||
|
Videos, |
||||||
|
Episodes, |
||||||
|
Episode, |
||||||
|
} from 'requests' |
||||||
|
|
||||||
|
import type { Chapters, Urls } from 'features/MultiSourcePlayer/types' |
||||||
|
|
||||||
|
import type { PlaylistOption } from '../../types' |
||||||
|
import { FULL_GAME_KEY } from '../../helpers/buildPlaylists' |
||||||
|
|
||||||
|
const getUniquePeriods = (videos: Videos) => uniq(map(videos, ({ period }) => period)) |
||||||
|
|
||||||
|
type Video = { |
||||||
|
duration: number, |
||||||
|
period: number, |
||||||
|
urls: Urls, |
||||||
|
} |
||||||
|
|
||||||
|
const getVideoByPeriod = (videos: Videos, period: number) => { |
||||||
|
const videosWithSamePeriod = filter(videos, { period }) |
||||||
|
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 getVideoByPeriods = (videos: Videos, periods: Array<number>) => ( |
||||||
|
reduce( |
||||||
|
periods, |
||||||
|
(acc: Array<Video>, period) => { |
||||||
|
const video = getVideoByPeriod(videos, period) |
||||||
|
return video ? concat(acc, video) : acc |
||||||
|
}, |
||||||
|
[], |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
const getFullMatchChapters = (videos: Array<Video>) => { |
||||||
|
const sortedVideos = orderBy(videos, ({ period }) => period) |
||||||
|
return reduce( |
||||||
|
sortedVideos, |
||||||
|
(acc: Chapters, video) => { |
||||||
|
const prevVideoEndMs = last(acc)?.endMs || 0 |
||||||
|
const endMs = prevVideoEndMs + video.duration |
||||||
|
const nextChapter = { |
||||||
|
duration: video.duration, |
||||||
|
endMs, |
||||||
|
endOffsetMs: endMs, |
||||||
|
period: video.period, |
||||||
|
startMs: prevVideoEndMs, |
||||||
|
startOffsetMs: 0, |
||||||
|
urls: video.urls, |
||||||
|
} |
||||||
|
return concat(acc, nextChapter) |
||||||
|
}, |
||||||
|
[], |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const getEpisodeUrls = (urls: Urls, episode: Episode) => reduce( |
||||||
|
urls, |
||||||
|
( |
||||||
|
acc: Urls, |
||||||
|
url, |
||||||
|
qulaity, |
||||||
|
) => { |
||||||
|
acc[qulaity] = `${url}#t=${episode.s},${episode.e}` |
||||||
|
return acc |
||||||
|
}, |
||||||
|
{}, |
||||||
|
) |
||||||
|
|
||||||
|
const getPlaylistChapters = (videos: Array<Video>, episodes: Episodes) => { |
||||||
|
const groupedByPeriods = groupBy(videos, ({ period }) => period) |
||||||
|
return reduce( |
||||||
|
episodes, |
||||||
|
(acc: Chapters, episode) => { |
||||||
|
const video = groupedByPeriods[episode.h]?.[0] |
||||||
|
if (!video || episode.s >= episode.e) return acc |
||||||
|
|
||||||
|
const episodeDuration = (episode.e - episode.s) * 1000 |
||||||
|
const prevVideoEndMs = last(acc)?.endMs || 0 |
||||||
|
const nextChapter = { |
||||||
|
duration: episodeDuration, |
||||||
|
endMs: prevVideoEndMs + episodeDuration, |
||||||
|
endOffsetMs: episode.e * 1000, |
||||||
|
period: video.period, |
||||||
|
startMs: prevVideoEndMs, |
||||||
|
startOffsetMs: episode.s * 1000, |
||||||
|
urls: getEpisodeUrls(video.urls, episode), |
||||||
|
} |
||||||
|
return concat(acc, nextChapter) |
||||||
|
}, |
||||||
|
[], |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
type Args = { |
||||||
|
episodes: Episodes, |
||||||
|
selectedPlaylist?: PlaylistOption, |
||||||
|
videos: Videos, |
||||||
|
} |
||||||
|
|
||||||
|
export const buildChapters = ({ |
||||||
|
episodes, |
||||||
|
selectedPlaylist, |
||||||
|
videos, |
||||||
|
}: Args) => { |
||||||
|
const periods = getUniquePeriods(videos) |
||||||
|
const highQualityVideos = getVideoByPeriods(videos, periods) |
||||||
|
return selectedPlaylist?.id === FULL_GAME_KEY |
||||||
|
? getFullMatchChapters(highQualityVideos) |
||||||
|
: getPlaylistChapters(highQualityVideos, episodes) |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
import { useToggle } from 'hooks/useToggle' |
||||||
|
|
||||||
|
import type { Settings } from 'features/MatchPopup' |
||||||
|
import { useMatchPopupStore } from 'features/MatchPopup' |
||||||
|
|
||||||
|
import { usePlayerLogger } from './usePlayerLogger' |
||||||
|
import { useEpisodes } from './useEpisodes' |
||||||
|
import { useChapters } from './useChapters' |
||||||
|
|
||||||
|
export const useFinishedMatch = () => { |
||||||
|
const { |
||||||
|
handlePlaylistClick, |
||||||
|
matchPlaylists, |
||||||
|
selectedPlaylist, |
||||||
|
setSettings, |
||||||
|
} = useMatchPopupStore() |
||||||
|
const { |
||||||
|
close: closeSettingsPopup, |
||||||
|
isOpen: isSettingsPopupOpen, |
||||||
|
open: openSettingsPopup, |
||||||
|
} = useToggle() |
||||||
|
|
||||||
|
const { episodes } = useEpisodes() |
||||||
|
|
||||||
|
const { logPlaylistChange, onPlayingChange } = usePlayerLogger() |
||||||
|
|
||||||
|
const setEpisodesSettings = (newSettings: Settings) => { |
||||||
|
setSettings(newSettings) |
||||||
|
closeSettingsPopup() |
||||||
|
} |
||||||
|
|
||||||
|
const onPlaylistSelect: typeof handlePlaylistClick = (playlist, e) => { |
||||||
|
if (selectedPlaylist) { |
||||||
|
logPlaylistChange(selectedPlaylist) |
||||||
|
} |
||||||
|
handlePlaylistClick(playlist, e) |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
closeSettingsPopup, |
||||||
|
isSettingsPopupOpen, |
||||||
|
onPlayingChange, |
||||||
|
onPlaylistSelect, |
||||||
|
openSettingsPopup, |
||||||
|
playlists: matchPlaylists, |
||||||
|
selectedPlaylist, |
||||||
|
setEpisodesSettings, |
||||||
|
...useChapters({ |
||||||
|
episodes, |
||||||
|
selectedPlaylist, |
||||||
|
}), |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
import { |
||||||
|
useEffect, |
||||||
|
useMemo, |
||||||
|
useState, |
||||||
|
} from 'react' |
||||||
|
|
||||||
|
import type { Episodes, Videos } from 'requests' |
||||||
|
import { getVideos } from 'requests' |
||||||
|
|
||||||
|
import { usePageParams } from 'hooks/usePageParams' |
||||||
|
|
||||||
|
import type { PlaylistOption } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
import { buildChapters } from '../helpers' |
||||||
|
|
||||||
|
type Args = { |
||||||
|
episodes: Episodes, |
||||||
|
selectedPlaylist?: PlaylistOption, |
||||||
|
} |
||||||
|
|
||||||
|
export const useChapters = ({ |
||||||
|
episodes, |
||||||
|
selectedPlaylist, |
||||||
|
}: Args) => { |
||||||
|
const [videos, setVideos] = useState<Videos>([]) |
||||||
|
const { profileId: matchId, sportType } = usePageParams() |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
getVideos(sportType, matchId).then(setVideos) |
||||||
|
}, [sportType, matchId]) |
||||||
|
|
||||||
|
const chapters = useMemo( |
||||||
|
() => buildChapters({ |
||||||
|
episodes, |
||||||
|
selectedPlaylist, |
||||||
|
videos, |
||||||
|
}), |
||||||
|
[ |
||||||
|
selectedPlaylist, |
||||||
|
episodes, |
||||||
|
videos, |
||||||
|
], |
||||||
|
) |
||||||
|
|
||||||
|
return { chapters } |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { |
||||||
|
useCallback, |
||||||
|
useEffect, |
||||||
|
useState, |
||||||
|
} from 'react' |
||||||
|
|
||||||
|
import isEmpty from 'lodash/isEmpty' |
||||||
|
|
||||||
|
import type { Episodes } from 'requests' |
||||||
|
import { getPlayerPlaylists } from 'requests' |
||||||
|
|
||||||
|
import { usePageParams } from 'hooks/usePageParams' |
||||||
|
|
||||||
|
import { PlaylistOption, PlaylistTypes } from 'features/MatchPage/types' |
||||||
|
import { |
||||||
|
defaultSettings, |
||||||
|
Settings, |
||||||
|
useMatchPopupStore, |
||||||
|
} from 'features/MatchPopup' |
||||||
|
|
||||||
|
export const useEpisodes = () => { |
||||||
|
const { |
||||||
|
handlePlaylistClick, |
||||||
|
matchPlaylists: playlists, |
||||||
|
selectedPlaylist, |
||||||
|
settings, |
||||||
|
} = useMatchPopupStore() |
||||||
|
const [episodes, setEpisodes] = useState<Episodes>([]) |
||||||
|
const { profileId: matchId, sportType } = usePageParams() |
||||||
|
|
||||||
|
const fetchEpisodes = useCallback(( |
||||||
|
playlistOption: PlaylistOption, |
||||||
|
popupSettings: Settings = defaultSettings, |
||||||
|
) => { |
||||||
|
if (playlistOption.type === PlaylistTypes.PLAYER) { |
||||||
|
getPlayerPlaylists({ |
||||||
|
matchId, |
||||||
|
playerId: playlistOption.id, |
||||||
|
settings: popupSettings, |
||||||
|
sportType, |
||||||
|
}).then(setEpisodes) |
||||||
|
} else if (playlistOption.type === PlaylistTypes.MATCH |
||||||
|
|| playlistOption.type === PlaylistTypes.EVENT) { |
||||||
|
setEpisodes(playlistOption.episodes) |
||||||
|
} |
||||||
|
}, [matchId, sportType]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!selectedPlaylist && playlists && !isEmpty(playlists.match)) { |
||||||
|
handlePlaylistClick(playlists.match[0]) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
selectedPlaylist, |
||||||
|
playlists, |
||||||
|
handlePlaylistClick, |
||||||
|
]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (selectedPlaylist) { |
||||||
|
fetchEpisodes(selectedPlaylist, settings) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
settings, |
||||||
|
selectedPlaylist, |
||||||
|
fetchEpisodes, |
||||||
|
]) |
||||||
|
|
||||||
|
return { episodes } |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
import { |
||||||
|
useCallback, |
||||||
|
useRef, |
||||||
|
} from 'react' |
||||||
|
import { useLocation } from 'react-router' |
||||||
|
|
||||||
|
import { LogActions, logUserAction } from 'requests/logUserAction' |
||||||
|
|
||||||
|
import { useInterval } from 'hooks/useInterval' |
||||||
|
import { usePageParams } from 'hooks/usePageParams' |
||||||
|
|
||||||
|
import { PlaylistOption, PlaylistTypes } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
const playlistTypeConfig = { |
||||||
|
ball_in_play: 2, |
||||||
|
full_game: 1, |
||||||
|
goals: 4, |
||||||
|
highlights: 3, |
||||||
|
players: 5, |
||||||
|
} |
||||||
|
|
||||||
|
const getInitialData = () => ({ dateVisit: new Date().toISOString(), seconds: 0 }) |
||||||
|
|
||||||
|
export const usePlayerLogger = () => { |
||||||
|
const location = useLocation() |
||||||
|
const { profileId, sportType } = usePageParams() |
||||||
|
const data = useRef(getInitialData()) |
||||||
|
|
||||||
|
const incrementSeconds = () => data.current.seconds++ |
||||||
|
|
||||||
|
const resetData = () => { |
||||||
|
data.current = getInitialData() |
||||||
|
} |
||||||
|
|
||||||
|
const { start, stop } = useInterval({ |
||||||
|
callback: incrementSeconds, |
||||||
|
intervalDuration: 1000, |
||||||
|
startImmediate: false, |
||||||
|
}) |
||||||
|
|
||||||
|
const onPlayingChange = useCallback((playing: boolean) => { |
||||||
|
if (playing) { |
||||||
|
start() |
||||||
|
} else { |
||||||
|
stop() |
||||||
|
} |
||||||
|
}, [start, stop]) |
||||||
|
|
||||||
|
const logPlaylistChange = (prevPlaylist: PlaylistOption) => { |
||||||
|
const args = prevPlaylist.type === PlaylistTypes.MATCH |
||||||
|
? { |
||||||
|
playlistType: playlistTypeConfig[prevPlaylist.id], |
||||||
|
} |
||||||
|
: { |
||||||
|
playerId: prevPlaylist.id, |
||||||
|
playlistType: playlistTypeConfig.players, |
||||||
|
} |
||||||
|
|
||||||
|
logUserAction({ |
||||||
|
actionType: LogActions.VideoChange, |
||||||
|
dateVisit: data.current.dateVisit, |
||||||
|
duration: data.current.seconds, |
||||||
|
matchId: profileId, |
||||||
|
sportType, |
||||||
|
url: location.pathname, |
||||||
|
...args, |
||||||
|
}) |
||||||
|
resetData() |
||||||
|
} |
||||||
|
|
||||||
|
return { logPlaylistChange, onPlayingChange } |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import styled from 'styled-components/macro' |
||||||
|
|
||||||
|
import { devices } from 'config/devices' |
||||||
|
|
||||||
|
import { Modal as BaseModal } from 'features/Modal' |
||||||
|
import { ModalWindow } from 'features/Modal/styled' |
||||||
|
|
||||||
|
export const Modal = styled(BaseModal)` |
||||||
|
background-color: rgba(0, 0, 0, 0.7); |
||||||
|
|
||||||
|
${ModalWindow} { |
||||||
|
width: 1222px; |
||||||
|
padding: 20px 0; |
||||||
|
background-color: #3F3F3F; |
||||||
|
border-radius: 5px; |
||||||
|
|
||||||
|
@media ${devices.tablet} { |
||||||
|
width: 100vw; |
||||||
|
} |
||||||
|
|
||||||
|
@media ${devices.mobile} { |
||||||
|
height: 100vh; |
||||||
|
padding: 0; |
||||||
|
background-color: transparent; |
||||||
|
} |
||||||
|
} |
||||||
|
` |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
import { calculateChapterStyles } from '..' |
||||||
|
|
||||||
|
const videoDuration = 60000 |
||||||
|
|
||||||
|
it('return correct progress and width lengthes', () => { |
||||||
|
let chapter = { |
||||||
|
duration: 15000, |
||||||
|
endMs: 20000, |
||||||
|
period: 0, |
||||||
|
startMs: 5000, |
||||||
|
urls: {}, |
||||||
|
} |
||||||
|
let expected = { |
||||||
|
...chapter, |
||||||
|
loaded: 100, |
||||||
|
played: 100, |
||||||
|
width: 25, |
||||||
|
} |
||||||
|
expect(calculateChapterStyles({ |
||||||
|
activeChapterIndex: 0, |
||||||
|
chapters: [chapter], |
||||||
|
loadedProgress: 30000, |
||||||
|
playedProgress: 30000, |
||||||
|
videoDuration, |
||||||
|
})).toEqual([expected]) |
||||||
|
|
||||||
|
chapter = { |
||||||
|
duration: 30000, |
||||||
|
endMs: 30000, |
||||||
|
period: 0, |
||||||
|
startMs: 0, |
||||||
|
urls: {}, |
||||||
|
} |
||||||
|
expected = { |
||||||
|
...chapter, |
||||||
|
loaded: 50, |
||||||
|
played: 50, |
||||||
|
width: 50, |
||||||
|
} |
||||||
|
expect(calculateChapterStyles({ |
||||||
|
activeChapterIndex: 0, |
||||||
|
chapters: [chapter], |
||||||
|
loadedProgress: 15000, |
||||||
|
playedProgress: 15000, |
||||||
|
videoDuration, |
||||||
|
})).toEqual([expected]) |
||||||
|
}) |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
import map from 'lodash/fp/map' |
||||||
|
import pipe from 'lodash/fp/pipe' |
||||||
|
import size from 'lodash/fp/size' |
||||||
|
import slice from 'lodash/fp/slice' |
||||||
|
|
||||||
|
import type { Chapters, Chapter } from 'features/MultiSourcePlayer/types' |
||||||
|
|
||||||
|
const calculateChapterProgress = (progress: number, chapter: Chapter) => ( |
||||||
|
Math.min(progress * 100 / chapter.duration, 100) |
||||||
|
) |
||||||
|
|
||||||
|
type Args = { |
||||||
|
activeChapterIndex: number, |
||||||
|
chapters: Chapters, |
||||||
|
loadedProgress: number, |
||||||
|
playedProgress: number, |
||||||
|
videoDuration: number, |
||||||
|
} |
||||||
|
|
||||||
|
export const calculateChapterStyles = ({ |
||||||
|
activeChapterIndex, |
||||||
|
chapters, |
||||||
|
loadedProgress, |
||||||
|
playedProgress, |
||||||
|
videoDuration, |
||||||
|
}: Args) => { |
||||||
|
const playedChapters = pipe( |
||||||
|
slice(0, activeChapterIndex), |
||||||
|
map((chapter: Chapter) => ({ |
||||||
|
...chapter, |
||||||
|
loaded: 100, |
||||||
|
played: 100, |
||||||
|
width: chapter.duration * 100 / videoDuration, |
||||||
|
})), |
||||||
|
)(chapters) |
||||||
|
|
||||||
|
const comingChapters = pipe( |
||||||
|
slice(activeChapterIndex + 1, size(chapters)), |
||||||
|
map((chapter: Chapter) => ({ |
||||||
|
...chapter, |
||||||
|
loaded: 0, |
||||||
|
played: 0, |
||||||
|
width: chapter.duration * 100 / videoDuration, |
||||||
|
})), |
||||||
|
)(chapters) |
||||||
|
|
||||||
|
const chapter = chapters[activeChapterIndex] |
||||||
|
const activeChapter = { |
||||||
|
...chapter, |
||||||
|
loaded: calculateChapterProgress(loadedProgress, chapter), |
||||||
|
played: calculateChapterProgress(playedProgress, chapter), |
||||||
|
width: chapter.duration * 100 / videoDuration, |
||||||
|
} |
||||||
|
return [ |
||||||
|
...playedChapters, |
||||||
|
activeChapter, |
||||||
|
...comingChapters, |
||||||
|
] |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import { useMemo } from 'react' |
||||||
|
|
||||||
|
import { secondsToHms } from 'helpers' |
||||||
|
|
||||||
|
import type { Chapters } from '../../types' |
||||||
|
import { calculateChapterStyles } from './helpers/calculateChapterStyles' |
||||||
|
|
||||||
|
export type Props = { |
||||||
|
activeChapterIndex: number, |
||||||
|
allPlayedProgress: number, |
||||||
|
chapters?: Chapters, |
||||||
|
duration: number, |
||||||
|
loadedProgress: number, |
||||||
|
onPlayedProgressChange: (progress: number, seeking: boolean) => void, |
||||||
|
playedProgress: number, |
||||||
|
} |
||||||
|
|
||||||
|
export const useProgressBar = ({ |
||||||
|
activeChapterIndex, |
||||||
|
allPlayedProgress, |
||||||
|
chapters = [], |
||||||
|
duration, |
||||||
|
loadedProgress, |
||||||
|
playedProgress, |
||||||
|
}: Props) => { |
||||||
|
const calculatedChapters = useMemo( |
||||||
|
() => calculateChapterStyles({ |
||||||
|
activeChapterIndex, |
||||||
|
chapters, |
||||||
|
loadedProgress, |
||||||
|
playedProgress, |
||||||
|
videoDuration: duration, |
||||||
|
}), |
||||||
|
[ |
||||||
|
activeChapterIndex, |
||||||
|
loadedProgress, |
||||||
|
playedProgress, |
||||||
|
duration, |
||||||
|
chapters, |
||||||
|
], |
||||||
|
) |
||||||
|
|
||||||
|
return { |
||||||
|
calculatedChapters, |
||||||
|
playedProgressInPercent: Math.min(allPlayedProgress * 100 / duration, 100), |
||||||
|
time: secondsToHms(allPlayedProgress / 1000), |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,126 @@ |
|||||||
|
import type { ReactElement } 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 '.' |
||||||
|
|
||||||
|
const Story = { |
||||||
|
component: ProgressBar, |
||||||
|
title: 'ProgressBarWithChapters', |
||||||
|
} |
||||||
|
|
||||||
|
export default Story |
||||||
|
|
||||||
|
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 visible> |
||||||
|
<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, |
||||||
|
endOffsetMs: 0, |
||||||
|
period: 0, |
||||||
|
startMs: 0, |
||||||
|
startOffsetMs: 0, |
||||||
|
urls: {}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
duration: 30000, |
||||||
|
endMs: 60000, |
||||||
|
endOffsetMs: 0, |
||||||
|
period: 0, |
||||||
|
startMs: 30000, |
||||||
|
startOffsetMs: 0, |
||||||
|
urls: {}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
duration: 10000, |
||||||
|
endMs: 70000, |
||||||
|
endOffsetMs: 0, |
||||||
|
period: 0, |
||||||
|
startMs: 60000, |
||||||
|
startOffsetMs: 0, |
||||||
|
urls: {}, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
export const Empty = () => renderInControls( |
||||||
|
<ProgressBar |
||||||
|
activeChapterIndex={0} |
||||||
|
allPlayedProgress={0} |
||||||
|
duration={duration} |
||||||
|
chapters={chapters} |
||||||
|
onPlayedProgressChange={callback} |
||||||
|
playedProgress={0} |
||||||
|
loadedProgress={0} |
||||||
|
/>, |
||||||
|
) |
||||||
|
|
||||||
|
export const HalfLoaded = () => renderInControls( |
||||||
|
<ProgressBar |
||||||
|
activeChapterIndex={0} |
||||||
|
allPlayedProgress={0} |
||||||
|
duration={duration} |
||||||
|
chapters={chapters} |
||||||
|
onPlayedProgressChange={callback} |
||||||
|
playedProgress={0} |
||||||
|
loadedProgress={30000} |
||||||
|
/>, |
||||||
|
) |
||||||
|
|
||||||
|
export const HalfPlayed = () => renderInControls( |
||||||
|
<ProgressBar |
||||||
|
activeChapterIndex={1} |
||||||
|
allPlayedProgress={1} |
||||||
|
duration={duration} |
||||||
|
chapters={chapters} |
||||||
|
onPlayedProgressChange={callback} |
||||||
|
playedProgress={30000} |
||||||
|
loadedProgress={0} |
||||||
|
/>, |
||||||
|
) |
||||||
|
|
||||||
|
export const Loaded40AndPlayed20 = () => renderInControls( |
||||||
|
<ProgressBar |
||||||
|
activeChapterIndex={0} |
||||||
|
allPlayedProgress={0} |
||||||
|
duration={duration} |
||||||
|
chapters={chapters} |
||||||
|
onPlayedProgressChange={callback} |
||||||
|
playedProgress={20000} |
||||||
|
loadedProgress={40000} |
||||||
|
/>, |
||||||
|
) |
||||||
@ -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,47 @@ |
|||||||
|
import { Fragment } from 'react' |
||||||
|
|
||||||
|
import map from 'lodash/map' |
||||||
|
|
||||||
|
import { OutsideClick } from 'features/OutsideClick' |
||||||
|
|
||||||
|
import type { Props } from './hooks' |
||||||
|
import { useSettings } from './hooks' |
||||||
|
import { |
||||||
|
SettingsButton, |
||||||
|
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,78 @@ |
|||||||
|
import styled, { css } from 'styled-components/macro' |
||||||
|
import { isMobileDevice } from 'config/userAgent' |
||||||
|
import { ButtonBase } from 'features/StreamPlayer/styled' |
||||||
|
|
||||||
|
export const SettingsButton = styled(ButtonBase)` |
||||||
|
width: 22px; |
||||||
|
height: 20px; |
||||||
|
margin-left: 25px; |
||||||
|
background-image: url(/images/settings.svg); |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
width: 20px; |
||||||
|
height: 18px; |
||||||
|
margin-left: 15px; |
||||||
|
cursor: pointer; |
||||||
|
` |
||||||
|
: ''}; |
||||||
|
` |
||||||
|
|
||||||
|
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; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
right: 0; |
||||||
|
bottom: 35px; |
||||||
|
` |
||||||
|
: ''}; |
||||||
|
` |
||||||
|
|
||||||
|
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; |
||||||
|
/* stylelint-disable-next-line */ |
||||||
|
font-family: Montserrat; |
||||||
|
font-size: 10px; |
||||||
|
line-height: 12px; |
||||||
|
letter-spacing: 0.01em; |
||||||
|
color: #fff; |
||||||
|
cursor: pointer; |
||||||
|
position: relative; |
||||||
|
|
||||||
|
:hover, :focus { |
||||||
|
background-color: rgba(255, 255, 255, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
${({ active }) => (active ? activeIcon : '')} |
||||||
|
` |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export const REWIND_SECONDS = 5 |
||||||
|
|
||||||
|
export const HOUR_IN_MILLISECONDS = 60 * 60 * 1000 |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import { RefObject } from 'react' |
||||||
|
|
||||||
|
import findIndex from 'lodash/findIndex' |
||||||
|
|
||||||
|
import type { Chapters, Players } from '../types' |
||||||
|
|
||||||
|
type Args = { |
||||||
|
from?: number, |
||||||
|
url: string, |
||||||
|
videoRef: RefObject<HTMLVideoElement>, |
||||||
|
} |
||||||
|
|
||||||
|
export const preparePlayer = ({ |
||||||
|
from = 0, |
||||||
|
url, |
||||||
|
videoRef, |
||||||
|
}: Args) => { |
||||||
|
const video = videoRef?.current |
||||||
|
if (!video) return |
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
video.src = url |
||||||
|
if (from) { |
||||||
|
video.currentTime = from |
||||||
|
} |
||||||
|
video.load() |
||||||
|
} |
||||||
|
|
||||||
|
export const findChapterByProgress = (chapters: Chapters, progressMs: number) => ( |
||||||
|
findIndex(chapters, ({ endMs, startMs }) => ( |
||||||
|
startMs <= progressMs && progressMs <= endMs |
||||||
|
)) |
||||||
|
) |
||||||
|
|
||||||
|
export const getNextPlayer = (player: Players): Players => (player + 1) % 2 |
||||||
@ -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,102 @@ |
|||||||
|
import { useCallback } from 'react' |
||||||
|
|
||||||
|
import isUndefined from 'lodash/isUndefined' |
||||||
|
|
||||||
|
import type { SetPartialState } from 'hooks' |
||||||
|
|
||||||
|
import type { PlayerState } from '.' |
||||||
|
import { getNextPlayer } from '../helpers' |
||||||
|
|
||||||
|
export const usePlayingHandlers = ( |
||||||
|
setPlayerState: SetPartialState<PlayerState>, |
||||||
|
numberOfChapters: number, |
||||||
|
) => { |
||||||
|
const onReady = useCallback(() => { |
||||||
|
setPlayerState((state) => ( |
||||||
|
state.ready |
||||||
|
? state |
||||||
|
: { playing: true, ready: true } |
||||||
|
)) |
||||||
|
}, [setPlayerState]) |
||||||
|
|
||||||
|
const togglePlaying = useCallback(() => { |
||||||
|
setPlayerState((state) => ( |
||||||
|
state.ready |
||||||
|
? { playing: !state.playing } |
||||||
|
: state |
||||||
|
)) |
||||||
|
}, [setPlayerState]) |
||||||
|
|
||||||
|
const stopPlaying = useCallback(() => { |
||||||
|
setPlayerState({ playing: false }) |
||||||
|
}, [setPlayerState]) |
||||||
|
|
||||||
|
const startPlaying = useCallback(() => { |
||||||
|
setPlayerState((state) => ( |
||||||
|
state.ready |
||||||
|
? { playing: true } |
||||||
|
: state |
||||||
|
)) |
||||||
|
}, [setPlayerState]) |
||||||
|
|
||||||
|
const playNextChapter = useCallback((fromMs?: number, startOffsetMs?: number) => { |
||||||
|
setPlayerState((state) => { |
||||||
|
if (!state.ready) return state |
||||||
|
|
||||||
|
const isLastChapter = state.activeChapterIndex + 1 === numberOfChapters |
||||||
|
if (isLastChapter || isUndefined(fromMs) || isUndefined(startOffsetMs)) { |
||||||
|
return { |
||||||
|
activeChapterIndex: isLastChapter ? 0 : state.activeChapterIndex + 1, |
||||||
|
activePlayer: getNextPlayer(state.activePlayer), |
||||||
|
loadedProgress: 0, |
||||||
|
playedProgress: 0, |
||||||
|
playing: isLastChapter ? false : state.playing, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
activeChapterIndex: state.activeChapterIndex + 1, |
||||||
|
loadedProgress: 0, |
||||||
|
playedProgress: fromMs, |
||||||
|
playing: state.playing, |
||||||
|
seek: { |
||||||
|
...state.seek, |
||||||
|
[state.activePlayer]: (startOffsetMs + fromMs) / 1000, |
||||||
|
}, |
||||||
|
} |
||||||
|
}) |
||||||
|
}, [numberOfChapters, setPlayerState]) |
||||||
|
|
||||||
|
const playPrevChapter = useCallback((fromMs?: number, startOffsetMs?: number) => { |
||||||
|
setPlayerState((state) => { |
||||||
|
if (!state.ready || state.activeChapterIndex === 0) return state |
||||||
|
|
||||||
|
if (isUndefined(fromMs) || isUndefined(startOffsetMs)) { |
||||||
|
return { |
||||||
|
activeChapterIndex: state.activeChapterIndex - 1, |
||||||
|
loadedProgress: 0, |
||||||
|
playedProgress: 0, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
activeChapterIndex: state.activeChapterIndex - 1, |
||||||
|
loadedProgress: 0, |
||||||
|
playedProgress: fromMs, |
||||||
|
seek: { |
||||||
|
...state.seek, |
||||||
|
[state.activePlayer]: (startOffsetMs + fromMs) / 1000, |
||||||
|
}, |
||||||
|
} |
||||||
|
}) |
||||||
|
}, [setPlayerState]) |
||||||
|
|
||||||
|
return { |
||||||
|
onReady, |
||||||
|
playNextChapter, |
||||||
|
playPrevChapter, |
||||||
|
startPlaying, |
||||||
|
stopPlaying, |
||||||
|
togglePlaying, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,51 @@ |
|||||||
|
import { useCallback } from 'react' |
||||||
|
|
||||||
|
import type { SetPartialState } from 'hooks' |
||||||
|
|
||||||
|
import type { Chapters } from '../types' |
||||||
|
import type { PlayerState } from '.' |
||||||
|
import { findChapterByProgress } from '../helpers' |
||||||
|
|
||||||
|
type Args = { |
||||||
|
chapters: Chapters, |
||||||
|
duration: number, |
||||||
|
setPlayerState: SetPartialState<PlayerState>, |
||||||
|
} |
||||||
|
|
||||||
|
export const useProgressChangeHandler = ({ |
||||||
|
chapters, |
||||||
|
duration, |
||||||
|
setPlayerState, |
||||||
|
}: Args) => { |
||||||
|
const onProgressChange = useCallback((progress: number, seeking: boolean) => { |
||||||
|
setPlayerState((state) => { |
||||||
|
// значение новой позиции ползунка в миллисекундах
|
||||||
|
const progressMs = progress * duration |
||||||
|
const chapterIndex = findChapterByProgress(chapters, progressMs) |
||||||
|
const chapter = chapters[chapterIndex] |
||||||
|
const isProgressOnDifferentChapter = ( |
||||||
|
chapterIndex !== -1 |
||||||
|
&& chapterIndex !== state.activeChapterIndex |
||||||
|
) |
||||||
|
const nextChapter = isProgressOnDifferentChapter |
||||||
|
? chapterIndex |
||||||
|
: state.activeChapterIndex |
||||||
|
|
||||||
|
// отнимаем начало главы на котором остановились от общего прогресса
|
||||||
|
// чтобы получить прогресс текущей главы
|
||||||
|
const chapterProgressMs = (progressMs - chapter.startMs) |
||||||
|
const seekMs = chapterProgressMs + chapter.startOffsetMs |
||||||
|
return { |
||||||
|
activeChapterIndex: nextChapter, |
||||||
|
playedProgress: chapterProgressMs, |
||||||
|
seek: { |
||||||
|
...state.seek, |
||||||
|
[state.activePlayer]: seekMs / 1000, |
||||||
|
}, |
||||||
|
seeking, |
||||||
|
} |
||||||
|
}) |
||||||
|
}, [duration, chapters, setPlayerState]) |
||||||
|
|
||||||
|
return onProgressChange |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
import keys from 'lodash/keys' |
||||||
|
import uniq from 'lodash/uniq' |
||||||
|
import orderBy from 'lodash/orderBy' |
||||||
|
import includes from 'lodash/includes' |
||||||
|
|
||||||
|
import { useLocalStore } from 'hooks' |
||||||
|
|
||||||
|
import type { Chapters } from '../types' |
||||||
|
|
||||||
|
const getVideoQualities = (chapters: Chapters) => { |
||||||
|
const qualities = uniq(keys(chapters[0]?.urls)) |
||||||
|
return orderBy( |
||||||
|
qualities, |
||||||
|
Number, |
||||||
|
'desc', |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export const useVideoQuality = (chapters: Chapters) => { |
||||||
|
const videoQualities = getVideoQualities(chapters) |
||||||
|
|
||||||
|
const qualityValidator = (localStorageQuality: string) => ( |
||||||
|
includes(videoQualities, localStorageQuality) |
||||||
|
) |
||||||
|
|
||||||
|
const [selectedQuality, setSelectedQuality] = useLocalStore({ |
||||||
|
// по умолчанию наилучшее качество
|
||||||
|
defaultValue: videoQualities[0], |
||||||
|
key: 'player_quality', |
||||||
|
validator: qualityValidator, |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
selectedQuality, |
||||||
|
setSelectedQuality, |
||||||
|
videoQualities, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
export type Urls = { [quality: string]: string } |
||||||
|
|
||||||
|
export type Chapter = { |
||||||
|
duration: number, |
||||||
|
endMs: number, |
||||||
|
endOffsetMs: number, |
||||||
|
period: number, |
||||||
|
startMs: number, |
||||||
|
startOffsetMs: number, |
||||||
|
urls: Urls, |
||||||
|
} |
||||||
|
|
||||||
|
export type Chapters = Array<Chapter> |
||||||
|
|
||||||
|
export enum Players { |
||||||
|
PLAYER1 = 0, |
||||||
|
PLAYER2 = 1, |
||||||
|
} |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
import isEmpty from 'lodash/isEmpty' |
||||||
|
import filter from 'lodash/filter' |
||||||
|
|
||||||
|
import { API_ROOT, SportTypes } from 'config' |
||||||
|
import { callApi } from 'helpers' |
||||||
|
|
||||||
|
const filterByIds = (videos: Videos) => { |
||||||
|
const zeroIdVideos = filter(videos, { abc: '0' }) |
||||||
|
return isEmpty(zeroIdVideos) ? videos : zeroIdVideos |
||||||
|
} |
||||||
|
|
||||||
|
export type Video = { |
||||||
|
/** id дорожки */ |
||||||
|
abc: string, |
||||||
|
duration: number, |
||||||
|
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`, |
||||||
|
}).then(filterByIds) |
||||||
|
} |
||||||
Loading…
Reference in new issue