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. 50
      src/features/MatchPopup/components/MatchPlaylist/index.tsx
  19. 2
      src/features/MatchPopup/components/PlayerActions/index.tsx
  20. 24
      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, go_back_to_match: 13405,
match_interviews: 13031, match_interviews: 13031,
match_playlist_ball_in_play: 2489, match_playlist_ball_in_play: 2489,
match_playlist_full_game: 13028, match_playlist_full_match: 13028,
match_playlist_goals: 3559, match_playlist_goals: 3559,
match_playlist_highlights: 13033, match_playlist_highlights: 13033,
match_settings: 13490, match_settings: 13490,

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

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

@ -10,14 +10,15 @@ export const Wrapper = styled.div`
line-height: 24px; line-height: 24px;
color: white; color: white;
min-height: 28px; min-height: 28px;
max-width: 85%; width: 100%;
margin-bottom: 14px;
@media ${devices.desktop} { @media ${devices.desktop} {
font-size: 22px; font-size: 22px;
} }
@media ${devices.laptop} { @media ${devices.laptop} {
font-size: 18px; font-size: 18px;
max-width: 80%;
} }
@media ${devices.tablet} { @media ${devices.tablet} {
@ -25,6 +26,7 @@ export const Wrapper = styled.div`
font-size: 16px; font-size: 16px;
padding: 15px 20px 0 20px; padding: 15px 20px 0 20px;
flex-wrap: wrap; 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 isEmpty from 'lodash/isEmpty'
import { StreamPlayer } from 'features/StreamPlayer'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { ProfileHeader } from 'features/ProfileHeader' import { ProfileHeader } from 'features/ProfileHeader'
import { MainWrapper } from 'features/MainWrapper' import { MainWrapper } from 'features/MainWrapper'
import { UserFavorites } from 'features/UserFavorites' import { UserFavorites } from 'features/UserFavorites'
import { MediaQuery } from 'features/MediaQuery' import { MediaQuery } from 'features/MediaQuery'
import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen' import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { MatchProfileCard } from './MatchProfileCard' import { StreamPlayer } from 'features/StreamPlayer'
import { usePlayerProgressReporter } from './hooks/usePlayerProgressReporter'
import { MatchProfileCard } from './components/MatchProfileCard'
import { useMatchProfile } from './hooks/useMatchProfile' import { useMatchProfile } from './hooks/useMatchProfile'
import { useVideoData } from './hooks/useVideoData'
import { import {
MainWrapper as Wrapper, MainWrapper as Wrapper,
Container, Container,
EmptyScrollTarget,
} from './styled' } from './styled'
import { useMatchPage } from './hooks/useMatchPage'
import { usePlayerProgressReporter } from './hooks/usePlayerProgressReporter'
export const MatchPage = () => { export const MatchPage = () => {
const profile = useMatchProfile() const profile = useMatchProfile()
const { const {
chapters,
isLastPlayPositionFetching, isLastPlayPositionFetching,
lastPlayPosition, lastPlayPosition,
url, url,
videos, } = useMatchPage()
} = useVideoData()
const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter() const { onPlayerProgressChange, onPlayingChange } = usePlayerProgressReporter()
const isLiveMatch = Boolean(url) && !isLastPlayPositionFetching const isLiveMatch = Boolean(url) && !isLastPlayPositionFetching
const isFinishedMatch = !isEmpty(videos) && !isLastPlayPositionFetching const isFinishedMatch = !isEmpty(chapters) && !isLastPlayPositionFetching
const { onFullscreenClick } = useFullscreen()
const playerEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
playerEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [onFullscreenClick])
return ( return (
<MainWrapper> <MainWrapper>
@ -47,8 +37,8 @@ export const MatchPage = () => {
</MediaQuery> </MediaQuery>
<ProfileHeader /> <ProfileHeader />
<Wrapper> <Wrapper>
<MatchProfileCard profile={profile} />
<Container> <Container>
<MatchProfileCard profile={profile} />
{ {
isLiveMatch && ( isLiveMatch && (
<StreamPlayer <StreamPlayer
@ -62,7 +52,7 @@ export const MatchPage = () => {
{ {
isFinishedMatch && ( isFinishedMatch && (
<MultiSourcePlayer <MultiSourcePlayer
videos={videos} chapters={chapters}
onPlayingChange={onPlayingChange} onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange} onProgressChange={onPlayerProgressChange}
resumeFrom={lastPlayPosition} resumeFrom={lastPlayPosition}
@ -71,7 +61,6 @@ export const MatchPage = () => {
} }
</Container> </Container>
</Wrapper> </Wrapper>
<EmptyScrollTarget ref={playerEndRef} />
</MainWrapper> </MainWrapper>
) )
} }

@ -1,26 +1,27 @@
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { devices } from 'config/devices' import { devices } from 'config/devices'
export const MainWrapper = styled.div` export const MainWrapper = styled.div`
margin: 63px 16px 0 16px; margin: 63px 16px 0 16px;
display: flex;
@media ${devices.laptop} { @media ${devices.laptop} {
margin: 0px 16px; margin: 0px 16px;
} }
@media ${devices.tablet} { @media ${devices.tablet} {
display: flex;
flex-direction: column; flex-direction: column;
margin: 0 0 16px 0; margin: 0 0 16px 0;
} }
` `
export const Container = styled.div` export const Container = styled.div`
max-width: 85%; max-width: 2360px;
max-height: 896px; max-height: 896px;
margin-top: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
@media ${devices.laptop} { @media ${devices.laptop} {
max-width: 80%; max-width: 80%;
@ -28,14 +29,9 @@ export const Container = styled.div`
@media ${devices.tablet} { @media ${devices.tablet} {
order: 1; order: 1;
margin-top: 0;
} }
@media ${devices.mobile} { @media ${devices.mobile} {
max-width: 100%; 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 isNaN from 'lodash/isNaN'
import type { EpisodeDuration } from '../../store/hooks/useSettingsState' import type { EpisodeDuration } from '../../types'
const LIMITS = { const LIMITS = {
max: 30, max: 30,

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

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

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

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

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

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

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

@ -8,7 +8,6 @@ import { SettingsButton } from '../SettingsButton'
import { CloseButton } from '../CloseButton' import { CloseButton } from '../CloseButton'
import { BackButton } from '../BackButton' import { BackButton } from '../BackButton'
import { MatchPlaylist } from '../MatchPlaylist' import { MatchPlaylist } from '../MatchPlaylist'
import { Interviews } from '../Interviews'
import { PlayersListDesktop } from '../PlayersListDesktop' import { PlayersListDesktop } from '../PlayersListDesktop'
import { PlayersListMobile } from '../PlayersListMobile' import { PlayersListMobile } from '../PlayersListMobile'
import { import {
@ -52,8 +51,6 @@ export const PlaylistPage = () => {
<Fragment> <Fragment>
<MatchPlaylist /> <MatchPlaylist />
<Interviews />
<MediaQuery maxDevice='mobile'> <MediaQuery maxDevice='mobile'>
<PlayersListMobile /> <PlayersListMobile />
</MediaQuery> </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 isEmpty from 'lodash/isEmpty'
import type { MatchPlaylists } from 'requests'
import { getMatchPlaylists } from 'requests' import { getMatchPlaylists } from 'requests'
import { buildPlaylists } from 'features/MatchPage/helpers/buildPlaylists'
import { Playlists } from 'features/MatchPage/types'
import { useSettingsState } from './useSettingsState' import { useSettingsState } from './useSettingsState'
import { useSportActions } from './useSportActions' import { useSportActions } from './useSportActions'
import { usePopupNavigation } from './usePopupNavigation' import { usePopupNavigation } from './usePopupNavigation'
import type { MatchData } from '../../types' import type { MatchData } from '../../types'
import { PopupPages, PlayerPlaylistFormats } from '../../types' import { PopupPages, PlayerPlaylistFormats } from '../../types'
import { usePlayerClickHandler } from './usePlayerClickHandler'
export const useMatchPopup = () => { export const useMatchPopup = () => {
const [match, setMatch] = useState<MatchData>(null) const [match, setMatch] = useState<MatchData>(null)
const [matchPlaylists, setMatchPlaylists] = useState<MatchPlaylists | null>(null) const [matchPlaylists, setMatchPlaylists] = useState<Playlists | null>(null)
const { const {
closePopup, closePopup,
goBack, goBack,
@ -25,15 +28,19 @@ export const useMatchPopup = () => {
} = usePopupNavigation() } = usePopupNavigation()
const { const {
episodeDuration,
resetSelectedActions, resetSelectedActions,
selectedActions,
selectedPlaylistFormat,
setEpisodeDuration, setEpisodeDuration,
setSelectedActions, setSelectedActions,
setSelectedPlaylistFormat, setSelectedPlaylistFormat,
settings,
} = useSettingsState(match?.sportType) } = useSettingsState(match?.sportType)
const {
episodeDuration,
selectedActions,
selectedFormat: selectedPlaylistFormat,
} = settings
const { actions, fetchSportActions } = useSportActions(match?.sportType) const { actions, fetchSportActions } = useSportActions(match?.sportType)
useEffect(() => { useEffect(() => {
@ -73,7 +80,8 @@ export const useMatchPopup = () => {
// запрос с экшнами [1, 2, 3] временный // запрос с экшнами [1, 2, 3] временный
selectedActions: isEmpty(selectedActions) ? [1, 2, 3] : selectedActions, selectedActions: isEmpty(selectedActions) ? [1, 2, 3] : selectedActions,
sportType: match.sportType, sportType: match.sportType,
}).then(setMatchPlaylists) }).then(buildPlaylists)
.then(setMatchPlaylists)
}, [ }, [
isOpen, isOpen,
match, match,
@ -98,5 +106,6 @@ export const useMatchPopup = () => {
selectedActions, selectedActions,
selectedPlaylistFormat, selectedPlaylistFormat,
setMatch, 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 { useLocalStore } from 'hooks'
import { PlayerPlaylistFormats } from '../../types' import type {
SettingsBySport,
export type SelectedActions = Array<number> Settings,
export type EpisodeDuration = { SelectedActions,
after: number, EpisodeDuration,
before: number, } from '../../types'
} import { PlayerPlaylistFormats, defaultSettings } from '../../types'
type Settings = {
episodeDuration: EpisodeDuration,
selectedActions: SelectedActions,
selectedFormat: PlayerPlaylistFormats,
}
type SettingsBySport = Partial<Record<SportTypes, Settings>>
const selectedActionsKey = 'playlist_settings' 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) const validator = (value: unknown) => Boolean(value) && isObject(value)
export const useSettingsState = (sportType?: SportTypes) => { export const useSettingsState = (sportType?: SportTypes) => {
@ -81,12 +67,10 @@ export const useSettingsState = (sportType?: SportTypes) => {
const settings = useMemo(getSettings, [getSettings]) const settings = useMemo(getSettings, [getSettings])
return { return {
episodeDuration: settings.episodeDuration,
resetSelectedActions, resetSelectedActions,
selectedActions: settings.selectedActions,
selectedPlaylistFormat: settings.selectedFormat,
setEpisodeDuration, setEpisodeDuration,
setSelectedActions, setSelectedActions,
setSelectedPlaylistFormat, setSelectedPlaylistFormat,
settings,
} }
} }

@ -1,3 +1,5 @@
import { SportTypes } from 'config'
import type { Match } from 'features/Matches/hooks' import type { Match } from 'features/Matches/hooks'
export type MatchData = Pick<Match, ( export type MatchData = Pick<Match, (
@ -22,3 +24,25 @@ export enum Teams {
TEAM1, TEAM1,
TEAM2, 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 = { let chapter = {
duration: 15000, duration: 15000,
endMs: 20000, endMs: 20000,
period: '', period: 0,
startMs: 5000, startMs: 5000,
urls: {},
} }
let expected = { let expected = {
...chapter, ...chapter,
@ -16,6 +17,7 @@ it('return correct progress and width lengthes', () => {
width: 25, width: 25,
} }
expect(calculateChapterStyles({ expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter], chapters: [chapter],
loadedProgress: 30000, loadedProgress: 30000,
playedProgress: 30000, playedProgress: 30000,
@ -25,8 +27,9 @@ it('return correct progress and width lengthes', () => {
chapter = { chapter = {
duration: 30000, duration: 30000,
endMs: 30000, endMs: 30000,
period: '', period: 0,
startMs: 0, startMs: 0,
urls: {},
} }
expected = { expected = {
...chapter, ...chapter,
@ -35,6 +38,7 @@ it('return correct progress and width lengthes', () => {
width: 50, width: 50,
} }
expect(calculateChapterStyles({ expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter], chapters: [chapter],
loadedProgress: 15000, loadedProgress: 15000,
playedProgress: 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 = { type Args = {
activeChapterIndex: number,
chapters: Chapters, chapters: Chapters,
loadedProgress: number, loadedProgress: number,
playedProgress: number, playedProgress: number,
@ -12,15 +18,42 @@ type Args = {
} }
export const calculateChapterStyles = ({ export const calculateChapterStyles = ({
activeChapterIndex,
chapters, chapters,
loadedProgress, loadedProgress,
playedProgress, playedProgress,
videoDuration, videoDuration,
}: Args) => ( }: Args) => {
map(chapters, (chapter) => ({ 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, ...chapter,
loaded: calculateChapterProgress(loadedProgress, chapter), loaded: calculateChapterProgress(loadedProgress, chapter),
played: calculateChapterProgress(playedProgress, chapter), played: calculateChapterProgress(playedProgress, chapter),
width: chapter.duration * 100 / videoDuration, width: chapter.duration * 100 / videoDuration,
})) }
) return [
...playedChapters,
activeChapter,
...comingChapters,
]
}

@ -1,17 +1,21 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { secondsToHms } from 'helpers'
import type { Chapters } from '../../types' import type { Chapters } from '../../types'
import { calculateChapterStyles } from './helpers/calculateChapterStyles' import { calculateChapterStyles } from './helpers/calculateChapterStyles'
export type Props = { export type Props = {
activeChapterIndex: number,
chapters?: Chapters, chapters?: Chapters,
duration: number, duration: number,
loadedProgress: number, loadedProgress: number,
onPlayedProgressChange: (progress: number) => void, onPlayedProgressChange: (progress: number, seeking: boolean) => void,
playedProgress: number, playedProgress: number,
} }
export const useProgressBar = ({ export const useProgressBar = ({
activeChapterIndex,
chapters = [], chapters = [],
duration, duration,
loadedProgress, loadedProgress,
@ -19,20 +23,25 @@ export const useProgressBar = ({
}: Props) => { }: Props) => {
const calculatedChapters = useMemo( const calculatedChapters = useMemo(
() => calculateChapterStyles({ () => calculateChapterStyles({
activeChapterIndex,
chapters, chapters,
loadedProgress, loadedProgress,
playedProgress, playedProgress,
videoDuration: duration, videoDuration: duration,
}), }),
[ [
activeChapterIndex,
loadedProgress, loadedProgress,
playedProgress, playedProgress,
duration, duration,
chapters, chapters,
], ],
) )
const played = playedProgress + chapters[activeChapterIndex].startMs
return { return {
calculatedChapters, 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 { useSlider } from 'features/StreamPlayer/hooks/useSlider'
import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip' import { TimeTooltip } from 'features/StreamPlayer/components/TimeTooltip'
import { Scrubber } from 'features/StreamPlayer/components/ProgressBar/styled' import { Scrubber } from 'features/StreamPlayer/components/ProgressBar/styled'
@ -10,15 +8,19 @@ import { useProgressBar } from './hooks'
import { ProgressBarList } from './styled' import { ProgressBarList } from './styled'
export const ProgressBar = (props: Props) => { export const ProgressBar = (props: Props) => {
const { onPlayedProgressChange, playedProgress } = props const { onPlayedProgressChange } = props
const progressBarRef = useSlider({ onChange: onPlayedProgressChange }) const progressBarRef = useSlider({ onChange: onPlayedProgressChange })
const { calculatedChapters, playedProgressInPercent } = useProgressBar(props) const {
calculatedChapters,
playedProgressInPercent,
time,
} = useProgressBar(props)
return ( return (
<ProgressBarList ref={progressBarRef}> <ProgressBarList ref={progressBarRef}>
<Chapters chapters={calculatedChapters} /> <Chapters chapters={calculatedChapters} />
<Scrubber style={{ left: `${playedProgressInPercent}%` }}> <Scrubber style={{ left: `${playedProgressInPercent}%` }}>
<TimeTooltip time={secondsToHms(playedProgress / 1000)} /> <TimeTooltip time={time} />
</Scrubber> </Scrubber>
</ProgressBarList> </ProgressBarList>
) )

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

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

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

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

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

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

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

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

@ -5,3 +5,4 @@ export * from './useSportNameParam'
export * from './useStorage' export * from './useStorage'
export * from './useInterval' export * from './useInterval'
export * from './useEventListener' 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, sportType: SportTypes,
} }
type PlaylistData = { export type Episode = {
/** episode end */ /** episode end */
e: number, e: number,
@ -26,8 +26,10 @@ type PlaylistData = {
s: number, s: number,
} }
type Playlist = { export type PlaylistData = Array<Episode>
data: Array<PlaylistData>,
type PlaylistWithDuration = {
data: PlaylistData,
dur: number, dur: number,
} }
@ -41,10 +43,10 @@ type Player = {
export type Players = Array<Player> export type Players = Array<Player>
export type MatchPlaylists = { export type MatchPlaylists = {
ball_in_play: Playlist, ball_in_play: PlaylistWithDuration,
fullMatchDuration: number, full_match: PlaylistWithDuration,
goals: Playlist, goals: PlaylistWithDuration,
highlights: Playlist, highlights: PlaylistWithDuration,
players1: Players, players1: Players,
players2: Players, players2: Players,
} }
@ -79,7 +81,12 @@ export const getMatchPlaylists = async ({
[playlistPromise, matchDurationPromise], [playlistPromise, matchDurationPromise],
) )
const full_match: PlaylistWithDuration = {
data: [],
dur: fullMatchDuration,
}
return playlist.data return playlist.data
? { ...playlist.data, fullMatchDuration } ? { ...playlist.data, full_match }
: null : 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 './getMatchesPreviewImages'
export * from './getSportActions' export * from './getSportActions'
export * from './getMatchPlaylists' export * from './getMatchPlaylists'
export * from './getPlayerPlaylists'

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

Loading…
Cancel
Save