Merge pull request 'OTT-1701-part-7-copy' (#6) from OTT-1701-part-7-copy into develop
Reviewed-on: https://gitea.instat.tv/InStat/spa_instat_tv/pulls/6keep-around/fdb88b04b32b9392e76795099e2ec47c9856b38b
commit
c7ad03f3bb
@ -0,0 +1,104 @@ |
||||
import last from 'lodash/last' |
||||
import find from 'lodash/find' |
||||
import reduce from 'lodash/reduce' |
||||
import concat from 'lodash/concat' |
||||
|
||||
import type { Episodes } from 'requests/getMatchPlaylists' |
||||
import type { MatchInfo } from 'requests/getMatchInfo' |
||||
|
||||
import type { Chapters, Chapter } from 'features/StreamPlayer/types' |
||||
|
||||
import type { MatchPlaylistOption, PlaylistOption } from '../../types' |
||||
import { FULL_GAME_KEY } from '../../helpers/buildPlaylists' |
||||
|
||||
export const FULL_MATCH_BOUNDARY = '0' |
||||
|
||||
/** |
||||
* Формирует эпизоды плейлиста Полный матч |
||||
* API не выдает полный матч как плейлист, формируем на фронте |
||||
* */ |
||||
const getFullMatchChapters = ( |
||||
profile: MatchInfo, |
||||
url: string, |
||||
playlist: MatchPlaylistOption, |
||||
) => { |
||||
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY }) |
||||
const durationMs = (playlist.duration ?? 0) * 1000 |
||||
return [ |
||||
{ |
||||
duration: durationMs, |
||||
endMs: durationMs, |
||||
endOffsetMs: bound ? Number(bound.e) * 1000 : durationMs, |
||||
index: 0, |
||||
isFullMatchChapter: true, |
||||
startMs: 0, |
||||
startOffsetMs: bound ? Number(bound.s) * 1000 : 0, |
||||
url, |
||||
}, |
||||
] |
||||
} |
||||
|
||||
/** |
||||
* Формирует эпизоды плейлистов матча и игроков |
||||
* */ |
||||
const getPlaylistChapters = ( |
||||
profile: MatchInfo, |
||||
url: string, |
||||
episodes: Episodes, |
||||
) => reduce( |
||||
episodes, |
||||
( |
||||
acc: Chapters, |
||||
episode, |
||||
index, |
||||
) => { |
||||
if (episode.s >= episode.e) return acc |
||||
|
||||
const bound = find(profile?.video_bounds, { h: String(episode.h) }) |
||||
const boundStart = bound ? Number(bound.s) : 0 |
||||
|
||||
const episodeDuration = (episode.e - episode.s) * 1000 |
||||
const prevVideoEndMs = last(acc)?.endMs ?? 0 |
||||
|
||||
const nextChapter: Chapter = { |
||||
duration: episodeDuration, |
||||
endMs: prevVideoEndMs + episodeDuration, |
||||
endOffsetMs: (boundStart + episode.e) * 1000, |
||||
index, |
||||
startMs: prevVideoEndMs, |
||||
startOffsetMs: (boundStart + episode.s) * 1000, |
||||
url, |
||||
} |
||||
return concat(acc, nextChapter) |
||||
}, |
||||
[], |
||||
) |
||||
|
||||
type Args = { |
||||
profile: MatchInfo, |
||||
selectedPlaylist?: PlaylistOption, |
||||
url: string, |
||||
} |
||||
|
||||
/** |
||||
* Формирует список эпизодов из выбранного плейлиста для плеера |
||||
*/ |
||||
export const buildChapters = ({ |
||||
profile, |
||||
selectedPlaylist, |
||||
url, |
||||
}: Args): Chapters => { |
||||
if (!selectedPlaylist) return [] |
||||
if (selectedPlaylist.id === FULL_GAME_KEY) { |
||||
return getFullMatchChapters( |
||||
profile, |
||||
url, |
||||
selectedPlaylist, |
||||
) |
||||
} |
||||
return getPlaylistChapters( |
||||
profile, |
||||
url, |
||||
selectedPlaylist.episodes, |
||||
) |
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
import { useMemo } from 'react' |
||||
|
||||
import type { PlaylistOption } from 'features/MatchPage/types' |
||||
import type { MatchInfo } from 'requests/getMatchInfo' |
||||
|
||||
import { buildChapters } from '../helpers' |
||||
|
||||
type Args = { |
||||
profile: MatchInfo, |
||||
selectedPlaylist?: PlaylistOption, |
||||
url: string, |
||||
} |
||||
|
||||
export const useChapters = ({ |
||||
profile, |
||||
selectedPlaylist, |
||||
url, |
||||
}: Args) => { |
||||
const chapters = useMemo( |
||||
() => buildChapters({ |
||||
profile, |
||||
selectedPlaylist, |
||||
url, |
||||
}), |
||||
[ |
||||
profile, |
||||
selectedPlaylist, |
||||
url, |
||||
], |
||||
) |
||||
|
||||
return { chapters } |
||||
} |
||||
@ -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 usePlaylistLogger = () => { |
||||
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,23 @@ |
||||
import { useMemo } from 'react' |
||||
import { useLocation } from 'react-router' |
||||
|
||||
import isNumber from 'lodash/isNumber' |
||||
|
||||
export const RESUME_KEY = 'resume' |
||||
|
||||
const readResumeParam = (search: string) => { |
||||
const params = new URLSearchParams(search) |
||||
const rawValue = params.get(RESUME_KEY) |
||||
if (!rawValue) return undefined |
||||
|
||||
const value = JSON.parse(rawValue) |
||||
return isNumber(value) ? value : 0 |
||||
} |
||||
|
||||
export const useResumeUrlParam = () => { |
||||
const { search } = useLocation() |
||||
|
||||
const resume = useMemo(() => readResumeParam(search), [search]) |
||||
|
||||
return resume |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
import find from 'lodash/find' |
||||
|
||||
import type { MatchInfo } from 'requests/getMatchInfo' |
||||
|
||||
import { FULL_MATCH_BOUNDARY } from 'features/MatchPage/components/LiveMatch/helpers' |
||||
|
||||
export const calculateDuration = (profile: MatchInfo) => { |
||||
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY }) |
||||
if (!bound) return 0 |
||||
return Number(bound.e) - Number(bound.s) |
||||
} |
||||
@ -0,0 +1,92 @@ |
||||
import { |
||||
useEffect, |
||||
useState, |
||||
useMemo, |
||||
} from 'react' |
||||
import { useToggle } from 'hooks' |
||||
|
||||
import type { MatchInfo } from 'requests/getMatchInfo' |
||||
import { getMatchInfo } from 'requests/getMatchInfo' |
||||
|
||||
import { usePageParams } from 'hooks/usePageParams' |
||||
|
||||
import { parseDate } from 'helpers/parseDate' |
||||
|
||||
import { useMatchData } from './useMatchData' |
||||
|
||||
import type { Playlists } from '../../types' |
||||
|
||||
const addScoresFromPlaylists = ( |
||||
profile: MatchInfo, |
||||
playlists: Playlists, |
||||
): MatchInfo => ( |
||||
profile |
||||
? { |
||||
...profile, |
||||
team1: { |
||||
...profile?.team1, |
||||
score: playlists.score1, |
||||
}, |
||||
team2: { |
||||
...profile?.team2, |
||||
score: playlists.score2, |
||||
}, |
||||
} |
||||
: null |
||||
) |
||||
|
||||
export const useMatchPage = () => { |
||||
const [matchProfile, setMatchProfile] = useState<MatchInfo>(null) |
||||
const { profileId: matchId, sportType } = usePageParams() |
||||
const { |
||||
close: hideProfileCard, |
||||
isOpen: profileCardShown, |
||||
open: showProfileCard, |
||||
} = useToggle(true) |
||||
|
||||
useEffect(() => { |
||||
getMatchInfo(sportType, matchId).then(setMatchProfile) |
||||
}, [sportType, matchId]) |
||||
|
||||
useEffect(() => { |
||||
let getIntervalMatch: ReturnType<typeof setInterval> |
||||
if (matchProfile?.live && !matchProfile.youtube_link) { |
||||
getIntervalMatch = setInterval( |
||||
() => getMatchInfo(sportType, matchId).then(setMatchProfile), 1000 * 60 * 3, |
||||
) |
||||
} |
||||
return () => clearInterval(getIntervalMatch) |
||||
}, [matchProfile, sportType, matchId]) |
||||
|
||||
const { |
||||
events, |
||||
handlePlaylistClick, |
||||
matchPlaylists, |
||||
selectedPlaylist, |
||||
setFullMatchPlaylistDuration, |
||||
} = useMatchData(matchProfile) |
||||
|
||||
const profile = useMemo( |
||||
() => addScoresFromPlaylists(matchProfile, matchPlaylists), |
||||
[matchProfile, matchPlaylists], |
||||
) |
||||
|
||||
const isStarted = useMemo(() => ( |
||||
profile?.date |
||||
? parseDate(profile.date) < new Date() |
||||
: true |
||||
), [profile?.date]) |
||||
|
||||
return { |
||||
events, |
||||
handlePlaylistClick, |
||||
hideProfileCard, |
||||
isStarted, |
||||
matchPlaylists, |
||||
profile, |
||||
profileCardShown, |
||||
selectedPlaylist, |
||||
setFullMatchPlaylistDuration, |
||||
showProfileCard, |
||||
} |
||||
} |
||||
@ -0,0 +1,28 @@ |
||||
import { useCallback, useState } from 'react' |
||||
|
||||
import type { Events } from 'requests' |
||||
import { getMatchEvents } from 'requests' |
||||
|
||||
import { usePageParams } from 'hooks/usePageParams' |
||||
|
||||
import { useEventsLexics } from './useEventsLexics' |
||||
|
||||
export const useEvents = () => { |
||||
const [events, setEvents] = useState<Events>([]) |
||||
const { fetchLexics } = useEventsLexics() |
||||
const { profileId: matchId, sportType } = usePageParams() |
||||
|
||||
const fetchMatchEvents = useCallback(() => { |
||||
getMatchEvents({ |
||||
matchId, |
||||
sportType, |
||||
}).then(fetchLexics) |
||||
.then(setEvents) |
||||
}, [ |
||||
fetchLexics, |
||||
matchId, |
||||
sportType, |
||||
]) |
||||
|
||||
return { events, fetchMatchEvents } |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
import { useCallback } from 'react' |
||||
|
||||
import isEmpty from 'lodash/isEmpty' |
||||
import map from 'lodash/map' |
||||
import uniq from 'lodash/uniq' |
||||
|
||||
import type { Events } from 'requests' |
||||
|
||||
import { useLexicsStore } from 'features/LexicsStore' |
||||
|
||||
export const useEventsLexics = () => { |
||||
const { addLexicsConfig } = useLexicsStore() |
||||
|
||||
const fetchLexics = useCallback((events: Events) => { |
||||
const lexics = uniq(map(events, ({ l }) => l)) |
||||
|
||||
if (!isEmpty(lexics)) { |
||||
addLexicsConfig(lexics) |
||||
} |
||||
|
||||
return events |
||||
}, [addLexicsConfig]) |
||||
|
||||
return { fetchLexics } |
||||
} |
||||
@ -0,0 +1,82 @@ |
||||
import { useEffect, useMemo } from 'react' |
||||
|
||||
import debounce from 'lodash/debounce' |
||||
|
||||
import { MatchInfo } from 'requests/getMatchInfo' |
||||
|
||||
import { usePageParams } from 'hooks/usePageParams' |
||||
import { useInterval } from 'hooks/useInterval' |
||||
|
||||
import { calculateDuration } from '../../helpers/fullMatchDuration' |
||||
import { useMatchPlaylists } from './useMatchPlaylists' |
||||
import { useEvents } from './useEvents' |
||||
|
||||
const MATCH_DATA_POLL_INTERVAL = 60000 |
||||
const MATCH_PLAYLISTS_DELAY = 5000 |
||||
|
||||
export const useMatchData = (profile: MatchInfo) => { |
||||
const { profileId: matchId, sportType } = usePageParams() |
||||
const { |
||||
fetchMatchPlaylists, |
||||
handlePlaylistClick, |
||||
matchPlaylists, |
||||
selectedPlaylist, |
||||
setFullMatchPlaylistDuration, |
||||
} = useMatchPlaylists() |
||||
const { events, fetchMatchEvents } = useEvents() |
||||
|
||||
const fetchPlaylistsDebounced = useMemo( |
||||
() => debounce(fetchMatchPlaylists, MATCH_PLAYLISTS_DELAY), |
||||
[fetchMatchPlaylists], |
||||
) |
||||
|
||||
const fullMatchDuration = useMemo(() => calculateDuration(profile), [profile]) |
||||
|
||||
useEffect(() => { |
||||
if (!profile) return |
||||
fetchMatchPlaylists({ |
||||
fullMatchDuration, |
||||
id: matchId, |
||||
sportType, |
||||
}) |
||||
fetchMatchEvents() |
||||
}, [ |
||||
profile, |
||||
fullMatchDuration, |
||||
matchId, |
||||
sportType, |
||||
fetchMatchPlaylists, |
||||
fetchMatchEvents, |
||||
]) |
||||
|
||||
const intervalCallback = () => { |
||||
fetchPlaylistsDebounced({ |
||||
fullMatchDuration, |
||||
id: matchId, |
||||
sportType, |
||||
}) |
||||
fetchMatchEvents() |
||||
} |
||||
|
||||
const { start, stop } = useInterval({ |
||||
callback: intervalCallback, |
||||
intervalDuration: MATCH_DATA_POLL_INTERVAL, |
||||
startImmediate: false, |
||||
}) |
||||
|
||||
useEffect(() => { |
||||
if (profile?.live) { |
||||
start() |
||||
} else { |
||||
stop() |
||||
} |
||||
}, [profile?.live, start, stop]) |
||||
|
||||
return { |
||||
events, |
||||
handlePlaylistClick, |
||||
matchPlaylists, |
||||
selectedPlaylist, |
||||
setFullMatchPlaylistDuration, |
||||
} |
||||
} |
||||
@ -0,0 +1,89 @@ |
||||
import { |
||||
useState, |
||||
useCallback, |
||||
} from 'react' |
||||
|
||||
import isEmpty from 'lodash/isEmpty' |
||||
|
||||
import type { SportTypes } from 'config/sportTypes' |
||||
|
||||
import { getMatchPlaylists } from 'requests/getMatchPlaylists' |
||||
|
||||
import type { Playlists } from 'features/MatchPage/types' |
||||
import { buildPlaylists } from 'features/MatchPage/helpers/buildPlaylists' |
||||
|
||||
import { usePlaylistLexics } from './usePlaylistLexics' |
||||
import { useSelectedPlaylist } from './useSelectedPlaylist' |
||||
|
||||
type ArgsFetchMatchPlaylists = { |
||||
fullMatchDuration: number, |
||||
id: number, |
||||
sportType: SportTypes, |
||||
} |
||||
|
||||
const initialPlaylists = buildPlaylists(null) |
||||
|
||||
export const useMatchPlaylists = () => { |
||||
const [matchPlaylists, setMatchPlaylists] = useState<Playlists>(initialPlaylists) |
||||
|
||||
const { fetchLexics } = usePlaylistLexics() |
||||
const { |
||||
handlePlaylistClick, |
||||
selectedPlaylist, |
||||
setSelectedPlaylist, |
||||
} = useSelectedPlaylist() |
||||
|
||||
const setInitialSeletedPlaylist = useCallback((playlists: Playlists) => { |
||||
setSelectedPlaylist((playlist) => { |
||||
if (!playlist && !isEmpty(playlists.match)) { |
||||
return playlists.match[0] |
||||
} |
||||
return playlist |
||||
}) |
||||
return playlists |
||||
}, [setSelectedPlaylist]) |
||||
|
||||
const fetchMatchPlaylists = useCallback(({ |
||||
fullMatchDuration, |
||||
id, |
||||
sportType, |
||||
}: ArgsFetchMatchPlaylists) => { |
||||
getMatchPlaylists({ |
||||
fullMatchDuration, |
||||
matchId: id, |
||||
selectedActions: [], |
||||
sportType, |
||||
}).then(fetchLexics) |
||||
.then(buildPlaylists) |
||||
.then(setInitialSeletedPlaylist) |
||||
.then(setMatchPlaylists) |
||||
}, [fetchLexics, setInitialSeletedPlaylist]) |
||||
|
||||
/** |
||||
* API не выдает длительность Полного матча |
||||
* Здесь получаем его из самого видео |
||||
* и обновляем длительность плейлиста Полный матч |
||||
*/ |
||||
const setFullMatchPlaylistDuration = (duration: number) => { |
||||
const playlists = [...matchPlaylists.match] |
||||
if (!playlists[0]) return |
||||
|
||||
playlists[0].duration = duration |
||||
setMatchPlaylists({ |
||||
...matchPlaylists, |
||||
match: playlists, |
||||
}) |
||||
|
||||
if (selectedPlaylist) { |
||||
setSelectedPlaylist({ ...selectedPlaylist }) |
||||
} |
||||
} |
||||
|
||||
return { |
||||
fetchMatchPlaylists, |
||||
handlePlaylistClick, |
||||
matchPlaylists, |
||||
selectedPlaylist, |
||||
setFullMatchPlaylistDuration, |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@ |
||||
import { useCallback } from 'react' |
||||
|
||||
import isEmpty from 'lodash/isEmpty' |
||||
import compact from 'lodash/compact' |
||||
import values from 'lodash/values' |
||||
|
||||
import type { MatchPlaylists } from 'requests' |
||||
|
||||
import { useLexicsStore } from 'features/LexicsStore' |
||||
|
||||
export const usePlaylistLexics = () => { |
||||
const { addLexicsConfig } = useLexicsStore() |
||||
const fetchLexics = useCallback((playlist: MatchPlaylists) => { |
||||
const lexics = compact(values(playlist.lexics)) |
||||
if (!isEmpty(lexics)) { |
||||
addLexicsConfig(lexics) |
||||
} |
||||
return playlist |
||||
}, [addLexicsConfig]) |
||||
|
||||
return { fetchLexics } |
||||
} |
||||
@ -0,0 +1,49 @@ |
||||
import type { MouseEvent } from 'react' |
||||
import { useState, useCallback } from 'react' |
||||
|
||||
import { getPlayerPlaylists } from 'requests/getPlayerPlaylists' |
||||
|
||||
import { usePageParams } from 'hooks/usePageParams' |
||||
|
||||
import { |
||||
PlayerPlaylistOption, |
||||
PlaylistOption, |
||||
PlaylistTypes, |
||||
} from 'features/MatchPage/types' |
||||
import { defaultSettings } from 'features/MatchPopup/types' |
||||
|
||||
export const useSelectedPlaylist = () => { |
||||
const { profileId: matchId, sportType } = usePageParams() |
||||
const [selectedPlaylist, setSelectedPlaylist] = useState<PlaylistOption>() |
||||
|
||||
const fetchPlayerEpisodes = useCallback((playlistOption: PlayerPlaylistOption) => ( |
||||
getPlayerPlaylists({ |
||||
matchId, |
||||
playerId: playlistOption.id, |
||||
settings: defaultSettings, |
||||
sportType, |
||||
}) |
||||
), [matchId, sportType]) |
||||
|
||||
const handlePlaylistClick = useCallback((playlist: PlaylistOption, e?: MouseEvent) => { |
||||
e?.stopPropagation() |
||||
if (playlist === selectedPlaylist) return |
||||
|
||||
if (playlist.type === PlaylistTypes.PLAYER) { |
||||
fetchPlayerEpisodes(playlist).then((episodes) => { |
||||
setSelectedPlaylist({ |
||||
...playlist, |
||||
episodes, |
||||
}) |
||||
}) |
||||
} else { |
||||
setSelectedPlaylist(playlist) |
||||
} |
||||
}, [fetchPlayerEpisodes, selectedPlaylist]) |
||||
|
||||
return { |
||||
handlePlaylistClick, |
||||
selectedPlaylist, |
||||
setSelectedPlaylist, |
||||
} |
||||
} |
||||
@ -1,42 +0,0 @@ |
||||
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,36 @@ |
||||
import map from 'lodash/map' |
||||
|
||||
import type { Chapter } from '../../types' |
||||
import { |
||||
ChapterList, |
||||
ChapterContainer, |
||||
LoadedProgress, |
||||
PlayedProgress, |
||||
} 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,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,62 @@ |
||||
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/StreamPlayer/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: playedProgress * 100 / videoDuration, |
||||
width: chapter.isFullMatchChapter |
||||
? 100 |
||||
: chapter.duration * 100 / videoDuration, |
||||
} |
||||
return [ |
||||
...playedChapters, |
||||
activeChapter, |
||||
...comingChapters, |
||||
] |
||||
} |
||||
@ -0,0 +1,52 @@ |
||||
import { useMemo, RefObject } from 'react' |
||||
|
||||
import { secondsToHms } from 'helpers' |
||||
|
||||
import type { Chapters } from '../../../StreamPlayer/types' |
||||
import { calculateChapterStyles } from './helpers/calculateChapterStyles' |
||||
|
||||
export type Props = { |
||||
activeChapterIndex: number, |
||||
allPlayedProgress: number, |
||||
chapters: Chapters, |
||||
duration: number, |
||||
isScrubberVisible?: boolean, |
||||
loadedProgress: number, |
||||
onPlayedProgressChange: (progress: number, seeking: boolean) => void, |
||||
onTouchEnd?: () => any, |
||||
onTouchStart?: () => any, |
||||
playedProgress: number, |
||||
videoRef?: RefObject<HTMLVideoElement>, |
||||
} |
||||
|
||||
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), |
||||
} |
||||
} |
||||
@ -1,43 +1,26 @@ |
||||
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 { |
||||
ProgressBarList, |
||||
LoadedProgress, |
||||
PlayedProgress, |
||||
Scrubber, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
duration: number, |
||||
isScrubberVisible?: boolean, |
||||
loadedProgress: number, |
||||
onPlayedProgressChange: (progress: number) => void, |
||||
playedProgress: number, |
||||
} |
||||
import { Chapters } from '../Chapters' |
||||
import type { Props } from './hooks' |
||||
import { useProgressBar } from './hooks' |
||||
import { ProgressBarList } from './styled' |
||||
|
||||
export const ProgressBar = ({ |
||||
duration, |
||||
isScrubberVisible, |
||||
loadedProgress, |
||||
onPlayedProgressChange, |
||||
playedProgress, |
||||
}: Props) => { |
||||
export const ProgressBar = (props: Props) => { |
||||
const { onPlayedProgressChange } = props |
||||
const progressBarRef = useSlider({ onChange: onPlayedProgressChange }) |
||||
const loadedFraction = Math.min(loadedProgress * 100 / duration, 100) |
||||
const playedFraction = Math.min(playedProgress * 100 / duration, 100) |
||||
|
||||
const { |
||||
calculatedChapters, |
||||
playedProgressInPercent, |
||||
time, |
||||
} = useProgressBar(props) |
||||
return ( |
||||
<ProgressBarList ref={progressBarRef}> |
||||
<LoadedProgress style={{ width: `${loadedFraction}%` }} /> |
||||
<PlayedProgress style={{ width: `${playedFraction}%` }} /> |
||||
{isScrubberVisible === false ? null : ( |
||||
<Scrubber style={{ left: `${playedFraction}%` }}> |
||||
<TimeTooltip time={secondsToHms(playedProgress / 1000)} /> |
||||
</Scrubber> |
||||
)} |
||||
<Chapters chapters={calculatedChapters} /> |
||||
<Scrubber style={{ left: `${playedProgressInPercent}%` }}> |
||||
<TimeTooltip time={time} /> |
||||
</Scrubber> |
||||
</ProgressBarList> |
||||
) |
||||
} |
||||
|
||||
@ -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: 10px; |
||||
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,13 @@ |
||||
import findIndex from 'lodash/findIndex' |
||||
import size from 'lodash/size' |
||||
|
||||
import type { Chapters } from '../types' |
||||
|
||||
export const findChapterByProgress = (chapters: Chapters, progressMs: number) => { |
||||
if (size(chapters) === 1 && chapters[0].isFullMatchChapter) return 0 |
||||
return ( |
||||
findIndex(chapters, ({ endMs, startMs }) => ( |
||||
startMs <= progressMs && progressMs <= endMs |
||||
)) |
||||
) |
||||
} |
||||
@ -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,113 @@ |
||||
import { useCallback } from 'react' |
||||
|
||||
import isUndefined from 'lodash/isUndefined' |
||||
|
||||
import type { SetPartialState } from 'hooks' |
||||
|
||||
import type { PlayerState } from '.' |
||||
import type { Chapters } from '../types' |
||||
|
||||
export const usePlayingHandlers = ( |
||||
setPlayerState: SetPartialState<PlayerState>, |
||||
chapters: Chapters, |
||||
) => { |
||||
const onReady = useCallback(() => { |
||||
setPlayerState((state) => ( |
||||
state.ready |
||||
? state |
||||
: { |
||||
buffering: false, |
||||
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 nextChapterIndex = state.activeChapterIndex + 1 |
||||
const nextChapter = chapters[nextChapterIndex] |
||||
if (!nextChapter) { |
||||
return { |
||||
activeChapterIndex: 0, |
||||
loadedProgress: 0, |
||||
playedProgress: 0, |
||||
playing: false, |
||||
seek: chapters[0].startOffsetMs / 1000, |
||||
seeking: false, |
||||
} |
||||
} |
||||
if (isUndefined(fromMs) || isUndefined(startOffsetMs)) { |
||||
return { |
||||
activeChapterIndex: nextChapterIndex, |
||||
loadedProgress: 0, |
||||
playedProgress: 0, |
||||
seek: nextChapter.startOffsetMs / 1000, |
||||
} |
||||
} |
||||
|
||||
return { |
||||
activeChapterIndex: nextChapterIndex, |
||||
loadedProgress: 0, |
||||
playedProgress: fromMs, |
||||
playing: state.playing, |
||||
seek: (startOffsetMs + fromMs) / 1000, |
||||
} |
||||
}) |
||||
}, [chapters, setPlayerState]) |
||||
|
||||
const playPrevChapter = useCallback((fromMs?: number, startOffsetMs?: number) => { |
||||
setPlayerState((state) => { |
||||
if (!state.ready || state.activeChapterIndex === 0) return state |
||||
|
||||
const prevChapterIndex = state.activeChapterIndex - 1 |
||||
const prevChapter = chapters[prevChapterIndex] |
||||
if (isUndefined(fromMs) || isUndefined(startOffsetMs)) { |
||||
return { |
||||
activeChapterIndex: prevChapterIndex, |
||||
loadedProgress: 0, |
||||
playedProgress: 0, |
||||
seek: prevChapter.startOffsetMs / 1000, |
||||
} |
||||
} |
||||
|
||||
return { |
||||
activeChapterIndex: prevChapterIndex, |
||||
loadedProgress: 0, |
||||
playedProgress: fromMs, |
||||
seek: (startOffsetMs + fromMs) / 1000, |
||||
} |
||||
}) |
||||
}, [chapters, setPlayerState]) |
||||
|
||||
return { |
||||
onReady, |
||||
playNextChapter, |
||||
playPrevChapter, |
||||
startPlaying, |
||||
stopPlaying, |
||||
togglePlaying, |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@ |
||||
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: seekMs / 1000, |
||||
seeking, |
||||
} |
||||
}) |
||||
}, [duration, chapters, setPlayerState]) |
||||
|
||||
return onProgressChange |
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
/** |
||||
* для примера матч с двумя эпизодами в плейлисте Голы, время в мс: |
||||
* [{start: 0, end: 20000}, {start: 60000, end: 80000}] |
||||
*/ |
||||
|
||||
export type Chapter = { |
||||
duration: number, |
||||
|
||||
/** |
||||
* конец эпизода в плейлисте |
||||
* в первом эпизоде - 20000, во втором - 40000 |
||||
*/ |
||||
endMs: number, |
||||
|
||||
/** конец эпизода как отмечено в матче */ |
||||
endOffsetMs: number, |
||||
|
||||
/** индекс эпизода для дебага */ |
||||
index?: number, |
||||
isFullMatchChapter?: boolean, |
||||
|
||||
/** |
||||
* начало эпизода в плейлисте |
||||
* в первом эпизоде - 0, во втором - 20000 |
||||
*/ |
||||
startMs: number, |
||||
|
||||
/** начало эпизода как отмечено в матче */ |
||||
startOffsetMs: number, |
||||
url: string, |
||||
} |
||||
|
||||
export type Chapters = Array<Chapter> |
||||
@ -1,24 +0,0 @@ |
||||
import pipe from 'lodash/fp/pipe' |
||||
import orderBy from 'lodash/fp/orderBy' |
||||
import sumBy from 'lodash/fp/sumBy' |
||||
import uniqBy from 'lodash/fp/uniqBy' |
||||
|
||||
import type { Videos, Video } from './getVideos' |
||||
import { getVideos } from './getVideos' |
||||
|
||||
const calculateDuration = (videos: Videos) => { |
||||
const durationMs = pipe( |
||||
orderBy(({ quality }: Video) => Number(quality), 'desc'), |
||||
uniqBy(({ period }: Video) => period), |
||||
sumBy(({ duration }: Video) => duration), |
||||
)(videos) |
||||
return durationMs / 1000 |
||||
} |
||||
|
||||
/** |
||||
* Временный способ получения длительности матча |
||||
*/ |
||||
export const getFullMatchDuration = async (...args: Parameters<typeof getVideos>) => { |
||||
const videos = await getVideos(...args) |
||||
return calculateDuration(videos) |
||||
} |
||||
Loading…
Reference in new issue