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
Mirlan 5 years ago committed by GitHub
parent 58deedf2d9
commit 227e983314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      src/config/lexics/indexLexics.tsx
  2. 1
      src/config/procedures.tsx
  3. 2
      src/config/profileTypes.tsx
  4. 0
      src/features/MatchPage/components/MatchProfileCard/index.tsx
  5. 6
      src/features/MatchPage/components/MatchProfileCard/styled.tsx
  6. 121
      src/features/MatchPage/helpers/buildChapters.tsx
  7. 53
      src/features/MatchPage/helpers/buildPlaylists.tsx
  8. 35
      src/features/MatchPage/hooks/useChapters.tsx
  9. 49
      src/features/MatchPage/hooks/useMatchPage.tsx
  10. 42
      src/features/MatchPage/hooks/usePlaylistData.tsx
  11. 69
      src/features/MatchPage/hooks/usePlaylists.tsx
  12. 37
      src/features/MatchPage/hooks/useRouteState.tsx
  13. 40
      src/features/MatchPage/hooks/useVideoData.tsx
  14. 33
      src/features/MatchPage/index.tsx
  15. 14
      src/features/MatchPage/styled.tsx
  16. 48
      src/features/MatchPage/types.tsx
  17. 2
      src/features/MatchPopup/components/EpisodeDurationInputs/hooks.tsx
  18. 56
      src/features/MatchPopup/components/MatchPlaylist/index.tsx
  19. 2
      src/features/MatchPopup/components/PlayerActions/index.tsx
  20. 54
      src/features/MatchPopup/components/PlayersList/index.tsx
  21. 28
      src/features/MatchPopup/components/PlayersList/styled.tsx
  22. 12
      src/features/MatchPopup/components/PlayersListDesktop/index.tsx
  23. 11
      src/features/MatchPopup/components/PlayersListMobile/index.tsx
  24. 37
      src/features/MatchPopup/components/PlaylistButton/index.tsx
  25. 3
      src/features/MatchPopup/components/PlaylistPage/index.tsx
  26. 4
      src/features/MatchPopup/config.tsx
  27. 21
      src/features/MatchPopup/store/hooks/index.tsx
  28. 41
      src/features/MatchPopup/store/hooks/usePlayerClickHandler.tsx
  29. 32
      src/features/MatchPopup/store/hooks/useSettingsState.tsx
  30. 24
      src/features/MatchPopup/types.tsx
  31. 36
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterProgress/__tests__/index.tsx
  32. 8
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterProgress/index.tsx
  33. 8
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterStyles/__tests__/index.tsx
  34. 47
      src/features/MultiSourcePlayer/components/ProgressBar/helpers/calculateChapterStyles/index.tsx
  35. 13
      src/features/MultiSourcePlayer/components/ProgressBar/hooks.tsx
  36. 12
      src/features/MultiSourcePlayer/components/ProgressBar/index.tsx
  37. 7
      src/features/MultiSourcePlayer/components/ProgressBar/stories.tsx
  38. 4
      src/features/MultiSourcePlayer/helpers/index.tsx
  39. 115
      src/features/MultiSourcePlayer/hooks/index.tsx
  40. 42
      src/features/MultiSourcePlayer/hooks/usePlayingHandlers.tsx
  41. 35
      src/features/MultiSourcePlayer/hooks/usePlayingState.tsx
  42. 45
      src/features/MultiSourcePlayer/hooks/useProgressChangeHandler.tsx
  43. 13
      src/features/MultiSourcePlayer/hooks/useVideoQuality.tsx
  44. 83
      src/features/MultiSourcePlayer/hooks/useVideos.tsx
  45. 3
      src/features/MultiSourcePlayer/index.tsx
  46. 1
      src/features/MultiSourcePlayer/types.tsx
  47. 22
      src/features/StreamPlayer/hooks/useSlider.tsx
  48. 1
      src/features/StreamPlayer/index.tsx
  49. 10
      src/features/VideoPlayer/hooks/index.tsx
  50. 8
      src/helpers/getProfileFallbackLogo/index.tsx
  51. 1
      src/hooks/index.tsx
  52. 36
      src/hooks/useObjectState.tsx
  53. 23
      src/requests/getMatchPlaylists.tsx
  54. 53
      src/requests/getPlayerPlaylists.tsx
  55. 1
      src/requests/index.tsx
  56. 2
      src/requests/search.tsx

@ -6,7 +6,7 @@ const matchPopupLexics = {
go_back_to_match: 13405,
match_interviews: 13031,
match_playlist_ball_in_play: 2489,
match_playlist_full_game: 13028,
match_playlist_full_match: 13028,
match_playlist_goals: 3559,
match_playlist_highlights: 13033,
match_settings: 13490,

@ -22,6 +22,7 @@ export const PROCEDURES = {
lst_c_country: 'lst_c_country',
ott_match_popup: 'ott_match_popup',
ott_match_popup_actions: 'ott_match_popup_actions',
ott_match_popup_player_playlist: 'ott_match_popup_player_playlist',
param_lexical: 'param_lexical',
save_user_custom_subscription: 'save_user_custom_subscription',
save_user_favorite: 'save_user_favorite',

@ -2,10 +2,12 @@ export enum ProfileTypes {
TOURNAMENTS = 1,
TEAMS = 2,
PLAYERS = 3,
MATCHES = 4,
}
export const PROFILE_NAMES = {
[ProfileTypes.TOURNAMENTS]: 'tournaments',
[ProfileTypes.TEAMS]: 'teams',
[ProfileTypes.PLAYERS]: 'players',
[ProfileTypes.MATCHES]: 'matches',
} as const

@ -10,14 +10,15 @@ export const Wrapper = styled.div`
line-height: 24px;
color: white;
min-height: 28px;
max-width: 85%;
width: 100%;
margin-bottom: 14px;
@media ${devices.desktop} {
font-size: 22px;
}
@media ${devices.laptop} {
font-size: 18px;
max-width: 80%;
}
@media ${devices.tablet} {
@ -25,6 +26,7 @@ export const Wrapper = styled.div`
font-size: 16px;
padding: 15px 20px 0 20px;
flex-wrap: wrap;
margin-bottom: 0;
}
`

@ -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(),
}
}

@ -1,44 +1,34 @@
import { useEffect, useRef } from 'react'
import isEmpty from 'lodash/isEmpty'
import { StreamPlayer } from 'features/StreamPlayer'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { ProfileHeader } from 'features/ProfileHeader'
import { MainWrapper } from 'features/MainWrapper'
import { UserFavorites } from 'features/UserFavorites'
import { MediaQuery } from 'features/MediaQuery'
import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen'
import { MatchProfileCard } from './MatchProfileCard'
import { usePlayerProgressReporter } from './hooks/usePlayerProgressReporter'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { StreamPlayer } from 'features/StreamPlayer'
import { MatchProfileCard } from './components/MatchProfileCard'
import { useMatchProfile } from './hooks/useMatchProfile'
import { useVideoData } from './hooks/useVideoData'
import {
MainWrapper as Wrapper,
Container,
EmptyScrollTarget,
} from './styled'
import { useMatchPage } from './hooks/useMatchPage'
import { usePlayerProgressReporter } from './hooks/usePlayerProgressReporter'
export const MatchPage = () => {
const profile = useMatchProfile()
const {
chapters,
isLastPlayPositionFetching,
lastPlayPosition,
url,
videos,
} = useVideoData()
} = useMatchPage()
const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter()
const isLiveMatch = Boolean(url) && !isLastPlayPositionFetching
const isFinishedMatch = !isEmpty(videos) && !isLastPlayPositionFetching
const { onFullscreenClick } = useFullscreen()
const playerEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
playerEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [onFullscreenClick])
const isFinishedMatch = !isEmpty(chapters) && !isLastPlayPositionFetching
return (
<MainWrapper>
@ -47,8 +37,8 @@ export const MatchPage = () => {
</MediaQuery>
<ProfileHeader />
<Wrapper>
<MatchProfileCard profile={profile} />
<Container>
<MatchProfileCard profile={profile} />
{
isLiveMatch && (
<StreamPlayer
@ -62,7 +52,7 @@ export const MatchPage = () => {
{
isFinishedMatch && (
<MultiSourcePlayer
videos={videos}
chapters={chapters}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
resumeFrom={lastPlayPosition}
@ -71,7 +61,6 @@ export const MatchPage = () => {
}
</Container>
</Wrapper>
<EmptyScrollTarget ref={playerEndRef} />
</MainWrapper>
)
}

@ -1,26 +1,27 @@
import styled from 'styled-components/macro'
import { devices } from 'config/devices'
export const MainWrapper = styled.div`
margin: 63px 16px 0 16px;
display: flex;
@media ${devices.laptop} {
margin: 0px 16px;
}
@media ${devices.tablet} {
display: flex;
flex-direction: column;
margin: 0 0 16px 0;
}
`
export const Container = styled.div`
max-width: 85%;
max-width: 2360px;
max-height: 896px;
margin-top: 14px;
display: flex;
flex-direction: column;
flex-grow: 1;
@media ${devices.laptop} {
max-width: 80%;
@ -28,14 +29,9 @@ export const Container = styled.div`
@media ${devices.tablet} {
order: 1;
margin-top: 0;
}
@media ${devices.mobile} {
max-width: 100%;
}
`
export const EmptyScrollTarget = styled.div`
@media ${devices.tablet} {
display: none;
}
`

@ -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,
},
}

@ -3,7 +3,7 @@ import { useState } from 'react'
import isNaN from 'lodash/isNaN'
import type { EpisodeDuration } from '../../store/hooks/useSettingsState'
import type { EpisodeDuration } from '../../types'
const LIMITS = {
max: 30,

@ -1,10 +1,10 @@
import { useLocation } from 'react-router-dom'
import styled from 'styled-components/macro'
import { devices, PAGES } from 'config'
import { getSportLexic } from 'helpers'
import map from 'lodash/map'
import { devices, ProfileTypes } from 'config'
import { getProfileUrl } from 'features/ProfileLink/helpers'
import { useMatchPopupStore } from 'features/MatchPopup/store'
import { PlaylistButton } from '../PlaylistButton'
@ -36,45 +36,27 @@ const Item = styled.li`
`
export const MatchPlaylist = () => {
const { pathname, search } = useLocation()
const { match, matchPlaylists } = useMatchPopupStore()
if (!match || !matchPlaylists) return null
// не меняем url при клике ссылки временно
// до добавления плейлистов для голов, обзоров
const currentRoute = `${pathname}${search}`
const sport = getSportLexic(match.sportType)
const matchLink = getProfileUrl({
id: match.id,
profileType: ProfileTypes.MATCHES,
sportType: match.sportType,
})
return (
<List>
<Item>
<PlaylistButton
to={`${sport}${PAGES.match}/${match.id}`}
title='match_playlist_full_game'
duration={matchPlaylists.fullMatchDuration}
/>
</Item>
<Item>
<PlaylistButton
to={currentRoute}
title='match_playlist_highlights'
duration={matchPlaylists.highlights.dur}
/>
</Item>
<Item>
<PlaylistButton
to={currentRoute}
title='match_playlist_ball_in_play'
duration={matchPlaylists.ball_in_play.dur}
/>
</Item>
<Item>
<PlaylistButton
to={currentRoute}
title='match_playlist_goals'
duration={matchPlaylists.goals.dur}
/>
</Item>
{
map(matchPlaylists.match, (playlist) => (
<Item key={playlist.id}>
<PlaylistButton
to={matchLink}
playlist={playlist}
/>
</Item>
))
}
</List>
)
}

@ -7,7 +7,7 @@ import type { Actions } from 'requests'
import { T9n } from 'features/T9n'
import { BlockTitle } from 'features/MatchPopup/styled'
import type { SelectedActions } from '../../store/hooks/useSettingsState'
import type { SelectedActions } from '../../types'
import {
Wrapper,
Checkbox,

@ -2,7 +2,8 @@ import map from 'lodash/map'
import { ProfileTypes, SportTypes } from 'config'
import type { Players } from 'requests'
import { PlayerPlaylistOptions, PlayerPlaylistOption } from 'features/MatchPage/types'
import { useMatchPopupStore } from 'features/MatchPopup/store'
import { Teams } from '../../types'
import {
@ -10,37 +11,42 @@ import {
Item,
Logo,
PlayerName,
Button,
} from './styled'
type Props = {
players: Players,
onClick: (player: PlayerPlaylistOption) => void,
players: PlayerPlaylistOptions,
sportType: SportTypes,
team: Teams,
}
export const PlayersList = ({
onClick,
players,
sportType,
team,
}: Props) => (
<List team={team}>
{
map(players, (player) => (
<Item key={player.id}>
<Logo
id={player.id}
sportType={sportType}
profileType={ProfileTypes.PLAYERS}
team={team}
/>
<PlayerName
nameObj={{
name_eng: `${player.num} ${player.name_eng}`,
name_rus: `${player.num} ${player.name_rus}`,
}}
/>
</Item>
))
}
</List>
)
}: Props) => {
const { match } = useMatchPopupStore()
if (!match) return null
return (
<List team={team}>
{
map(players, (player) => (
<Item key={player.id}>
<Button onClick={() => onClick(player)}>
<Logo
id={player.id}
sportType={sportType}
profileType={ProfileTypes.PLAYERS}
team={team}
/>
<PlayerName nameObj={player} />
</Button>
</Item>
))
}
</List>
)
}

@ -30,17 +30,10 @@ export const List = styled.ul<ListProps>`
}
`
export const Item = styled.li.attrs(() => ({
tabIndex: 0,
}))`
export const Item = styled.li`
width: 76px;
height: 95px;
margin: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
cursor: pointer;
@media ${devices.mobile} {
width: 100%;
@ -52,6 +45,24 @@ export const Item = styled.li.attrs(() => ({
}
`
export const Button = styled.button`
border: none;
background: none;
cursor: pointer;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
@media ${devices.mobile} {
flex-direction: row;
justify-content: flex-start;
}
`
type LogoProps = {
team: Teams,
}
@ -80,6 +91,7 @@ export const PlayerName = styled(Name)`
line-height: 10px;
text-align: center;
letter-spacing: 0.02em;
color: #fff;
@media ${devices.mobile} {
text-align: start;

@ -23,7 +23,11 @@ const ListsWrapper = styled.div`
`
export const PlayersListDesktop = () => {
const { match, matchPlaylists } = useMatchPopupStore()
const {
handlePlayerClick,
match,
matchPlaylists,
} = useMatchPopupStore()
if (!match || !matchPlaylists) return null
@ -35,13 +39,15 @@ export const PlayersListDesktop = () => {
<ListsWrapper>
<PlayersList
team={Teams.TEAM1}
players={matchPlaylists.players1}
players={matchPlaylists.players.team1}
sportType={match.sportType}
onClick={handlePlayerClick}
/>
<PlayersList
team={Teams.TEAM2}
players={matchPlaylists.players2}
players={matchPlaylists.players.team2}
sportType={match.sportType}
onClick={handlePlayerClick}
/>
</ListsWrapper>
</Wrapper>

@ -14,14 +14,18 @@ import {
} from './styled'
export const PlayersListMobile = () => {
const { match, matchPlaylists } = useMatchPopupStore()
const {
handlePlayerClick,
match,
matchPlaylists,
} = useMatchPopupStore()
const [selectedTeam, setSelectedTeam] = useState<Teams>(Teams.TEAM1)
if (!match || !matchPlaylists) return null
const players = selectedTeam === Teams.TEAM1
? matchPlaylists.players1
: matchPlaylists.players2
? matchPlaylists.players.team1
: matchPlaylists.players.team2
return (
<Wrapper>
@ -46,6 +50,7 @@ export const PlayersListMobile = () => {
team={selectedTeam}
players={players}
sportType={match.sportType}
onClick={handlePlayerClick}
/>
</Wrapper>
)

@ -1,14 +1,15 @@
import type { MouseEvent } from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import styled, { css } from 'styled-components/macro'
import { devices } from 'config'
import { secondsToHms } from 'helpers'
import { T9n } from 'features/T9n'
import { MatchPlaylistOption } from 'features/MatchPage/types'
const StyledLink = styled(Link)`
export const buttonStyles = css`
border: none;
cursor: pointer;
@ -37,12 +38,20 @@ const StyledLink = styled(Link)`
}
`
const Title = styled.span`
const StyledLink = styled(Link)`
${buttonStyles}
`
type TitleProps = {
textTransform?: 'uppercase' | 'capitalize',
}
export const Title = styled.span<TitleProps>`
font-weight: 500;
font-size: 20px;
line-height: 50px;
letter-spacing: 0.03em;
text-transform: uppercase;
text-transform: ${({ textTransform = 'uppercase' }) => textTransform};
color: #ffffff;
@media ${devices.mobile} {
@ -53,7 +62,7 @@ const Title = styled.span`
}
`
const Duration = styled(Title)`
export const Duration = styled(Title)`
font-weight: 300;
font-size: 24px;
letter-spacing: 0.05em;
@ -68,20 +77,24 @@ const Duration = styled(Title)`
const stopPropagation = (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation()
type Props = {
duration: number,
title: string,
playlist: MatchPlaylistOption,
to: string,
}
export const PlaylistButton = ({
duration,
title,
playlist,
to,
}: Props) => (
<StyledLink to={to} onClick={stopPropagation}>
<StyledLink
to={{
pathname: to,
state: { selectedPlaylist: playlist },
}}
onClick={stopPropagation}
>
<Title>
<T9n t={title} />
<T9n t={playlist.lexic} />
</Title>
<Duration>{secondsToHms(duration)}</Duration>
{playlist.duration && <Duration>{secondsToHms(playlist.duration)}</Duration>}
</StyledLink>
)

@ -8,7 +8,6 @@ import { SettingsButton } from '../SettingsButton'
import { CloseButton } from '../CloseButton'
import { BackButton } from '../BackButton'
import { MatchPlaylist } from '../MatchPlaylist'
import { Interviews } from '../Interviews'
import { PlayersListDesktop } from '../PlayersListDesktop'
import { PlayersListMobile } from '../PlayersListMobile'
import {
@ -52,8 +51,6 @@ export const PlaylistPage = () => {
<Fragment>
<MatchPlaylist />
<Interviews />
<MediaQuery maxDevice='mobile'>
<PlayersListMobile />
</MediaQuery>

@ -1,4 +0,0 @@
export enum Teams {
TEAM1,
TEAM2,
}

@ -2,19 +2,22 @@ import { useState, useEffect } from 'react'
import isEmpty from 'lodash/isEmpty'
import type { MatchPlaylists } from 'requests'
import { getMatchPlaylists } from 'requests'
import { buildPlaylists } from 'features/MatchPage/helpers/buildPlaylists'
import { Playlists } from 'features/MatchPage/types'
import { useSettingsState } from './useSettingsState'
import { useSportActions } from './useSportActions'
import { usePopupNavigation } from './usePopupNavigation'
import type { MatchData } from '../../types'
import { PopupPages, PlayerPlaylistFormats } from '../../types'
import { usePlayerClickHandler } from './usePlayerClickHandler'
export const useMatchPopup = () => {
const [match, setMatch] = useState<MatchData>(null)
const [matchPlaylists, setMatchPlaylists] = useState<MatchPlaylists | null>(null)
const [matchPlaylists, setMatchPlaylists] = useState<Playlists | null>(null)
const {
closePopup,
goBack,
@ -25,15 +28,19 @@ export const useMatchPopup = () => {
} = usePopupNavigation()
const {
episodeDuration,
resetSelectedActions,
selectedActions,
selectedPlaylistFormat,
setEpisodeDuration,
setSelectedActions,
setSelectedPlaylistFormat,
settings,
} = useSettingsState(match?.sportType)
const {
episodeDuration,
selectedActions,
selectedFormat: selectedPlaylistFormat,
} = settings
const { actions, fetchSportActions } = useSportActions(match?.sportType)
useEffect(() => {
@ -73,7 +80,8 @@ export const useMatchPopup = () => {
// запрос с экшнами [1, 2, 3] временный
selectedActions: isEmpty(selectedActions) ? [1, 2, 3] : selectedActions,
sportType: match.sportType,
}).then(setMatchPlaylists)
}).then(buildPlaylists)
.then(setMatchPlaylists)
}, [
isOpen,
match,
@ -98,5 +106,6 @@ export const useMatchPopup = () => {
selectedActions,
selectedPlaylistFormat,
setMatch,
...usePlayerClickHandler(match, settings),
}
}

@ -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 }
}

@ -6,29 +6,15 @@ import { SportTypes } from 'config'
import { useLocalStore } from 'hooks'
import { PlayerPlaylistFormats } from '../../types'
export type SelectedActions = Array<number>
export type EpisodeDuration = {
after: number,
before: number,
}
type Settings = {
episodeDuration: EpisodeDuration,
selectedActions: SelectedActions,
selectedFormat: PlayerPlaylistFormats,
}
type SettingsBySport = Partial<Record<SportTypes, Settings>>
import type {
SettingsBySport,
Settings,
SelectedActions,
EpisodeDuration,
} from '../../types'
import { PlayerPlaylistFormats, defaultSettings } from '../../types'
const selectedActionsKey = 'playlist_settings'
const defaultSettings: Settings = {
episodeDuration: {
after: 6,
before: 6,
},
selectedActions: [],
selectedFormat: PlayerPlaylistFormats.ALL_MATCH_TIME,
}
const validator = (value: unknown) => Boolean(value) && isObject(value)
export const useSettingsState = (sportType?: SportTypes) => {
@ -81,12 +67,10 @@ export const useSettingsState = (sportType?: SportTypes) => {
const settings = useMemo(getSettings, [getSettings])
return {
episodeDuration: settings.episodeDuration,
resetSelectedActions,
selectedActions: settings.selectedActions,
selectedPlaylistFormat: settings.selectedFormat,
setEpisodeDuration,
setSelectedActions,
setSelectedPlaylistFormat,
settings,
}
}

@ -1,3 +1,5 @@
import { SportTypes } from 'config'
import type { Match } from 'features/Matches/hooks'
export type MatchData = Pick<Match, (
@ -22,3 +24,25 @@ export enum Teams {
TEAM1,
TEAM2,
}
export type SelectedActions = Array<number>
export type EpisodeDuration = {
after: number,
before: number,
}
export type Settings = {
episodeDuration: EpisodeDuration,
selectedActions: SelectedActions,
selectedFormat: PlayerPlaylistFormats,
}
export type SettingsBySport = Partial<Record<SportTypes, Settings>>
export const defaultSettings: Settings = {
episodeDuration: {
after: 6,
before: 6,
},
selectedActions: [],
selectedFormat: PlayerPlaylistFormats.ALL_MATCH_TIME,
}

@ -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
}

@ -6,8 +6,9 @@ it('return correct progress and width lengthes', () => {
let chapter = {
duration: 15000,
endMs: 20000,
period: '',
period: 0,
startMs: 5000,
urls: {},
}
let expected = {
...chapter,
@ -16,6 +17,7 @@ it('return correct progress and width lengthes', () => {
width: 25,
}
expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter],
loadedProgress: 30000,
playedProgress: 30000,
@ -25,8 +27,9 @@ it('return correct progress and width lengthes', () => {
chapter = {
duration: 30000,
endMs: 30000,
period: '',
period: 0,
startMs: 0,
urls: {},
}
expected = {
...chapter,
@ -35,6 +38,7 @@ it('return correct progress and width lengthes', () => {
width: 50,
}
expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter],
loadedProgress: 15000,
playedProgress: 15000,

@ -1,10 +1,16 @@
import map from 'lodash/map'
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 } from 'features/MultiSourcePlayer/types'
import type { Chapters, Chapter } from 'features/MultiSourcePlayer/types'
import { calculateChapterProgress } from '../calculateChapterProgress'
const calculateChapterProgress = (progress: number, chapter: Chapter) => (
Math.min(progress * 100 / chapter.duration, 100)
)
type Args = {
activeChapterIndex: number,
chapters: Chapters,
loadedProgress: number,
playedProgress: number,
@ -12,15 +18,42 @@ type Args = {
}
export const calculateChapterStyles = ({
activeChapterIndex,
chapters,
loadedProgress,
playedProgress,
videoDuration,
}: Args) => (
map(chapters, (chapter) => ({
}: Args) => {
const playedChapters = pipe(
slice(0, activeChapterIndex),
map((chapter: Chapter) => ({
...chapter,
loaded: 100,
played: 100,
width: chapter.duration * 100 / videoDuration,
})),
)(chapters)
const comingChapters = pipe(
slice(activeChapterIndex + 1, size(chapters)),
map((chapter: Chapter) => ({
...chapter,
loaded: 0,
played: 0,
width: chapter.duration * 100 / videoDuration,
})),
)(chapters)
const chapter = chapters[activeChapterIndex]
const activeChapter = {
...chapter,
loaded: calculateChapterProgress(loadedProgress, chapter),
played: calculateChapterProgress(playedProgress, chapter),
width: chapter.duration * 100 / videoDuration,
}))
)
}
return [
...playedChapters,
activeChapter,
...comingChapters,
]
}

@ -1,17 +1,21 @@
import { useMemo } from 'react'
import { secondsToHms } from 'helpers'
import type { Chapters } from '../../types'
import { calculateChapterStyles } from './helpers/calculateChapterStyles'
export type Props = {
activeChapterIndex: number,
chapters?: Chapters,
duration: number,
loadedProgress: number,
onPlayedProgressChange: (progress: number) => void,
onPlayedProgressChange: (progress: number, seeking: boolean) => void,
playedProgress: number,
}
export const useProgressBar = ({
activeChapterIndex,
chapters = [],
duration,
loadedProgress,
@ -19,20 +23,25 @@ export const useProgressBar = ({
}: Props) => {
const calculatedChapters = useMemo(
() => calculateChapterStyles({
activeChapterIndex,
chapters,
loadedProgress,
playedProgress,
videoDuration: duration,
}),
[
activeChapterIndex,
loadedProgress,
playedProgress,
duration,
chapters,
],
)
const played = playedProgress + chapters[activeChapterIndex].startMs
return {
calculatedChapters,
playedProgressInPercent: playedProgress * 100 / duration,
playedProgressInPercent: Math.min(played * 100 / duration, 100),
time: secondsToHms(played / 1000),
}
}

@ -1,5 +1,3 @@
import { secondsToHms } from 'helpers'
import { useSlider } from 'features/StreamPlayer/hooks/useSlider'
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip'
import { Scrubber } from 'features/StreamPlayer/components/ProgressBar/styled'
@ -10,15 +8,19 @@ import { useProgressBar } from './hooks'
import { ProgressBarList } from './styled'
export const ProgressBar = (props: Props) => {
const { onPlayedProgressChange, playedProgress } = props
const { onPlayedProgressChange } = props
const progressBarRef = useSlider({ onChange: onPlayedProgressChange })
const { calculatedChapters, playedProgressInPercent } = useProgressBar(props)
const {
calculatedChapters,
playedProgressInPercent,
time,
} = useProgressBar(props)
return (
<ProgressBarList ref={progressBarRef}>
<Chapters chapters={calculatedChapters} />
<Scrubber style={{ left: `${playedProgressInPercent}%` }}>
<TimeTooltip time={secondsToHms(playedProgress / 1000)} />
<TimeTooltip time={time} />
</Scrubber>
</ProgressBarList>
)

@ -53,6 +53,7 @@ const chapters = [
endMs: 30000,
period: 0,
startMs: 0,
startOffsetMs: 0,
urls: {},
},
{
@ -60,6 +61,7 @@ const chapters = [
endMs: 60000,
period: 0,
startMs: 30000,
startOffsetMs: 0,
urls: {},
},
{
@ -67,12 +69,14 @@ const chapters = [
endMs: 70000,
period: 0,
startMs: 60000,
startOffsetMs: 0,
urls: {},
},
]
export const Empty = () => renderInControls(
<ProgressBar
activeChapterIndex={0}
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
@ -83,6 +87,7 @@ export const Empty = () => renderInControls(
export const HalfLoaded = () => renderInControls(
<ProgressBar
activeChapterIndex={0}
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
@ -93,6 +98,7 @@ export const HalfLoaded = () => renderInControls(
export const HalfPlayed = () => renderInControls(
<ProgressBar
activeChapterIndex={1}
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}
@ -103,6 +109,7 @@ export const HalfPlayed = () => renderInControls(
export const Loaded40AndPlayed20 = () => renderInControls(
<ProgressBar
activeChapterIndex={0}
duration={duration}
chapters={chapters}
onPlayedProgressChange={callback}

@ -26,8 +26,8 @@ export const preparePlayer = ({
video.load()
}
export const findChapterByProgress = (chapters: Chapters, progress: number) => (
export const findChapterByProgress = (chapters: Chapters, progressMs: number) => (
findIndex(chapters, ({ endMs, startMs }) => (
startMs / 1000 <= progress && progress <= endMs / 1000
startMs <= progressMs && progressMs <= endMs
))
)

@ -2,58 +2,84 @@ import type { MouseEvent } from 'react'
import {
useCallback,
useEffect,
useState,
useRef,
} from 'react'
import size from 'lodash/size'
import type { LastPlayPosition, Videos } from 'requests'
import type { LastPlayPosition } from 'requests'
import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen'
import { useVolume } from 'features/VideoPlayer/hooks/useVolume'
import { useObjectState } from 'hooks'
import { useProgressChangeHandler } from './useProgressChangeHandler'
import { usePlayingState } from './usePlayingState'
import { usePlayingHandlers } from './usePlayingHandlers'
import { useVideoQuality } from './useVideoQuality'
import { useDuration } from './useDuration'
import { useVideos } from './useVideos'
import type { Chapters } from '../types'
export type PlayerState = {
activeChapterIndex: number,
loadedProgress: number,
playedProgress: number,
playing: boolean,
ready: boolean,
seek: number,
seeking: boolean,
}
const initialState: PlayerState = {
activeChapterIndex: 0,
loadedProgress: 0,
playedProgress: 0,
playing: false,
ready: false,
seek: 0,
seeking: false,
}
export type Props = {
chapters: Chapters,
onError?: () => void,
onPlayingChange: (playing: boolean) => void,
onProgressChange: (seconds: number, period: number) => void,
resumeFrom: LastPlayPosition,
videos: Videos,
}
export const useMultiSourcePlayer = ({
chapters,
onError,
onPlayingChange,
onProgressChange: onProgressChangeCallback,
resumeFrom,
videos,
}: Props) => {
const activeChapterIndex = useRef(resumeFrom.half)
const [
{
activeChapterIndex,
loadedProgress,
playedProgress,
playing,
seek,
seeking,
},
setPlayerState,
] = useObjectState({ ...initialState, activeChapterIndex: resumeFrom.half })
const videoRef = useRef<HTMLVideoElement>(null)
const [seek, setSeek] = useState(resumeFrom.second)
const [loadedProgress, setLoadedProgress] = useState(0)
const [playedProgress, setPlayedProgress] = useState(0)
const {
onReady,
playing,
startPlaying,
stopPlaying,
togglePlaying,
} = usePlayingState()
} = usePlayingHandlers(setPlayerState)
const {
selectedQuality,
setSelectedQuality,
videoQualities,
} = useVideoQuality(videos)
} = useVideoQuality(chapters)
const { chapters } = useVideos(videos)
const duration = useDuration(chapters)
const handleError = useCallback(() => {
@ -65,31 +91,29 @@ export const useMultiSourcePlayer = ({
activeChapterIndex,
chapters,
duration,
setPlayedProgress,
setSeek,
setPlayerState,
})
const getActiveChapterUrl = useCallback((quality: string = selectedQuality) => (
chapters[activeChapterIndex.current].urls[quality]
), [selectedQuality, chapters])
const getActiveChapterStart = useCallback(() => (
chapters[activeChapterIndex.current]?.startMs || 0
), [chapters])
chapters[activeChapterIndex].urls[quality]
), [selectedQuality, chapters, activeChapterIndex])
const onQualitySelect = (quality: string) => {
setSeek(videoRef.current?.currentTime || 0)
setPlayerState({
seek: videoRef.current?.currentTime ?? 0,
})
setSelectedQuality(quality)
}
const playNextChapter = () => {
activeChapterIndex.current += 1
const isLastChapterPlayed = activeChapterIndex.current === size(chapters)
if (isLastChapterPlayed) {
activeChapterIndex.current = 0
stopPlaying()
}
}
const playNextChapter = useCallback(() => {
const nextIndex = (activeChapterIndex + 1) % size(chapters)
setPlayerState({
activeChapterIndex: nextIndex,
loadedProgress: 0,
playedProgress: 0,
playing: nextIndex !== 0,
})
}, [activeChapterIndex, chapters, setPlayerState])
const onPlayerClick = (e: MouseEvent<HTMLDivElement>) => {
if (e.target === videoRef.current) {
@ -98,13 +122,17 @@ export const useMultiSourcePlayer = ({
}
const onLoadedProgress = (loadedMs: number) => {
const chapterStart = getActiveChapterStart()
setLoadedProgress(chapterStart + loadedMs)
const chapter = chapters[activeChapterIndex]
const value = loadedMs - chapter.startOffsetMs
setPlayerState({ loadedProgress: value })
}
const onPlayedProgress = (playedMs: number) => {
const chapterStart = getActiveChapterStart()
setPlayedProgress(chapterStart + playedMs)
const chapter = chapters[activeChapterIndex]
const value = Math.max(playedMs - chapter.startOffsetMs, 0)
setPlayerState({ playedProgress: value })
}
useEffect(() => {
@ -113,15 +141,30 @@ export const useMultiSourcePlayer = ({
useEffect(() => {
const progressSeconds = playedProgress / 1000
const { period } = chapters[activeChapterIndex.current]
const { period } = chapters[activeChapterIndex]
onProgressChangeCallback(progressSeconds, Number(period))
}, [
playedProgress,
chapters,
onProgressChangeCallback,
activeChapterIndex,
])
useEffect(() => {
const { duration: chapterDuration } = chapters[activeChapterIndex]
if (playedProgress >= chapterDuration && !seeking) {
playNextChapter()
}
}, [
activeChapterIndex,
playedProgress,
seeking,
playNextChapter,
chapters,
])
return {
activeChapterIndex,
activeSrc: getActiveChapterUrl(),
chapters,
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,13 +1,14 @@
import map from 'lodash/map'
import keys from 'lodash/keys'
import uniq from 'lodash/uniq'
import orderBy from 'lodash/orderBy'
import includes from 'lodash/includes'
import type { Videos } from 'requests'
import { useLocalStore } from 'hooks'
const getVideoQualities = (videos: Videos) => {
const qualities = uniq(map(videos, 'quality'))
import type { Chapters } from '../types'
const getVideoQualities = (chapters: Chapters) => {
const qualities = uniq(keys(chapters[0]?.urls))
return orderBy(
qualities,
Number,
@ -15,8 +16,8 @@ const getVideoQualities = (videos: Videos) => {
)
}
export const useVideoQuality = (videos: Videos) => {
const videoQualities = getVideoQualities(videos)
export const useVideoQuality = (chapters: Chapters) => {
const videoQualities = getVideoQualities(chapters)
const qualityValidator = (localStorageQuality: string) => (
includes(videoQualities, localStorageQuality)

@ -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 }
}

@ -14,6 +14,7 @@ import { useMultiSourcePlayer } from './hooks'
export const MultiSourcePlayer = (props: Props) => {
const {
activeChapterIndex,
activeSrc,
chapters,
duration,
@ -55,6 +56,7 @@ export const MultiSourcePlayer = (props: Props) => {
volume={volume}
ref={videoRef}
seek={seek}
isFullscreen={isFullscreen}
onLoadedProgress={onLoadedProgress}
onPlayedProgress={onPlayedProgress}
onEnded={playNextChapter}
@ -70,6 +72,7 @@ export const MultiSourcePlayer = (props: Props) => {
onClick={onVolumeClick}
/>
<ProgressBar
activeChapterIndex={activeChapterIndex}
duration={duration}
chapters={chapters}
onPlayedProgressChange={onProgressChange}

@ -5,6 +5,7 @@ export type Chapter = {
endMs: number,
period: number,
startMs: number,
startOffsetMs: number,
urls: Urls,
}

@ -9,37 +9,47 @@ const getNormalizedProgress = (fraction: number) => {
}
type Args = {
onChange: (progress: number) => void,
onChange: (progress: number, seeking: boolean) => void,
}
export const useSlider = ({ onChange }: Args) => {
const ref = useRef<HTMLDivElement>(null)
const lastProgress = useRef(0)
const {
close: setMouseDown,
isOpen: isMouseUp,
open: setMouseUp,
} = useToggle(true)
const handleProgress = (mouseX: number) => {
const handleProgress = (mouseX: number, seeking: boolean) => {
const track = ref.current
if (!track) return
const x = mouseX - track.getBoundingClientRect().left
const progress = getNormalizedProgress(x / track.offsetWidth)
onChange(progress)
lastProgress.current = progress
onChange(progress, seeking)
}
const mouseDownListener = ({ clientX, target }: MouseEvent) => {
const track = ref.current
if (target === track || track?.contains(target as Node)) {
setMouseDown()
handleProgress(clientX)
handleProgress(clientX, !isMouseUp)
}
}
const mouseUpListener = () => {
if (!isMouseUp) {
onChange(lastProgress.current, false)
setMouseUp()
lastProgress.current = 0
}
}
const mouseMoveListener = (e: MouseEvent) => {
if (isMouseUp) return
handleProgress(e.clientX)
handleProgress(e.clientX, !isMouseUp)
}
useEventListener({
@ -47,7 +57,7 @@ export const useSlider = ({ onChange }: Args) => {
event: 'mousedown',
})
useEventListener({
callback: setMouseUp,
callback: mouseUpListener,
event: 'mouseup',
})
useEventListener({

@ -57,6 +57,7 @@ export const StreamPlayer = (props: Props) => {
volume={volume}
muted={muted}
seek={seek}
isFullscreen={isFullscreen}
onLoadedProgress={onLoadedProgress}
onPlayedProgress={onPlayedProgress}
onDurationChange={onDuration}

@ -15,6 +15,7 @@ type Ref = Parameters<ForwardRefRenderFunction<HTMLVideoElement>>[1]
export type Props = {
className?: string,
height?: string,
isFullscreen?: boolean,
muted?: boolean,
onDurationChange?: (durationMs: number) => void,
onEnded?: (e: SyntheticEvent<HTMLVideoElement>) => void,
@ -37,8 +38,8 @@ const useVideoRef = (ref?: Ref) => {
}
export const useVideoPlayer = ({
isFullscreen,
onDurationChange,
onError,
onLoadedProgress,
onPlayedProgress,
onReady,
@ -82,7 +83,7 @@ export const useVideoPlayer = ({
if (playing) {
// автовоспроизведение со звуком иногда может не сработать
// https://developers.google.com/web/updates/2017/09/autoplay-policy-changes#new-behaviors
video.play().catch(onError)
video.play().catch()
} else {
video.pause()
}
@ -90,7 +91,6 @@ export const useVideoPlayer = ({
ready,
playing,
src,
onError,
videoRef,
])
@ -101,6 +101,10 @@ export const useVideoPlayer = ({
}
}, [volume, videoRef])
useEffect(() => {
videoRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [isFullscreen, videoRef])
return {
handleDurationChange,
handleReady,

@ -28,6 +28,8 @@ type Args = {
export const getProfileFallbackLogo = ({
profileType,
sportType = 1,
}: Args) => (
FALLBACK_LOGOS[sportType][profileType]
)
}: Args) => {
if (profileType === ProfileTypes.MATCHES) return ''
return FALLBACK_LOGOS[sportType][profileType]
}

@ -5,3 +5,4 @@ export * from './useSportNameParam'
export * from './useStorage'
export * from './useInterval'
export * from './useEventListener'
export * from './useObjectState'

@ -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
}

@ -15,7 +15,7 @@ type Args = {
sportType: SportTypes,
}
type PlaylistData = {
export type Episode = {
/** episode end */
e: number,
@ -26,8 +26,10 @@ type PlaylistData = {
s: number,
}
type Playlist = {
data: Array<PlaylistData>,
export type PlaylistData = Array<Episode>
type PlaylistWithDuration = {
data: PlaylistData,
dur: number,
}
@ -41,10 +43,10 @@ type Player = {
export type Players = Array<Player>
export type MatchPlaylists = {
ball_in_play: Playlist,
fullMatchDuration: number,
goals: Playlist,
highlights: Playlist,
ball_in_play: PlaylistWithDuration,
full_match: PlaylistWithDuration,
goals: PlaylistWithDuration,
highlights: PlaylistWithDuration,
players1: Players,
players2: Players,
}
@ -79,7 +81,12 @@ export const getMatchPlaylists = async ({
[playlistPromise, matchDurationPromise],
)
const full_match: PlaylistWithDuration = {
data: [],
dur: fullMatchDuration,
}
return playlist.data
? { ...playlist.data, fullMatchDuration }
? { ...playlist.data, full_match }
: null
}

@ -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 || []
}

@ -23,3 +23,4 @@ export * from './getMatchLastWatchSeconds'
export * from './getMatchesPreviewImages'
export * from './getSportActions'
export * from './getMatchPlaylists'
export * from './getPlayerPlaylists'

@ -50,7 +50,7 @@ const filterResults = ({
tournaments: filter(results.tournaments, filterFunc),
}
if (isNull(profileType)) return filteredResults
if (isNull(profileType) || profileType === ProfileTypes.MATCHES) return filteredResults
const key = PROFILE_NAMES[profileType]
return { [key]: filteredResults[key] }

Loading…
Cancel
Save