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 type { SetPartialState } from 'hooks' |
||||
|
||||
import type { Chapters } from '../types' |
||||
import type { PlayerState } from '.' |
||||
import { findChapterByProgress } from '../helpers' |
||||
|
||||
type Args = { |
||||
activeChapterIndex: MutableRefObject<number>, |
||||
activeChapterIndex: number, |
||||
chapters: Chapters, |
||||
duration: number, |
||||
setPlayedProgress: (value: number) => void, |
||||
setSeek: (value: number) => void, |
||||
setPlayerState: SetPartialState<PlayerState>, |
||||
} |
||||
|
||||
export const useProgressChangeHandler = ({ |
||||
activeChapterIndex, |
||||
chapters, |
||||
duration, |
||||
setPlayedProgress, |
||||
setSeek, |
||||
setPlayerState, |
||||
}: Args) => { |
||||
const onProgressChange = useCallback((progress: number) => { |
||||
const onProgressChange = useCallback((progress: number, seeking: boolean) => { |
||||
// значение новой позиции ползунка в миллисекундах
|
||||
const progressMs = progress * duration |
||||
const chapterIndex = findChapterByProgress(chapters, progressMs / 1000) |
||||
const chapterIndex = findChapterByProgress(chapters, progressMs) |
||||
const chapter = chapters[chapterIndex] |
||||
const isProgressOnDifferentChapter = ( |
||||
chapterIndex !== -1 |
||||
&& chapterIndex !== activeChapterIndex.current |
||||
&& chapterIndex !== activeChapterIndex |
||||
) |
||||
// если ползунок остановили на другой главе
|
||||
if (isProgressOnDifferentChapter) { |
||||
// eslint-disable-next-line no-param-reassign
|
||||
activeChapterIndex.current = chapterIndex |
||||
} |
||||
setPlayedProgress(progressMs) |
||||
const nextChapter = isProgressOnDifferentChapter |
||||
? chapterIndex |
||||
: activeChapterIndex |
||||
|
||||
// отнимаем начало главы на котором остановились от общего прогресса
|
||||
// чтобы получить прогресс текущей главы
|
||||
const chapterProgressSec = (progressMs - chapter.startMs) / 1000 |
||||
setSeek(chapterProgressSec) |
||||
}, [ |
||||
chapters, |
||||
duration, |
||||
setPlayedProgress, |
||||
setSeek, |
||||
activeChapterIndex, |
||||
]) |
||||
const chapterProgressMs = (progressMs - chapter.startMs) |
||||
const seekMs = chapterProgressMs + chapter.startOffsetMs |
||||
setPlayerState({ |
||||
activeChapterIndex: nextChapter, |
||||
playedProgress: chapterProgressMs, |
||||
seek: seekMs / 1000, |
||||
seeking, |
||||
}) |
||||
}, [duration, chapters, activeChapterIndex, setPlayerState]) |
||||
|
||||
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