Ott 595 playlists (#262)
* Ott 595 part 1/links to playlist (#256) * refactor(595): moved match profile card into components * refactor(595): extracted players into separate component * refactor(595): moved scroll to video player into player component * feat(595): added links to playlist page * Ott 595 part 2/prep (#258) * feat(595): added useObjectState hook * refactor(595): get video qualities from chapters type * refactor(595): moved full match chapters building * refactor(595): calculate progress bar chapters progresses * fix(595): set progress limit * refactor(595): save only chapter progress * refactor(595): converted active chapter index ref into state * Ott 595 part 3/playper prep (#259) * refactor(595): playlist play prep refactoring * refactor(595): removed interviews block * feat(595): added requests and changed lexics * refactor(595): building and playing playlist (#260) * refactor(595): go to match on popup playlist click (#261)keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
parent
58deedf2d9
commit
227e983314
@ -0,0 +1,121 @@ |
|||||||
|
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, |
||||||
|
PlaylistData, |
||||||
|
Episode, |
||||||
|
} from 'requests' |
||||||
|
|
||||||
|
import type { Chapters, Urls } from 'features/MultiSourcePlayer/types' |
||||||
|
|
||||||
|
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 nextChapter = { |
||||||
|
duration: video.duration, |
||||||
|
endMs: prevVideoEndMs + video.duration, |
||||||
|
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>, playlists: PlaylistData) => { |
||||||
|
const groupedByPeriods = groupBy(videos, ({ period }) => period) |
||||||
|
return reduce( |
||||||
|
playlists, |
||||||
|
(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, |
||||||
|
period: video.period, |
||||||
|
startMs: prevVideoEndMs, |
||||||
|
startOffsetMs: episode.s * 1000, |
||||||
|
urls: getEpisodeUrls(video.urls, episode), |
||||||
|
} |
||||||
|
return concat(acc, nextChapter) |
||||||
|
}, |
||||||
|
[], |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export const buildChapters = (videos: Videos, playlists: PlaylistData = []) => { |
||||||
|
const periods = getUniquePeriods(videos) |
||||||
|
const highQualityVideos = getVideoByPeriods(videos, periods) |
||||||
|
return isEmpty(playlists) |
||||||
|
? getFullMatchChapters(highQualityVideos) |
||||||
|
: getPlaylistChapters(highQualityVideos, playlists) |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
import map from 'lodash/map' |
||||||
|
|
||||||
|
import type { MatchPlaylists, Players } from 'requests' |
||||||
|
|
||||||
|
import type { |
||||||
|
Playlists, |
||||||
|
PlayerPlaylistOptions, |
||||||
|
MatchPlaylistOptions, |
||||||
|
} from 'features/MatchPage/types' |
||||||
|
import { PlaylistTypes } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
const matchKeys = [ |
||||||
|
'full_match', |
||||||
|
'highlights', |
||||||
|
'ball_in_play', |
||||||
|
'goals', |
||||||
|
] as const |
||||||
|
|
||||||
|
const getMatchPlaylists = (matchPlaylists: MatchPlaylists | null): MatchPlaylistOptions => { |
||||||
|
if (!matchPlaylists) return [] |
||||||
|
|
||||||
|
return map(matchKeys, (key) => { |
||||||
|
const playlist = matchPlaylists[key] |
||||||
|
return { |
||||||
|
data: playlist.data, |
||||||
|
duration: playlist.dur, |
||||||
|
id: key, |
||||||
|
lexic: `match_playlist_${key}`, |
||||||
|
type: PlaylistTypes.MATCH, |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const getPlayerPlaylists = (players?: Players): PlayerPlaylistOptions => ( |
||||||
|
map(players, (player) => ({ |
||||||
|
...player, |
||||||
|
name_eng: `${player.num} ${player.name_eng}`, |
||||||
|
name_rus: `${player.num} ${player.name_rus}`, |
||||||
|
type: PlaylistTypes.PLAYER, |
||||||
|
})) |
||||||
|
) |
||||||
|
|
||||||
|
export const buildPlaylists = (matchPlaylists: MatchPlaylists | null) => { |
||||||
|
const playlists: Playlists = { |
||||||
|
interview: [], |
||||||
|
match: getMatchPlaylists(matchPlaylists), |
||||||
|
players: { |
||||||
|
team1: getPlayerPlaylists(matchPlaylists?.players1), |
||||||
|
team2: getPlayerPlaylists(matchPlaylists?.players2), |
||||||
|
}, |
||||||
|
} |
||||||
|
return playlists |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import { |
||||||
|
useEffect, |
||||||
|
useMemo, |
||||||
|
useState, |
||||||
|
} from 'react' |
||||||
|
|
||||||
|
import type { PlaylistData, Videos } from 'requests' |
||||||
|
import { getVideos } from 'requests' |
||||||
|
|
||||||
|
import { usePageId, useSportNameParam } from 'hooks' |
||||||
|
|
||||||
|
import { buildChapters } from '../helpers/buildChapters' |
||||||
|
|
||||||
|
export const useChapters = (isFinishedMatch: boolean, playlist: PlaylistData) => { |
||||||
|
const [videos, setVideos] = useState<Videos>([]) |
||||||
|
const { sportType } = useSportNameParam() |
||||||
|
const matchId = usePageId() |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (isFinishedMatch) { |
||||||
|
getVideos(sportType, matchId).then(setVideos) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
isFinishedMatch, |
||||||
|
matchId, |
||||||
|
sportType, |
||||||
|
]) |
||||||
|
|
||||||
|
const chapters = useMemo( |
||||||
|
() => buildChapters(videos, playlist), |
||||||
|
[playlist, videos], |
||||||
|
) |
||||||
|
|
||||||
|
return { chapters } |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
import { useEffect, useState } from 'react' |
||||||
|
|
||||||
|
import type { LiveVideos } from 'requests' |
||||||
|
import { getLiveVideos } from 'requests' |
||||||
|
|
||||||
|
import { useSportNameParam, usePageId } from 'hooks' |
||||||
|
|
||||||
|
import { useLastPlayPosition } from './useLastPlayPosition' |
||||||
|
import { usePlaylists } from './usePlaylists' |
||||||
|
import { useChapters } from './useChapters' |
||||||
|
import { useRouteState } from './useRouteState' |
||||||
|
|
||||||
|
export const useMatchPage = () => { |
||||||
|
const { initialSelectedPlaylist } = useRouteState() |
||||||
|
const [isFinishedMatch, setFinishedMatch] = useState(Boolean(initialSelectedPlaylist)) |
||||||
|
const [liveVideos, setLiveVideos] = useState<LiveVideos>([]) |
||||||
|
const { sportType } = useSportNameParam() |
||||||
|
const matchId = usePageId() |
||||||
|
|
||||||
|
const { |
||||||
|
onPlaylistSelect, |
||||||
|
playlistData, |
||||||
|
playlists, |
||||||
|
selectedPlaylist, |
||||||
|
} = usePlaylists(isFinishedMatch) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!isFinishedMatch) { |
||||||
|
getLiveVideos(sportType, matchId) |
||||||
|
.then(setLiveVideos) |
||||||
|
.catch(() => setFinishedMatch(true)) |
||||||
|
} |
||||||
|
}, |
||||||
|
[ |
||||||
|
isFinishedMatch, |
||||||
|
sportType, |
||||||
|
matchId, |
||||||
|
]) |
||||||
|
|
||||||
|
return { |
||||||
|
isFinishedMatch, |
||||||
|
onPlaylistSelect, |
||||||
|
playlists, |
||||||
|
selectedPlaylist, |
||||||
|
url: liveVideos[0] || '', |
||||||
|
...useChapters(isFinishedMatch, playlistData), |
||||||
|
...useLastPlayPosition(), |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
import { |
||||||
|
useCallback, |
||||||
|
useState, |
||||||
|
useEffect, |
||||||
|
} from 'react' |
||||||
|
|
||||||
|
import { getPlayerPlaylists } from 'requests' |
||||||
|
|
||||||
|
import { usePageId, useSportNameParam } from 'hooks' |
||||||
|
|
||||||
|
import { PlaylistOption, PlaylistTypes } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
import { useRouteState } from './useRouteState' |
||||||
|
|
||||||
|
export const usePlaylistsData = () => { |
||||||
|
const { initialPlaylistData, initialSelectedPlaylist } = useRouteState() |
||||||
|
const [playlistData, setPlaylistData] = useState(initialPlaylistData || []) |
||||||
|
const { sportType } = useSportNameParam() |
||||||
|
const matchId = usePageId() |
||||||
|
|
||||||
|
const fetchPlaylists = useCallback((selectedPlaylist: PlaylistOption) => { |
||||||
|
if (!selectedPlaylist) return |
||||||
|
|
||||||
|
if (selectedPlaylist.type === PlaylistTypes.PLAYER) { |
||||||
|
getPlayerPlaylists({ |
||||||
|
matchId, |
||||||
|
playerId: selectedPlaylist.id, |
||||||
|
sportType, |
||||||
|
}).then(setPlaylistData) |
||||||
|
} else if (selectedPlaylist.type === PlaylistTypes.MATCH) { |
||||||
|
setPlaylistData(selectedPlaylist.data) |
||||||
|
} |
||||||
|
}, [matchId, sportType]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (initialSelectedPlaylist?.type === PlaylistTypes.MATCH) { |
||||||
|
fetchPlaylists(initialSelectedPlaylist) |
||||||
|
} |
||||||
|
}, [initialSelectedPlaylist, fetchPlaylists]) |
||||||
|
|
||||||
|
return { fetchPlaylists, playlistData } |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { useEffect, useState } from 'react' |
||||||
|
|
||||||
|
import isEmpty from 'lodash/isEmpty' |
||||||
|
|
||||||
|
import { getMatchPlaylists } from 'requests' |
||||||
|
|
||||||
|
import { usePageId, useSportNameParam } from 'hooks' |
||||||
|
|
||||||
|
import type { PlaylistOption, Playlists } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
import { buildPlaylists } from '../helpers/buildPlaylists' |
||||||
|
import { useRouteState } from './useRouteState' |
||||||
|
import { usePlaylistsData } from './usePlaylistData' |
||||||
|
|
||||||
|
const initialPlaylists: Playlists = { |
||||||
|
interview: [], |
||||||
|
match: [], |
||||||
|
players: { |
||||||
|
team1: [], |
||||||
|
team2: [], |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
export const usePlaylists = (isFinishedMatch: boolean) => { |
||||||
|
const { initialSelectedPlaylist } = useRouteState() |
||||||
|
const [playlists, setPlaylists] = useState<Playlists>(initialPlaylists) |
||||||
|
const [selectedPlaylist, setSelectedPlaylist] = useState(initialSelectedPlaylist) |
||||||
|
const { sportType } = useSportNameParam() |
||||||
|
const matchId = usePageId() |
||||||
|
|
||||||
|
const { |
||||||
|
fetchPlaylists, |
||||||
|
playlistData, |
||||||
|
} = usePlaylistsData() |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (isFinishedMatch) { |
||||||
|
getMatchPlaylists({ |
||||||
|
matchId, |
||||||
|
selectedActions: [1, 2, 3], |
||||||
|
sportType, |
||||||
|
}).then(buildPlaylists) |
||||||
|
.then(setPlaylists) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
isFinishedMatch, |
||||||
|
matchId, |
||||||
|
sportType, |
||||||
|
]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!selectedPlaylist && !isEmpty(playlists.match)) { |
||||||
|
setSelectedPlaylist(playlists.match[0]) |
||||||
|
} |
||||||
|
}, [selectedPlaylist, playlists]) |
||||||
|
|
||||||
|
const onPlaylistSelect = (option: PlaylistOption) => { |
||||||
|
if (option === selectedPlaylist) return |
||||||
|
setSelectedPlaylist(option) |
||||||
|
fetchPlaylists(option) |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
onPlaylistSelect, |
||||||
|
playlistData, |
||||||
|
playlists, |
||||||
|
selectedPlaylist, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
import { useEffect } from 'react' |
||||||
|
import { useHistory, useLocation } from 'react-router' |
||||||
|
|
||||||
|
import type { PlaylistData } from 'requests' |
||||||
|
|
||||||
|
import type { PlaylistOption } from 'features/MatchPage/types' |
||||||
|
|
||||||
|
export type RouteState = { |
||||||
|
/** |
||||||
|
* Данные плейлиста если был выбран игрок |
||||||
|
*/ |
||||||
|
playlistData?: PlaylistData, |
||||||
|
|
||||||
|
/** |
||||||
|
* Выбранный плейлист на попапе матчей |
||||||
|
*/ |
||||||
|
selectedPlaylist: PlaylistOption, |
||||||
|
} |
||||||
|
|
||||||
|
export const useRouteState = () => { |
||||||
|
// считываем стейт из роутера
|
||||||
|
// если есть стейт то на этот матч мы переходили
|
||||||
|
// из попапа матчей и статус матча Завершенный
|
||||||
|
// и запрос на получение ссылки лив матча можно пропустить
|
||||||
|
const { state } = useLocation<RouteState | undefined>() |
||||||
|
const history = useHistory() |
||||||
|
|
||||||
|
useEffect(() => () => { |
||||||
|
// сбрасываем роут стейт
|
||||||
|
history.replace(history.location.pathname) |
||||||
|
}, [history]) |
||||||
|
|
||||||
|
return { |
||||||
|
initialPlaylistData: state?.playlistData, |
||||||
|
initialSelectedPlaylist: state?.selectedPlaylist, |
||||||
|
} |
||||||
|
} |
||||||
@ -1,40 +0,0 @@ |
|||||||
import { |
|
||||||
useCallback, |
|
||||||
useEffect, |
|
||||||
useState, |
|
||||||
} from 'react' |
|
||||||
|
|
||||||
import type { LiveVideos, Videos } from 'requests' |
|
||||||
import { getLiveVideos, getVideos } from 'requests' |
|
||||||
|
|
||||||
import { useSportNameParam, usePageId } from 'hooks' |
|
||||||
|
|
||||||
import { useLastPlayPosition } from './useLastPlayPosition' |
|
||||||
|
|
||||||
export const useVideoData = () => { |
|
||||||
const [videos, setVideos] = useState<Videos>([]) |
|
||||||
const [liveVideos, setLiveVideos] = useState<LiveVideos>([]) |
|
||||||
const { sportType } = useSportNameParam() |
|
||||||
const matchId = usePageId() |
|
||||||
|
|
||||||
const fetchMatchVideos = useCallback(() => { |
|
||||||
getVideos(sportType, matchId).then(setVideos) |
|
||||||
}, [sportType, matchId]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
getLiveVideos(sportType, matchId) |
|
||||||
.then(setLiveVideos) |
|
||||||
.catch(fetchMatchVideos) |
|
||||||
}, |
|
||||||
[ |
|
||||||
sportType, |
|
||||||
matchId, |
|
||||||
fetchMatchVideos, |
|
||||||
]) |
|
||||||
|
|
||||||
return { |
|
||||||
url: liveVideos[0] || '', |
|
||||||
videos, |
|
||||||
...useLastPlayPosition(), |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import type { PlaylistData } from 'requests' |
||||||
|
|
||||||
|
export enum PlaylistTypes { |
||||||
|
MATCH, |
||||||
|
PLAYER, |
||||||
|
INTERVIEW, |
||||||
|
} |
||||||
|
|
||||||
|
export type MatchPlaylistOption = { |
||||||
|
data: PlaylistData, |
||||||
|
duration?: number, |
||||||
|
id: string, |
||||||
|
lexic: string, |
||||||
|
type: PlaylistTypes.MATCH, |
||||||
|
} |
||||||
|
|
||||||
|
export type PlayerPlaylistOption = { |
||||||
|
id: number, |
||||||
|
name_eng: string, |
||||||
|
name_rus: string, |
||||||
|
type: PlaylistTypes.PLAYER, |
||||||
|
} |
||||||
|
|
||||||
|
export type InterviewPlaylistOption = { |
||||||
|
id: number, |
||||||
|
name_eng: string, |
||||||
|
name_rus: string, |
||||||
|
type: PlaylistTypes.INTERVIEW, |
||||||
|
} |
||||||
|
|
||||||
|
export type PlaylistOption = ( |
||||||
|
MatchPlaylistOption |
||||||
|
| PlayerPlaylistOption |
||||||
|
| InterviewPlaylistOption |
||||||
|
) |
||||||
|
|
||||||
|
export type InterviewPlaylistOptions = Array<InterviewPlaylistOption> |
||||||
|
export type MatchPlaylistOptions = Array<MatchPlaylistOption> |
||||||
|
export type PlayerPlaylistOptions = Array<PlayerPlaylistOption> |
||||||
|
|
||||||
|
export type Playlists = { |
||||||
|
interview: InterviewPlaylistOptions, |
||||||
|
match: MatchPlaylistOptions, |
||||||
|
players: { |
||||||
|
team1: PlayerPlaylistOptions, |
||||||
|
team2: PlayerPlaylistOptions, |
||||||
|
}, |
||||||
|
} |
||||||
@ -1,4 +0,0 @@ |
|||||||
export enum Teams { |
|
||||||
TEAM1, |
|
||||||
TEAM2, |
|
||||||
} |
|
||||||
@ -0,0 +1,41 @@ |
|||||||
|
import { useCallback } from 'react' |
||||||
|
import { useHistory } from 'react-router' |
||||||
|
|
||||||
|
import { ProfileTypes } from 'config' |
||||||
|
import { getPlayerPlaylists } from 'requests' |
||||||
|
|
||||||
|
import type { PlayerPlaylistOption } from 'features/MatchPage/types' |
||||||
|
import type { MatchData, Settings } from 'features/MatchPopup/types' |
||||||
|
import type { RouteState } from 'features/MatchPage/hooks/useRouteState' |
||||||
|
import { getProfileUrl } from 'features/ProfileLink/helpers' |
||||||
|
|
||||||
|
export const usePlayerClickHandler = (match: MatchData, settings: Settings) => { |
||||||
|
const history = useHistory() |
||||||
|
const handlePlayerClick = useCallback(async (player: PlayerPlaylistOption) => { |
||||||
|
if (!match) return |
||||||
|
const playlistData = await getPlayerPlaylists({ |
||||||
|
matchId: match.id, |
||||||
|
playerId: player.id, |
||||||
|
settings, |
||||||
|
sportType: match.sportType, |
||||||
|
}) |
||||||
|
|
||||||
|
const matchLink = getProfileUrl({ |
||||||
|
id: match.id, |
||||||
|
profileType: ProfileTypes.MATCHES, |
||||||
|
sportType: match.sportType, |
||||||
|
}) |
||||||
|
|
||||||
|
const routeState: RouteState = { |
||||||
|
playlistData, |
||||||
|
selectedPlaylist: player, |
||||||
|
} |
||||||
|
history.push(matchLink, routeState) |
||||||
|
}, [ |
||||||
|
match, |
||||||
|
settings, |
||||||
|
history, |
||||||
|
]) |
||||||
|
|
||||||
|
return { handlePlayerClick } |
||||||
|
} |
||||||
@ -1,36 +0,0 @@ |
|||||||
import { calculateChapterProgress } from '..' |
|
||||||
|
|
||||||
it('calculates zero chapter progress for zero progress or less than chapter start ms', () => { |
|
||||||
const chapter = { |
|
||||||
duration: 10000, |
|
||||||
endMs: 15000, |
|
||||||
period: '', |
|
||||||
startMs: 5000, |
|
||||||
url: '', |
|
||||||
} |
|
||||||
expect(calculateChapterProgress(0, chapter)).toBe(0) |
|
||||||
expect(calculateChapterProgress(4999, chapter)).toBe(0) |
|
||||||
}) |
|
||||||
|
|
||||||
it('calculates half chapter progress', () => { |
|
||||||
const chapter = { |
|
||||||
duration: 10000, |
|
||||||
endMs: 10000, |
|
||||||
period: '', |
|
||||||
startMs: 0, |
|
||||||
url: '', |
|
||||||
} |
|
||||||
expect(calculateChapterProgress(5000, chapter)).toBe(50) |
|
||||||
}) |
|
||||||
|
|
||||||
it('calculates full chapter progress for full progress or more than chapter end ms', () => { |
|
||||||
const chapter = { |
|
||||||
duration: 10000, |
|
||||||
endMs: 10000, |
|
||||||
period: '', |
|
||||||
startMs: 0, |
|
||||||
url: '', |
|
||||||
} |
|
||||||
expect(calculateChapterProgress(10000, chapter)).toBe(100) |
|
||||||
expect(calculateChapterProgress(99999, chapter)).toBe(100) |
|
||||||
}) |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
import type { Chapter } from 'features/MultiSourcePlayer/types' |
|
||||||
|
|
||||||
export const calculateChapterProgress = (progress: number, chapter: Chapter) => { |
|
||||||
if (progress <= chapter.startMs) return 0 |
|
||||||
if (progress >= chapter.endMs) return 100 |
|
||||||
const progressInChapter = progress - chapter.startMs |
|
||||||
return progressInChapter * 100 / chapter.duration |
|
||||||
} |
|
||||||
@ -0,0 +1,42 @@ |
|||||||
|
import { useCallback } from 'react' |
||||||
|
|
||||||
|
import { SetPartialState } from 'hooks' |
||||||
|
|
||||||
|
import { PlayerState } from '.' |
||||||
|
|
||||||
|
export const usePlayingHandlers = (setPlayerState: SetPartialState<PlayerState>) => { |
||||||
|
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]) |
||||||
|
|
||||||
|
return { |
||||||
|
onReady, |
||||||
|
startPlaying, |
||||||
|
stopPlaying, |
||||||
|
togglePlaying, |
||||||
|
} |
||||||
|
} |
||||||
@ -1,35 +0,0 @@ |
|||||||
import { useState, useCallback } from 'react' |
|
||||||
|
|
||||||
export const usePlayingState = () => { |
|
||||||
const [ready, setReady] = useState(false) |
|
||||||
const [playing, setPlaying] = useState(false) |
|
||||||
|
|
||||||
const onReady = useCallback(() => { |
|
||||||
if (ready) return |
|
||||||
|
|
||||||
setReady(true) |
|
||||||
setPlaying(true) |
|
||||||
}, [ready]) |
|
||||||
|
|
||||||
const togglePlaying = useCallback(() => { |
|
||||||
setPlaying((isPlaying) => (ready ? !isPlaying : false)) |
|
||||||
}, [ready]) |
|
||||||
|
|
||||||
const stopPlaying = useCallback(() => { |
|
||||||
setPlaying(false) |
|
||||||
}, []) |
|
||||||
|
|
||||||
const startPlaying = useCallback(() => { |
|
||||||
if (ready) { |
|
||||||
setPlaying(true) |
|
||||||
} |
|
||||||
}, [ready]) |
|
||||||
|
|
||||||
return { |
|
||||||
onReady, |
|
||||||
playing, |
|
||||||
startPlaying, |
|
||||||
stopPlaying, |
|
||||||
togglePlaying, |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,51 +1,48 @@ |
|||||||
import type { MutableRefObject } from 'react' |
|
||||||
import { useCallback } from 'react' |
import { useCallback } from 'react' |
||||||
|
|
||||||
|
import type { SetPartialState } from 'hooks' |
||||||
|
|
||||||
import type { Chapters } from '../types' |
import type { Chapters } from '../types' |
||||||
|
import type { PlayerState } from '.' |
||||||
import { findChapterByProgress } from '../helpers' |
import { findChapterByProgress } from '../helpers' |
||||||
|
|
||||||
type Args = { |
type Args = { |
||||||
activeChapterIndex: MutableRefObject<number>, |
activeChapterIndex: number, |
||||||
chapters: Chapters, |
chapters: Chapters, |
||||||
duration: number, |
duration: number, |
||||||
setPlayedProgress: (value: number) => void, |
setPlayerState: SetPartialState<PlayerState>, |
||||||
setSeek: (value: number) => void, |
|
||||||
} |
} |
||||||
|
|
||||||
export const useProgressChangeHandler = ({ |
export const useProgressChangeHandler = ({ |
||||||
activeChapterIndex, |
activeChapterIndex, |
||||||
chapters, |
chapters, |
||||||
duration, |
duration, |
||||||
setPlayedProgress, |
setPlayerState, |
||||||
setSeek, |
|
||||||
}: Args) => { |
}: Args) => { |
||||||
const onProgressChange = useCallback((progress: number) => { |
const onProgressChange = useCallback((progress: number, seeking: boolean) => { |
||||||
// значение новой позиции ползунка в миллисекундах
|
// значение новой позиции ползунка в миллисекундах
|
||||||
const progressMs = progress * duration |
const progressMs = progress * duration |
||||||
const chapterIndex = findChapterByProgress(chapters, progressMs / 1000) |
const chapterIndex = findChapterByProgress(chapters, progressMs) |
||||||
const chapter = chapters[chapterIndex] |
const chapter = chapters[chapterIndex] |
||||||
const isProgressOnDifferentChapter = ( |
const isProgressOnDifferentChapter = ( |
||||||
chapterIndex !== -1 |
chapterIndex !== -1 |
||||||
&& chapterIndex !== activeChapterIndex.current |
&& chapterIndex !== activeChapterIndex |
||||||
) |
) |
||||||
// если ползунок остановили на другой главе
|
const nextChapter = isProgressOnDifferentChapter |
||||||
if (isProgressOnDifferentChapter) { |
? chapterIndex |
||||||
// eslint-disable-next-line no-param-reassign
|
: activeChapterIndex |
||||||
activeChapterIndex.current = chapterIndex |
|
||||||
} |
|
||||||
setPlayedProgress(progressMs) |
|
||||||
|
|
||||||
// отнимаем начало главы на котором остановились от общего прогресса
|
// отнимаем начало главы на котором остановились от общего прогресса
|
||||||
// чтобы получить прогресс текущей главы
|
// чтобы получить прогресс текущей главы
|
||||||
const chapterProgressSec = (progressMs - chapter.startMs) / 1000 |
const chapterProgressMs = (progressMs - chapter.startMs) |
||||||
setSeek(chapterProgressSec) |
const seekMs = chapterProgressMs + chapter.startOffsetMs |
||||||
}, [ |
setPlayerState({ |
||||||
chapters, |
activeChapterIndex: nextChapter, |
||||||
duration, |
playedProgress: chapterProgressMs, |
||||||
setPlayedProgress, |
seek: seekMs / 1000, |
||||||
setSeek, |
seeking, |
||||||
activeChapterIndex, |
}) |
||||||
]) |
}, [duration, chapters, activeChapterIndex, setPlayerState]) |
||||||
|
|
||||||
return onProgressChange |
return onProgressChange |
||||||
} |
} |
||||||
|
|||||||
@ -1,83 +0,0 @@ |
|||||||
import { useMemo } from 'react' |
|
||||||
|
|
||||||
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 type { Videos } from 'requests' |
|
||||||
|
|
||||||
import type { Chapters, Urls } from '../types' |
|
||||||
|
|
||||||
const getUniquePeriods = (videos: Videos) => uniq(map(videos, '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 getChapters = (videos: Array<Video>) => { |
|
||||||
const sortedVideos = orderBy(videos, 'period') |
|
||||||
return reduce( |
|
||||||
sortedVideos, |
|
||||||
(acc: Chapters, video) => { |
|
||||||
const prevVideoEndMs = last(acc)?.endMs || 0 |
|
||||||
const nextChapter = { |
|
||||||
duration: video.duration, |
|
||||||
endMs: prevVideoEndMs + video.duration, |
|
||||||
period: video.period, |
|
||||||
startMs: prevVideoEndMs, |
|
||||||
urls: video.urls, |
|
||||||
} |
|
||||||
return concat(acc, nextChapter) |
|
||||||
}, |
|
||||||
[], |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
const buildChapters = (videos: Videos) => { |
|
||||||
const periods = getUniquePeriods(videos) |
|
||||||
const highQualityVideos = getVideoByPeriods(videos, periods) |
|
||||||
return getChapters(highQualityVideos) |
|
||||||
} |
|
||||||
|
|
||||||
export const useVideos = (videos: Videos) => { |
|
||||||
const chapters = useMemo(() => buildChapters(videos), [videos]) |
|
||||||
return { chapters } |
|
||||||
} |
|
||||||
@ -0,0 +1,36 @@ |
|||||||
|
import type { Dispatch, SetStateAction } from 'react' |
||||||
|
import { useCallback, useState } from 'react' |
||||||
|
|
||||||
|
import isFunction from 'lodash/isFunction' |
||||||
|
|
||||||
|
type Updater<T> = (prevState: T) => T |
||||||
|
const isUpdater = <T extends object>(value: any): value is Updater<T> => ( |
||||||
|
isFunction(value) |
||||||
|
) |
||||||
|
|
||||||
|
/** |
||||||
|
* Дженерик для создания типа сеттера, получает тип стейта как параметр |
||||||
|
*/ |
||||||
|
export type SetPartialState<T> = Dispatch<SetStateAction<Partial<T>>> |
||||||
|
|
||||||
|
/** |
||||||
|
* Хук на основе useState с возможносьтю мержа стейта как в классовых компонентах |
||||||
|
*/ |
||||||
|
export const useObjectState = <T extends object>(initialState: T) => { |
||||||
|
const [state, setState] = useState(initialState) |
||||||
|
|
||||||
|
const setPlayerState: SetPartialState<T> = useCallback((newStateOrUpdater) => { |
||||||
|
setState((oldState) => { |
||||||
|
const newState = isUpdater(newStateOrUpdater) |
||||||
|
? newStateOrUpdater(oldState) |
||||||
|
: newStateOrUpdater |
||||||
|
// если updater функция вернула старый стейт то и здесь
|
||||||
|
// возвращаем тот же стейт чтобы избежать лишнего ререндера
|
||||||
|
if (newState === oldState) return oldState |
||||||
|
|
||||||
|
return { ...oldState, ...newState } |
||||||
|
}) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return [state, setPlayerState] as const |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
import { |
||||||
|
DATA_URL, |
||||||
|
PROCEDURES, |
||||||
|
SportTypes, |
||||||
|
} from 'config' |
||||||
|
|
||||||
|
import { callApi, getSportLexic } from 'helpers' |
||||||
|
|
||||||
|
import type { Settings } from 'features/MatchPopup/types' |
||||||
|
import { defaultSettings } from 'features/MatchPopup/types' |
||||||
|
|
||||||
|
import type { PlaylistData } from './getMatchPlaylists' |
||||||
|
|
||||||
|
const proc = PROCEDURES.ott_match_popup_player_playlist |
||||||
|
|
||||||
|
type Args = { |
||||||
|
matchId: number, |
||||||
|
playerId: number, |
||||||
|
settings?: Settings, |
||||||
|
sportType: SportTypes, |
||||||
|
} |
||||||
|
|
||||||
|
type Response = { |
||||||
|
data?: PlaylistData, |
||||||
|
} |
||||||
|
|
||||||
|
export const getPlayerPlaylists = async ({ |
||||||
|
matchId, |
||||||
|
playerId, |
||||||
|
settings = defaultSettings, |
||||||
|
sportType, |
||||||
|
}: Args) => { |
||||||
|
const config = { |
||||||
|
body: { |
||||||
|
params: { |
||||||
|
_p_actions: settings.selectedActions, |
||||||
|
_p_match_id: matchId, |
||||||
|
_p_offset_end: settings.episodeDuration.after, |
||||||
|
_p_offset_start: settings.episodeDuration.before, |
||||||
|
_p_player_id: playerId, |
||||||
|
_p_type: settings.selectedFormat, |
||||||
|
}, |
||||||
|
proc, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
const response: Response = await callApi({ |
||||||
|
config, |
||||||
|
url: `${DATA_URL}/${getSportLexic(sportType)}`, |
||||||
|
}) |
||||||
|
|
||||||
|
return response?.data || [] |
||||||
|
} |
||||||
Loading…
Reference in new issue