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 { useSlider } from 'features/StreamPlayer/hooks/useSlider' |
||||||
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip' |
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip' |
||||||
|
import { Scrubber } from 'features/StreamPlayer/components/ProgressBar/styled' |
||||||
|
|
||||||
import { |
import { Chapters } from '../Chapters' |
||||||
ProgressBarList, |
import type { Props } from './hooks' |
||||||
LoadedProgress, |
import { useProgressBar } from './hooks' |
||||||
PlayedProgress, |
import { ProgressBarList } from './styled' |
||||||
Scrubber, |
|
||||||
} from './styled' |
|
||||||
|
|
||||||
type Props = { |
|
||||||
duration: number, |
|
||||||
isScrubberVisible?: boolean, |
|
||||||
loadedProgress: number, |
|
||||||
onPlayedProgressChange: (progress: number) => void, |
|
||||||
playedProgress: number, |
|
||||||
} |
|
||||||
|
|
||||||
export const ProgressBar = ({ |
export const ProgressBar = (props: Props) => { |
||||||
duration, |
const { onPlayedProgressChange } = props |
||||||
isScrubberVisible, |
|
||||||
loadedProgress, |
|
||||||
onPlayedProgressChange, |
|
||||||
playedProgress, |
|
||||||
}: Props) => { |
|
||||||
const progressBarRef = useSlider({ onChange: onPlayedProgressChange }) |
const progressBarRef = useSlider({ onChange: onPlayedProgressChange }) |
||||||
const loadedFraction = Math.min(loadedProgress * 100 / duration, 100) |
const { |
||||||
const playedFraction = Math.min(playedProgress * 100 / duration, 100) |
calculatedChapters, |
||||||
|
playedProgressInPercent, |
||||||
|
time, |
||||||
|
} = useProgressBar(props) |
||||||
return ( |
return ( |
||||||
<ProgressBarList ref={progressBarRef}> |
<ProgressBarList ref={progressBarRef}> |
||||||
<LoadedProgress style={{ width: `${loadedFraction}%` }} /> |
<Chapters chapters={calculatedChapters} /> |
||||||
<PlayedProgress style={{ width: `${playedFraction}%` }} /> |
<Scrubber style={{ left: `${playedProgressInPercent}%` }}> |
||||||
{isScrubberVisible === false ? null : ( |
<TimeTooltip time={time} /> |
||||||
<Scrubber style={{ left: `${playedFraction}%` }}> |
|
||||||
<TimeTooltip time={secondsToHms(playedProgress / 1000)} /> |
|
||||||
</Scrubber> |
</Scrubber> |
||||||
)} |
|
||||||
</ProgressBarList> |
</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